Updated the autocomplete support to be more smarter
This commit is contained in:
parent
6cf4f6b988
commit
a528bf33ec
3 changed files with 141 additions and 40 deletions
|
|
@ -35,18 +35,19 @@ class Upgrader:
|
||||||
load_dotenv(str(envFile))
|
load_dotenv(str(envFile))
|
||||||
|
|
||||||
user_site: Path = config_path / f"python{sys.version_info.major}.{sys.version_info.minor}-packages"
|
user_site: Path = config_path / f"python{sys.version_info.major}.{sys.version_info.minor}-packages"
|
||||||
|
user_site_ver: Path = user_site / ".version"
|
||||||
|
|
||||||
|
from app.library.version import APP_VERSION
|
||||||
|
|
||||||
if not user_site.exists():
|
if not user_site.exists():
|
||||||
user_site.mkdir(parents=True, exist_ok=True)
|
user_site.mkdir(parents=True, exist_ok=True)
|
||||||
|
user_site_ver.write_text(APP_VERSION)
|
||||||
|
|
||||||
if user_site.is_dir():
|
if user_site.is_dir():
|
||||||
if str(user_site) not in sys.path:
|
if str(user_site) not in sys.path:
|
||||||
sys.path.insert(0, str(user_site))
|
sys.path.insert(0, str(user_site))
|
||||||
|
|
||||||
from app.library.version import APP_VERSION
|
if not user_site_ver.exists() or APP_VERSION != user_site_ver.read_text().strip():
|
||||||
|
|
||||||
user_site_ver: Path = user_site / ".version"
|
|
||||||
if not user_site_ver.exists() or user_site_ver.read_text().strip() != APP_VERSION:
|
|
||||||
self.clean_up(user_site, user_site_ver, APP_VERSION)
|
self.clean_up(user_site, user_site_ver, APP_VERSION)
|
||||||
|
|
||||||
self.check(config_path, user_site)
|
self.check(config_path, user_site)
|
||||||
|
|
|
||||||
|
|
@ -34,21 +34,38 @@ const props = defineProps<{
|
||||||
const model = defineModel<string>()
|
const model = defineModel<string>()
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
if (false === showList.value) {
|
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0
|
||||||
showList.value = true
|
highlightedIndex.value = showList.value ? 0 : -1
|
||||||
}
|
|
||||||
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showList = ref(false)
|
const showList = ref(false)
|
||||||
const highlightedIndex = ref(-1)
|
const highlightedIndex = ref(-1)
|
||||||
const dropdownItemRefs = ref<(HTMLElement | null)[]>([])
|
const dropdownItemRefs = ref<(HTMLElement | null)[]>([])
|
||||||
|
|
||||||
|
// Extract the last non-space token and its bounds
|
||||||
|
const getLastToken = (value: string) => {
|
||||||
|
const m = (value || '').match(/(\S+)$/)
|
||||||
|
const token: string = m?.[1] ?? ''
|
||||||
|
const start = m ? (m.index as number) : value.length
|
||||||
|
const end = m ? start + token.length : value.length
|
||||||
|
return { token, start, end }
|
||||||
|
}
|
||||||
|
|
||||||
const filteredOptions = computed(() => {
|
const filteredOptions = computed(() => {
|
||||||
if (!model.value) {
|
const value = model.value || ''
|
||||||
|
if (!value) {
|
||||||
return props.options
|
return props.options
|
||||||
}
|
}
|
||||||
const val = model.value.toLowerCase()
|
const { token } = getLastToken(value)
|
||||||
|
// Hide suggestions when token has '=' or doesn't start with '--'
|
||||||
|
if (!token || token.includes('=') || !token.startsWith('--')) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// Hide suggestions if token exactly matches an option value
|
||||||
|
if (props.options.some(opt => opt.value === token)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const val = token.toLowerCase()
|
||||||
const startsWithFlag = []
|
const startsWithFlag = []
|
||||||
const includesFlag = []
|
const includesFlag = []
|
||||||
const includesDesc = []
|
const includesDesc = []
|
||||||
|
|
@ -67,7 +84,16 @@ const filteredOptions = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectOption = (val: string) => {
|
const selectOption = (val: string) => {
|
||||||
model.value = val
|
const value = model.value || ''
|
||||||
|
const { token, start, end } = getLastToken(value)
|
||||||
|
if (token) {
|
||||||
|
// Preserve any '=value' suffix already typed for this token
|
||||||
|
const eqPos = token.indexOf('=')
|
||||||
|
const after = eqPos !== -1 ? token.slice(eqPos) : ''
|
||||||
|
model.value = value.slice(0, start) + val + after + value.slice(end)
|
||||||
|
} else {
|
||||||
|
model.value = val
|
||||||
|
}
|
||||||
showList.value = false
|
showList.value = false
|
||||||
highlightedIndex.value = -1
|
highlightedIndex.value = -1
|
||||||
}
|
}
|
||||||
|
|
@ -81,8 +107,8 @@ const hideList = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
showList.value = true
|
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0
|
||||||
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
highlightedIndex.value = showList.value ? 0 : -1
|
||||||
}
|
}
|
||||||
|
|
||||||
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
||||||
|
|
@ -100,8 +126,21 @@ 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)
|
||||||
|
})
|
||||||
|
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if (!filteredOptions.value.length) {
|
// Escape closes dropdown and lets arrows navigate the input
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
showList.value = false
|
||||||
|
highlightedIndex.value = -1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!showList.value || !filteredOptions.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +165,7 @@ const handleKeydown = (e: KeyboardEvent) => {
|
||||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
||||||
filteredOptions.value[highlightedIndex.value] : undefined
|
filteredOptions.value[highlightedIndex.value] : undefined
|
||||||
if (selected) {
|
if (selected && isFlagTrigger.value) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
selectOption(selected.value)
|
selectOption(selected.value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
||||||
<div class="control" style="width:100%;">
|
<div class="control" style="width:100%;">
|
||||||
<textarea :id="id" v-model="localValue" @input="onInput" @focus="showList = true" @blur="hideList" @keydown="onKeydown"
|
<textarea :id="id" ref="textareaRef" v-model="localValue" @input="onInput" @focus="onFocus" @blur="hideList"
|
||||||
class="textarea" :placeholder="placeholder" autocomplete="off" style="width:100%; position:relative; z-index:2;"
|
@keydown="onKeydown" @keyup="updateCaret" @click="updateCaret" @mouseup="updateCaret"
|
||||||
rows="4" :disabled="disabled" />
|
class="textarea" :placeholder="placeholder" autocomplete="off"
|
||||||
|
style="width:100%; position:relative; z-index:2;" rows="4" :disabled="disabled" />
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
||||||
<div class="dropdown-content" style="width:100%; max-height:11em; overflow-y:auto;">
|
<div class="dropdown-content" style="width:100%; max-height:11em; overflow-y:auto;">
|
||||||
|
|
@ -31,6 +32,8 @@ const props = defineProps<{
|
||||||
|
|
||||||
const model = defineModel<string>()
|
const model = defineModel<string>()
|
||||||
const localValue = ref(model.value || '')
|
const localValue = ref(model.value || '')
|
||||||
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
const caretIndex = ref(0)
|
||||||
const showList = ref(false)
|
const showList = ref(false)
|
||||||
const highlightedIndex = ref(-1)
|
const highlightedIndex = ref(-1)
|
||||||
const dropdownItems = ref<HTMLElement[]>([])
|
const dropdownItems = ref<HTMLElement[]>([])
|
||||||
|
|
@ -38,12 +41,35 @@ const dropdownItems = ref<HTMLElement[]>([])
|
||||||
watch(model, val => localValue.value = val || '')
|
watch(model, val => localValue.value = val || '')
|
||||||
watch(localValue, val => model.value = val)
|
watch(localValue, val => model.value = val)
|
||||||
|
|
||||||
|
// Compute token at caret position
|
||||||
|
const getCurrentToken = (value: string, caret: number) => {
|
||||||
|
const left = value.slice(0, caret)
|
||||||
|
const right = value.slice(caret)
|
||||||
|
const leftMatch = left.match(/(\S+)$/)
|
||||||
|
const rightMatch = right.match(/^(\S+)/)
|
||||||
|
const leftToken: string = (leftMatch?.[1] ?? '') as string
|
||||||
|
const rightToken: string = (rightMatch?.[1] ?? '') as string
|
||||||
|
const token = leftToken + rightToken
|
||||||
|
const start = leftMatch ? caret - leftToken.length : caret
|
||||||
|
const end = rightMatch ? caret + rightToken.length : caret
|
||||||
|
return { token, start, end }
|
||||||
|
}
|
||||||
|
|
||||||
const filteredOptions = computed(() => {
|
const filteredOptions = computed(() => {
|
||||||
const lastWord = localValue.value.split(/\s+/).pop() || ''
|
const { token } = getCurrentToken(localValue.value, caretIndex.value)
|
||||||
if (!lastWord) {
|
// Hide suggestions once '=' is present in the current token
|
||||||
return props.options
|
if (!token || token.includes('=')) {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
const val = lastWord.toLowerCase()
|
// Only suggest when typing a long flag starting with `--`
|
||||||
|
if (!token.startsWith('--')) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// Hide suggestions if token exactly matches an option value
|
||||||
|
if (props.options.some(opt => opt.value === token)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const val = token.toLowerCase()
|
||||||
const startsWithFlag = []
|
const startsWithFlag = []
|
||||||
const includesFlag = []
|
const includesFlag = []
|
||||||
const includesDesc = []
|
const includesDesc = []
|
||||||
|
|
@ -62,25 +88,53 @@ const filteredOptions = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const appendFlag = (flag: string) => {
|
const appendFlag = (flag: string) => {
|
||||||
// Only replace the last word if the cursor is at the end and the last word is not followed by a space
|
|
||||||
const value = localValue.value
|
const value = localValue.value
|
||||||
const cursorAtEnd = true // textarea autocomplete only works at end
|
const caret = caretIndex.value
|
||||||
const match = value.match(/(\S+)$/)
|
const { token, start, end } = getCurrentToken(value, caret)
|
||||||
if (match && cursorAtEnd && !value.endsWith(' ')) {
|
if (token) {
|
||||||
// Replace last word
|
// Replace only the flag part of the token, preserving any '=value' suffix in the same token
|
||||||
localValue.value = value.slice(0, match.index) + flag
|
const eqPos = token.indexOf('=')
|
||||||
|
const after = eqPos !== -1 ? token.slice(eqPos) : ''
|
||||||
|
localValue.value = value.slice(0, start) + flag + after + value.slice(end)
|
||||||
|
nextTick(() => {
|
||||||
|
const pos = start + flag.length + after.length
|
||||||
|
if (textareaRef.value) {
|
||||||
|
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos
|
||||||
|
caretIndex.value = pos
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// Just append
|
// No token at caret: append at caret position
|
||||||
localValue.value += (value && !value.endsWith(' ') ? ' ' : '') + flag
|
const needsSpace = caret > 0 && value[caret - 1] !== ' '
|
||||||
|
localValue.value = value.slice(0, caret) + (needsSpace ? ' ' : '') + flag + value.slice(caret)
|
||||||
|
nextTick(() => {
|
||||||
|
const pos = caret + (needsSpace ? 1 : 0) + flag.length
|
||||||
|
if (textareaRef.value) {
|
||||||
|
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos
|
||||||
|
caretIndex.value = pos
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
showList.value = false
|
showList.value = false
|
||||||
highlightedIndex.value = -1
|
highlightedIndex.value = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateCaret = () => {
|
||||||
|
caretIndex.value = textareaRef.value ? (textareaRef.value.selectionStart ?? localValue.value.length) : localValue.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
updateCaret()
|
||||||
|
showList.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
const lastWord = localValue.value.split(/\s+/).pop() || ''
|
updateCaret()
|
||||||
showList.value = lastWord.length > 0
|
const { token } = getCurrentToken(localValue.value, caretIndex.value)
|
||||||
highlightedIndex.value = (showList.value && filteredOptions.value.length && lastWord.length > 0) ? 0 : -1
|
const hasEqual = token.includes('=')
|
||||||
|
const isFlagTrigger = token.startsWith('--') && !hasEqual
|
||||||
|
showList.value = isFlagTrigger && filteredOptions.value.length > 0
|
||||||
|
highlightedIndex.value = showList.value ? 0 : -1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset scroll position when filtered options change
|
// Reset scroll position when filtered options change
|
||||||
|
|
@ -91,12 +145,21 @@ watch(filteredOptions, () => {
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.scrollTop = 0
|
dropdown.scrollTop = 0
|
||||||
}
|
}
|
||||||
|
const items = document.querySelectorAll('.dropdown-item')
|
||||||
|
dropdownItems.value = Array.from(items) as HTMLElement[]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const hideList = () => setTimeout(() => { showList.value = false; highlightedIndex.value = -1 }, 100)
|
const hideList = () => setTimeout(() => { showList.value = false; highlightedIndex.value = -1 }, 100)
|
||||||
|
|
||||||
const onKeydown = (e: KeyboardEvent) => {
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
// Track caret and allow ESC to immediately close suggestions and restore normal keys
|
||||||
|
updateCaret()
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
showList.value = false
|
||||||
|
highlightedIndex.value = -1
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!showList.value || !filteredOptions.value.length) {
|
if (!showList.value || !filteredOptions.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -132,20 +195,18 @@ const onKeydown = (e: KeyboardEvent) => {
|
||||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
})
|
})
|
||||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
const lastWord = localValue.value.split(/\s+/).pop() || ''
|
const { token } = getCurrentToken(localValue.value, caretIndex.value)
|
||||||
|
const hasEqual = token.includes('=')
|
||||||
|
const isFlagTrigger = token.startsWith('--') && !hasEqual
|
||||||
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
||||||
filteredOptions.value[highlightedIndex.value] : undefined
|
filteredOptions.value[highlightedIndex.value] : undefined
|
||||||
// Only autocomplete if there's a partial word being typed
|
// Only autocomplete if there's a partial word being typed
|
||||||
if (selected && lastWord.trim().length > 0) {
|
if (selected && isFlagTrigger) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
appendFlag(selected.value)
|
appendFlag(selected.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep dropdownItems ref in sync with rendered items
|
// dropdownItems is updated via a single top-level watch(filteredOptions)
|
||||||
watch(filteredOptions, () => nextTick(() => {
|
|
||||||
const items = document.querySelectorAll('.dropdown-item')
|
|
||||||
dropdownItems.value = Array.from(items) as HTMLElement[]
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue