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",
|
||||
"headers": "This request does not have any headers",
|
||||
"history": "History is empty",
|
||||
"history_suggestions": "History does not have any matching entries",
|
||||
"invites": "Invite list is empty",
|
||||
"members": "Team is empty",
|
||||
"parameters": "This request does not have any parameters",
|
||||
|
||||
@@ -44,11 +44,7 @@
|
||||
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center flex flex-col">
|
||||
{{ t("state.nothing_found") }}
|
||||
<span class="break-all">"{{ filterText }}"</span>
|
||||
</span>
|
||||
</div>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div v-else class="flex flex-col divide-y divide-dividerLight">
|
||||
<details
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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
|
||||
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
|
||||
@@ -47,13 +47,14 @@
|
||||
</label>
|
||||
</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
|
||||
v-model="tab.document.request.endpoint"
|
||||
:placeholder="`${t('request.url')}`"
|
||||
@enter="newSendRequest()"
|
||||
:auto-complete-source="userHistories"
|
||||
@paste="onPasteUrl($event)"
|
||||
@enter="newSendRequest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +229,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useStreamSubscriber } from "@composables/stream"
|
||||
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { refAutoReset, useVModel } from "@vueuse/core"
|
||||
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 { HoppRESTTab } from "~/helpers/rest/tab"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
|
||||
import { platform } from "~/platform"
|
||||
import { getCurrentStrategyID } from "~/helpers/network"
|
||||
|
||||
@@ -313,6 +315,12 @@ const clearAll = ref<any | null>(null)
|
||||
const copyRequestAction = 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 () => {
|
||||
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
|
||||
toast.error(`${t("empty.endpoint")}`)
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center flex-1 flex-shrink-0 py-4 overflow-auto whitespace-nowrap"
|
||||
>
|
||||
<div class="absolute inset-0 flex flex-1">
|
||||
<div class="autocomplete-wrapper">
|
||||
<div class="absolute inset-0 flex flex-1 overflow-x-auto">
|
||||
<div
|
||||
ref="editor"
|
||||
:placeholder="placeholder"
|
||||
class="flex flex-1"
|
||||
:class="styles"
|
||||
@keydown.enter.prevent="emit('enter', $event)"
|
||||
@keyup="emit('keyup', $event)"
|
||||
@click="emit('click', $event)"
|
||||
@keydown="emit('keydown', $event)"
|
||||
@keydown="handleKeystroke"
|
||||
@focusin="showSuggestionPopover = true"
|
||||
></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>
|
||||
</template>
|
||||
|
||||
@@ -35,6 +60,8 @@ import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironme
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
||||
import { platform } from "~/platform"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { onClickOutside } from "@vueuse/core"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -46,6 +73,7 @@ const props = withDefaults(
|
||||
selectTextOnMount?: boolean
|
||||
environmentHighlights?: boolean
|
||||
readonly?: boolean
|
||||
autoCompleteSource?: string[]
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
@@ -55,6 +83,7 @@ const props = withDefaults(
|
||||
focus: false,
|
||||
readonly: false,
|
||||
environmentHighlights: true,
|
||||
autoCompleteSource: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -68,12 +97,160 @@ const emit = defineEmits<{
|
||||
(e: "click", ev: any): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const cachedValue = ref(props.modelValue)
|
||||
|
||||
const view = ref<EditorView>()
|
||||
|
||||
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(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
@@ -236,3 +413,49 @@ watch(editor, () => {
|
||||
}
|
||||
})
|
||||
</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