Improve autocomplete for console

This commit is contained in:
arabcoders 2025-10-02 16:35:57 +03:00
parent a1c65572d5
commit d1060c43e2
3 changed files with 127 additions and 31 deletions

View file

@ -82,7 +82,7 @@
<span>Associated yt-dlp option</span>
</label>
<InputAutocomplete v-model="item.field" :options="ytDlpOptions" :disabled="isLoading"
placeholder="Type or select a yt-dlp option" />
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" />
<span class="help is-bold">
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
</span>

View file

@ -24,12 +24,22 @@
import { ref, watch, computed, defineModel, defineProps, nextTick } from 'vue'
import type { AutoCompleteOptions } from '~/types/autocomplete'
const props = defineProps<{
const props = withDefaults(defineProps<{
options: AutoCompleteOptions
placeholder?: string
disabled?: boolean,
disabled?: boolean
id?: string
}>()
multiple?: boolean
openOnFocus?: boolean
allowShortFlags?: boolean
}>(), {
placeholder: '',
disabled: false,
id: '',
multiple: true,
openOnFocus: false,
allowShortFlags: false
})
const model = defineModel<string>()
@ -44,6 +54,16 @@ const dropdownItemRefs = ref<(HTMLElement | null)[]>([])
// Extract the last non-space token and its bounds
const getLastToken = (value: string) => {
// If multiple is disabled, treat the entire input as a single token
if (!props.multiple) {
return {
token: value,
start: 0,
end: value.length
}
}
// Multiple enabled: extract last token for multi-flag support
const m = (value || '').match(/(\S+)$/)
const token: string = m?.[1] ?? ''
const start = m ? (m.index as number) : value.length
@ -57,35 +77,82 @@ const filteredOptions = computed(() => {
return props.options
}
const { token } = getLastToken(value)
// Hide suggestions when token has '=' or doesn't start with '--'
if (!token || token.includes('=') || !token.startsWith('--')) {
// If openOnFocus is enabled and token is empty/just whitespace, show all options
if (props.openOnFocus && !token) {
return props.options
}
// Hide suggestions when token has '='
if (!token || token.includes('=')) {
return []
}
// Hide suggestions if token exactly matches an option value
if (props.options.some(opt => opt.value === token)) {
// Check if token is a valid flag format
const isLongFlag = token.startsWith('--')
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--')
if (!isLongFlag && !isShortFlag) {
return []
}
const val = token.toLowerCase()
// Check for exact match first - if found, only show that
const exactMatch = props.options.find(opt => opt.value === token)
if (exactMatch) {
return [exactMatch]
}
const startsWithFlag = []
const includesFlag = []
const includesDesc = []
for (const opt of props.options) {
const flag = opt.value.toLowerCase()
const flag = opt.value
const desc = opt.description.toLowerCase()
if (flag.startsWith(val)) {
startsWithFlag.push(opt)
} else if (flag.includes(val)) {
includesFlag.push(opt)
} else if (desc.includes(val)) {
includesDesc.push(opt)
if (isShortFlag) {
// Short flags: case-sensitive matching for flag, case-insensitive for description
if (flag === token) {
startsWithFlag.push(opt)
} else if (flag.includes(token)) {
includesFlag.push(opt)
} else if (desc.includes(token.toLowerCase())) {
includesDesc.push(opt)
}
} else {
// Long flags: case-insensitive matching
const val = token.toLowerCase()
const flagLower = flag.toLowerCase()
if (flagLower.startsWith(val)) {
startsWithFlag.push(opt)
} else if (flagLower.includes(val)) {
includesFlag.push(opt)
} else if (desc.includes(val)) {
includesDesc.push(opt)
}
}
}
return [...startsWithFlag, ...includesFlag, ...includesDesc]
})
const selectOption = (val: string) => {
const value = model.value || ''
const { token, start, end } = getLastToken(value)
// If multiple is disabled, replace entire value
if (!props.multiple) {
// Preserve any '=value' suffix already typed
const eqPos = token.indexOf('=')
const after = eqPos !== -1 ? token.slice(eqPos) : ''
model.value = val + after
showList.value = false
highlightedIndex.value = -1
return
}
// Multiple enabled: replace only the last token
if (token) {
// Preserve any '=value' suffix already typed for this token
const eqPos = token.indexOf('=')
@ -107,8 +174,13 @@ const hideList = () => {
}
const onFocus = () => {
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0
highlightedIndex.value = showList.value ? 0 : -1
if (!props.openOnFocus) {
return
}
// When openOnFocus is enabled, show dropdown if there are options
const hasOptions = filteredOptions.value.length > 0
showList.value = hasOptions
highlightedIndex.value = hasOptions ? 0 : -1
}
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
@ -128,9 +200,24 @@ watch(filteredOptions, () => {
const isFlagTrigger = computed(() => {
const { token } = getLastToken(model.value || '')
if (!token || !token.startsWith('--') || token.includes('=')) return false
// Suppress if exact match
return !props.options.some(opt => opt.value === token)
// If openOnFocus is enabled and input is empty, allow trigger
if (props.openOnFocus && !token) {
return true
}
if (!token || token.includes('=')) return false
// Check if token is a valid flag format
const isLongFlag = token.startsWith('--')
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--')
if (!isLongFlag && !isShortFlag) {
return false
}
// Allow trigger even for exact matches so users can see descriptions
return true
})
const handleKeydown = (e: KeyboardEvent) => {

View file

@ -14,15 +14,18 @@
</span>
</h1>
<div class="subtitle is-6 is-unselectable">
You can use this console window to execute non-interactive commands. The interface is jailed to the
<code>yt-dlp</code>
You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing the web interface and
it's settings.
</div>
</div>
<div class="column is-12">
<div class="card">
<header class="card-header">
<p class="card-header-title">
<span class="icon"><i class="fa-solid fa-terminal" /></span> Console Output
<span class="icon">
<i class="fa-solid fa-desktop" />
</span>
<span class="ml-2">Console Output</span>
</p>
<p class="card-header-icon">
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput()">
@ -36,14 +39,14 @@
<section class="card-content p-1 m-1">
<div class="field is-grouped">
<div class="control is-expanded">
<input type="text" class="input" v-model="command" placeholder="--help" autocomplete="off"
ref="command_input" @keydown.enter="runCommand" :disabled="isLoading" id="command">
<InputAutocomplete v-model="command" :options="ytDlpOptions" :disabled="isLoading" placeholder="--help"
id="command" @keydown.enter="runCommand" :multiple="true" :allowShortFlags="true" />
</div>
<p class="control">
<button class="button is-primary" type="button" :disabled="isLoading || '' === command"
@click="runCommand">
<span class="icon">
<i class="fa-solid fa-spinner" spin v-if="isLoading" />
<i class="fa-solid fa-spinner fa-spin" v-if="isLoading" />
<i class="fa-solid fa-paper-plane" v-else />
</span>
</button>
@ -60,6 +63,8 @@ import '@xterm/xterm/css/xterm.css'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { disableOpacity, enableOpacity } from '~/utils'
import InputAutocomplete from '~/components/InputAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete'
const config = useConfigStore()
const socket = useSocketStore()
@ -69,9 +74,12 @@ const terminal = ref<Terminal>()
const terminalFit = ref<FitAddon>()
const command = ref<string>('')
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window')
const command_input = useTemplateRef<HTMLInputElement>('command_input')
const isLoading = ref<boolean>(false)
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
.map(flag => ({ value: flag, description: opt.description || '' }))
))
watch(() => isLoading.value, async value => {
if (value) {
return
@ -158,10 +166,11 @@ const clearOutput = async (withCommand: boolean = false) => {
}
const focusInput = () => {
if (!command_input.value) {
return
// Focus the InputAutocomplete component's input field
const inputElement = document.getElementById('command') as HTMLInputElement
if (inputElement) {
inputElement.focus()
}
command_input.value.focus()
}
const writer = (s: string) => {