feat: auto-complete recent history entries in URL bar (#3141)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
@@ -173,6 +173,7 @@
|
|||||||
"folder": "Folder is empty",
|
"folder": "Folder is empty",
|
||||||
"headers": "This request does not have any headers",
|
"headers": "This request does not have any headers",
|
||||||
"history": "History is empty",
|
"history": "History is empty",
|
||||||
|
"history_suggestions": "History does not have any matching entries",
|
||||||
"invites": "Invite list is empty",
|
"invites": "Invite list is empty",
|
||||||
"members": "Team is empty",
|
"members": "Team is empty",
|
||||||
"parameters": "This request does not have any parameters",
|
"parameters": "This request does not have any parameters",
|
||||||
|
|||||||
@@ -44,11 +44,7 @@
|
|||||||
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
||||||
>
|
>
|
||||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||||
<span class="my-2 text-center flex flex-col">
|
</HoppSmartPlaceholder>
|
||||||
{{ t("state.nothing_found") }}
|
|
||||||
<span class="break-all">"{{ filterText }}"</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col divide-y divide-dividerLight">
|
<div v-else class="flex flex-col divide-y divide-dividerLight">
|
||||||
<details
|
<details
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 overflow-x-auto sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
|
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
|
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
|
||||||
@@ -47,13 +47,14 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex flex-1 overflow-auto transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
class="flex flex-1 transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<SmartEnvInput
|
<SmartEnvInput
|
||||||
v-model="tab.document.request.endpoint"
|
v-model="tab.document.request.endpoint"
|
||||||
:placeholder="`${t('request.url')}`"
|
:placeholder="`${t('request.url')}`"
|
||||||
@enter="newSendRequest()"
|
:auto-complete-source="userHistories"
|
||||||
@paste="onPasteUrl($event)"
|
@paste="onPasteUrl($event)"
|
||||||
|
@enter="newSendRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +229,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useSetting } from "@composables/settings"
|
import { useSetting } from "@composables/settings"
|
||||||
import { useStreamSubscriber } from "@composables/stream"
|
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { refAutoReset, useVModel } from "@vueuse/core"
|
import { refAutoReset, useVModel } from "@vueuse/core"
|
||||||
import * as E from "fp-ts/Either"
|
import * as E from "fp-ts/Either"
|
||||||
@@ -259,6 +260,7 @@ import IconSave from "~icons/lucide/save"
|
|||||||
import IconShare2 from "~icons/lucide/share-2"
|
import IconShare2 from "~icons/lucide/share-2"
|
||||||
import { HoppRESTTab } from "~/helpers/rest/tab"
|
import { HoppRESTTab } from "~/helpers/rest/tab"
|
||||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||||
|
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
import { getCurrentStrategyID } from "~/helpers/network"
|
import { getCurrentStrategyID } from "~/helpers/network"
|
||||||
|
|
||||||
@@ -313,6 +315,12 @@ const clearAll = ref<any | null>(null)
|
|||||||
const copyRequestAction = ref<any | null>(null)
|
const copyRequestAction = ref<any | null>(null)
|
||||||
const saveRequestAction = ref<any | null>(null)
|
const saveRequestAction = ref<any | null>(null)
|
||||||
|
|
||||||
|
const history = useReadonlyStream<RESTHistoryEntry[]>(restHistory$, [])
|
||||||
|
|
||||||
|
const userHistories = computed(() => {
|
||||||
|
return history.value.map((history) => history.request.endpoint).slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
const newSendRequest = async () => {
|
const newSendRequest = async () => {
|
||||||
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
|
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
|
||||||
toast.error(`${t("empty.endpoint")}`)
|
toast.error(`${t("empty.endpoint")}`)
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="autocomplete-wrapper">
|
||||||
class="relative flex items-center flex-1 flex-shrink-0 py-4 overflow-auto whitespace-nowrap"
|
<div class="absolute inset-0 flex flex-1 overflow-x-auto">
|
||||||
>
|
|
||||||
<div class="absolute inset-0 flex flex-1">
|
|
||||||
<div
|
<div
|
||||||
ref="editor"
|
ref="editor"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
class="flex flex-1"
|
class="flex flex-1"
|
||||||
:class="styles"
|
:class="styles"
|
||||||
@keydown.enter.prevent="emit('enter', $event)"
|
|
||||||
@keyup="emit('keyup', $event)"
|
|
||||||
@click="emit('click', $event)"
|
@click="emit('click', $event)"
|
||||||
@keydown="emit('keydown', $event)"
|
@keydown="handleKeystroke"
|
||||||
|
@focusin="showSuggestionPopover = true"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
<ul
|
||||||
|
v-if="showSuggestionPopover && autoCompleteSource"
|
||||||
|
ref="suggestionsMenu"
|
||||||
|
class="suggestions"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(suggestion, index) in suggestions"
|
||||||
|
:key="`suggestion-${index}`"
|
||||||
|
:class="{ active: currentSuggestionIndex === index }"
|
||||||
|
@click="updateModelValue(suggestion)"
|
||||||
|
>
|
||||||
|
<span class="truncate py-0.5">
|
||||||
|
{{ suggestion }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="currentSuggestionIndex === index"
|
||||||
|
class="hidden md:flex text-secondary items-center"
|
||||||
|
>
|
||||||
|
<kbd class="shortcut-key">TAB</kbd>
|
||||||
|
<span class="ml-2 truncate">to select</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="suggestions.length === 0" class="pointer-events-none">
|
||||||
|
<span class="truncate py-0.5">
|
||||||
|
{{ t("empty.history_suggestions") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -35,6 +60,8 @@ import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironme
|
|||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import { onClickOutside } from "@vueuse/core"
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -46,6 +73,7 @@ const props = withDefaults(
|
|||||||
selectTextOnMount?: boolean
|
selectTextOnMount?: boolean
|
||||||
environmentHighlights?: boolean
|
environmentHighlights?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
autoCompleteSource?: string[]
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
@@ -55,6 +83,7 @@ const props = withDefaults(
|
|||||||
focus: false,
|
focus: false,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
environmentHighlights: true,
|
environmentHighlights: true,
|
||||||
|
autoCompleteSource: undefined,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,12 +97,160 @@ const emit = defineEmits<{
|
|||||||
(e: "click", ev: any): void
|
(e: "click", ev: any): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
const cachedValue = ref(props.modelValue)
|
const cachedValue = ref(props.modelValue)
|
||||||
|
|
||||||
const view = ref<EditorView>()
|
const view = ref<EditorView>()
|
||||||
|
|
||||||
const editor = ref<any | null>(null)
|
const editor = ref<any | null>(null)
|
||||||
|
|
||||||
|
const currentSuggestionIndex = ref(-1)
|
||||||
|
const showSuggestionPopover = ref(false)
|
||||||
|
|
||||||
|
const suggestionsMenu = ref<any | null>(null)
|
||||||
|
|
||||||
|
onClickOutside(suggestionsMenu, () => {
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
//filter autocompleteSource with unique values
|
||||||
|
const uniqueAutoCompleteSource = computed(() => {
|
||||||
|
if (props.autoCompleteSource) {
|
||||||
|
return [...new Set(props.autoCompleteSource)]
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const suggestions = computed(() => {
|
||||||
|
if (
|
||||||
|
props.modelValue &&
|
||||||
|
props.modelValue.length > 0 &&
|
||||||
|
uniqueAutoCompleteSource.value &&
|
||||||
|
uniqueAutoCompleteSource.value.length > 0
|
||||||
|
) {
|
||||||
|
return uniqueAutoCompleteSource.value.filter((suggestion) =>
|
||||||
|
suggestion.toLowerCase().includes(props.modelValue.toLowerCase())
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return uniqueAutoCompleteSource.value ?? []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateModelValue = (value: string) => {
|
||||||
|
emit("update:modelValue", value)
|
||||||
|
emit("change", value)
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeystroke = (ev: KeyboardEvent) => {
|
||||||
|
if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(ev.key)) {
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuggestionPopover.value = true
|
||||||
|
|
||||||
|
if (
|
||||||
|
["Enter", "Tab"].includes(ev.key) &&
|
||||||
|
suggestions.value.length > 0 &&
|
||||||
|
currentSuggestionIndex.value > -1
|
||||||
|
) {
|
||||||
|
updateModelValue(suggestions.value[currentSuggestionIndex.value])
|
||||||
|
currentSuggestionIndex.value = -1
|
||||||
|
|
||||||
|
//used to set codemirror cursor at the end of the line after selecting a suggestion
|
||||||
|
nextTick(() => {
|
||||||
|
view.value?.dispatch({
|
||||||
|
selection: EditorSelection.create([
|
||||||
|
EditorSelection.range(
|
||||||
|
props.modelValue.length,
|
||||||
|
props.modelValue.length
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "ArrowDown") {
|
||||||
|
scrollActiveElIntoView()
|
||||||
|
|
||||||
|
currentSuggestionIndex.value =
|
||||||
|
currentSuggestionIndex.value < suggestions.value.length - 1
|
||||||
|
? currentSuggestionIndex.value + 1
|
||||||
|
: suggestions.value.length - 1
|
||||||
|
|
||||||
|
emit("keydown", ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "ArrowUp") {
|
||||||
|
scrollActiveElIntoView()
|
||||||
|
|
||||||
|
currentSuggestionIndex.value =
|
||||||
|
currentSuggestionIndex.value - 1 >= 0
|
||||||
|
? currentSuggestionIndex.value - 1
|
||||||
|
: 0
|
||||||
|
|
||||||
|
emit("keyup", ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
emit("enter", ev)
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// used to scroll to the first suggestion when left arrow is pressed
|
||||||
|
if (ev.key === "ArrowLeft") {
|
||||||
|
if (suggestions.value.length > 0) {
|
||||||
|
currentSuggestionIndex.value = 0
|
||||||
|
nextTick(() => {
|
||||||
|
scrollActiveElIntoView()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// used to scroll to the last suggestion when right arrow is pressed
|
||||||
|
if (ev.key === "ArrowRight") {
|
||||||
|
if (suggestions.value.length > 0) {
|
||||||
|
currentSuggestionIndex.value = suggestions.value.length - 1
|
||||||
|
nextTick(() => {
|
||||||
|
scrollActiveElIntoView()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset currentSuggestionIndex showSuggestionPopover is false
|
||||||
|
watch(
|
||||||
|
() => showSuggestionPopover.value,
|
||||||
|
(newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
currentSuggestionIndex.value = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to scroll the active suggestion into view
|
||||||
|
*/
|
||||||
|
const scrollActiveElIntoView = () => {
|
||||||
|
const suggestionsMenuEl = suggestionsMenu.value
|
||||||
|
if (suggestionsMenuEl) {
|
||||||
|
const activeSuggestionEl = suggestionsMenuEl.querySelector(".active")
|
||||||
|
if (activeSuggestionEl) {
|
||||||
|
activeSuggestionEl.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
inline: "start",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
@@ -236,3 +413,49 @@ watch(editor, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.autocomplete-wrapper {
|
||||||
|
@apply relative;
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-1;
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
@apply whitespace-nowrap;
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
@apply absolute;
|
||||||
|
@apply bg-popover;
|
||||||
|
@apply z-50;
|
||||||
|
@apply shadow-lg;
|
||||||
|
@apply max-h-46;
|
||||||
|
@apply border-b border-x border-divider;
|
||||||
|
@apply overflow-y-auto;
|
||||||
|
@apply -left-[1px];
|
||||||
|
@apply right-0;
|
||||||
|
|
||||||
|
top: calc(100% + 1px);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
@apply flex;
|
||||||
|
@apply items-center;
|
||||||
|
@apply justify-between;
|
||||||
|
@apply w-full;
|
||||||
|
@apply py-2 px-4;
|
||||||
|
@apply text-secondary;
|
||||||
|
@apply cursor-pointer;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-radius: 0 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.active {
|
||||||
|
@apply bg-primaryDark;
|
||||||
|
@apply text-secondaryDark;
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user