Improve autocomplete for console
This commit is contained in:
parent
a1c65572d5
commit
d1060c43e2
3 changed files with 127 additions and 31 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue