mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #72889 from NousResearch/bb/composer-at-paths
Fix `@` path navigation, folder completion, and chip baseline in the composer
This commit is contained in:
commit
7c532e1006
8 changed files with 417 additions and 21 deletions
180
apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx
Normal file
180
apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
/**
|
||||
* Folder navigation in the `@` popover, driven through the REAL hook against a
|
||||
* real contentEditable.
|
||||
*
|
||||
* Tab and Enter used to be the same branch, so picking a folder always
|
||||
* committed a chip and closed the menu — there was no way to walk into a
|
||||
* subdirectory from the list. Tab now descends, Enter still commits, and
|
||||
* Backspace climbs back out one segment.
|
||||
*/
|
||||
function folderItem(path: string): Unstable_TriggerItem {
|
||||
return {
|
||||
id: `@folder:${path}|0`,
|
||||
type: 'folder',
|
||||
label: path.split('/').filter(Boolean).pop() ?? path,
|
||||
metadata: {
|
||||
icon: 'folder',
|
||||
display: path,
|
||||
meta: 'dir',
|
||||
rawText: `@folder:${path}`,
|
||||
insertId: path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setup(initialText: string) {
|
||||
const editor = document.createElement('div')
|
||||
editor.contentEditable = 'true'
|
||||
// The real composer marks its editor with this slot; `composerPlainText`
|
||||
// keys off it to decide whether a DIV contributes a trailing newline.
|
||||
// Without it the harness would silently diverge from production text.
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
renderComposerContents(editor, initialText)
|
||||
|
||||
// Caret at the end, which is where a typed trigger always leaves it.
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
const sel = window.getSelection()
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
|
||||
const editorRef = createRef<HTMLDivElement>() as { current: HTMLDivElement | null }
|
||||
editorRef.current = editor
|
||||
|
||||
const draftRef = { current: initialText }
|
||||
const setComposerText = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useComposerTrigger({
|
||||
at: { adapter: null, loading: false },
|
||||
draftRef,
|
||||
editorRef,
|
||||
requestMainFocus: vi.fn(),
|
||||
setComposerText,
|
||||
slash: { adapter: null, loading: false }
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.refreshTrigger()
|
||||
})
|
||||
|
||||
return { editor, result, setComposerText }
|
||||
}
|
||||
|
||||
describe('@ folder navigation', () => {
|
||||
it('Tab on a folder walks into it and keeps the popover open', () => {
|
||||
const { editor, result } = setup('@app')
|
||||
|
||||
expect(result.current.trigger).toMatchObject({ kind: '@', query: 'app' })
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
|
||||
})
|
||||
|
||||
// Plain text, not a chip — the token is still being typed.
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
expect(editor.querySelector('[data-ref-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('descends repeatedly, one level per Tab', () => {
|
||||
const { editor, result } = setup('@apps/desk')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true })
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@apps/desktop/')
|
||||
})
|
||||
|
||||
it('Enter on a folder commits it as a chip instead of descending', () => {
|
||||
const { editor, result } = setup('@app')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'))
|
||||
})
|
||||
|
||||
const chip = editor.querySelector('[data-ref-text]')
|
||||
|
||||
expect(chip).not.toBeNull()
|
||||
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
|
||||
expect(composerPlainText(editor)).toContain('@folder:')
|
||||
})
|
||||
|
||||
it('only descends for `@` folders — a file pick still commits', () => {
|
||||
const { editor, result } = setup('@main')
|
||||
|
||||
const file: Unstable_TriggerItem = {
|
||||
id: '@file:src/main.tsx|0',
|
||||
type: 'file',
|
||||
label: 'main.tsx',
|
||||
metadata: {
|
||||
icon: 'file',
|
||||
display: 'main.tsx',
|
||||
meta: 'src',
|
||||
rawText: '@file:src/main.tsx',
|
||||
insertId: 'src/main.tsx'
|
||||
}
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(file, { descend: true })
|
||||
})
|
||||
|
||||
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('Backspace climbs out one segment at a time', () => {
|
||||
const { editor, result } = setup('@apps/desktop/')
|
||||
|
||||
let handled = false
|
||||
act(() => {
|
||||
handled = result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
})
|
||||
|
||||
it('Backspace drops a partially typed segment before its parent', () => {
|
||||
const { editor, result } = setup('@apps/desk')
|
||||
|
||||
act(() => {
|
||||
result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@apps/')
|
||||
})
|
||||
|
||||
it('leaves Backspace alone when there is no path to climb', () => {
|
||||
const { result } = setup('@apps')
|
||||
|
||||
let handled = true
|
||||
act(() => {
|
||||
handled = result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
// No `/` in the query — normal character deletion must still happen.
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves text typed before the mention', () => {
|
||||
const { editor, result } = setup('look at @app')
|
||||
|
||||
act(() => {
|
||||
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
|
||||
})
|
||||
|
||||
expect(composerPlainText(editor)).toBe('look at @apps/')
|
||||
})
|
||||
})
|
||||
|
|
@ -189,7 +189,7 @@ export function useComposerTrigger({
|
|||
return true
|
||||
}
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || !trigger) {
|
||||
|
|
@ -219,6 +219,31 @@ export function useComposerTrigger({
|
|||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
|
||||
// Tab on a folder walks INTO it instead of committing it: re-type the
|
||||
// token as the bare path so the next `complete.path` lists that folder's
|
||||
// children, exactly as typing the path by hand would. Enter still commits
|
||||
// the folder itself — the two intents are distinct, so the keys are too.
|
||||
// Only `@` folders descend; a slash command's arg list has no hierarchy.
|
||||
const descendInto =
|
||||
options?.descend && trigger.kind === '@' && item.type === 'folder'
|
||||
? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '')
|
||||
: ''
|
||||
|
||||
if (descendInto) {
|
||||
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, `${prefix}@${path}`)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
setComposerText(draftRef.current)
|
||||
requestMainFocus()
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
|
||||
// it — expand to its options step so the popover shows the inline list, just
|
||||
// as typing `/personality ` by hand would. A serialized value with a space is
|
||||
|
|
@ -300,8 +325,37 @@ export function useComposerTrigger({
|
|||
finish()
|
||||
}
|
||||
|
||||
/** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`)
|
||||
* instead of one character. Descending is one Tab per level, so climbing
|
||||
* back out should cost one key too rather than a held delete. Returns
|
||||
* false when the caret isn't in a path, so keydown falls through. */
|
||||
const ascendTriggerPath = () => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trailing slash means we're listing a folder's children: drop that
|
||||
// folder. Otherwise a partial segment is typed — drop just that.
|
||||
const trimmed = trigger.query.replace(/\/$/, '')
|
||||
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
|
||||
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, `${prefix}@${parent}`)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
setComposerText(draftRef.current)
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
argStageEmpty,
|
||||
ascendTriggerPath,
|
||||
closeTrigger,
|
||||
commitTypedSlashDirective,
|
||||
refreshTrigger,
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ export function ChatBar({
|
|||
// this API; keyup uses triggerKeyConsumedRef to skip its refresh.
|
||||
const {
|
||||
argStageEmpty,
|
||||
ascendTriggerPath,
|
||||
closeTrigger,
|
||||
commitTypedSlashDirective,
|
||||
refreshTrigger,
|
||||
|
|
@ -556,12 +557,24 @@ export function ChatBar({
|
|||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
replaceTriggerWithChip(item)
|
||||
// Tab means "go deeper" on a folder; Enter means "I want this one".
|
||||
// Everything else treats them alike.
|
||||
replaceTriggerWithChip(item, { descend: event.key === 'Tab' })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Backspace climbs out of an `@` path one segment at a time, mirroring
|
||||
// Tab's one-key descent. Only when the caret sits at the end of the
|
||||
// token — mid-token editing keeps normal character deletion.
|
||||
if (event.key === 'Backspace' && !event.metaKey && !event.altKey && ascendTriggerPath()) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
|
|
|||
|
|
@ -51,6 +51,39 @@ describe('detectTrigger', () => {
|
|||
expect(detectTrigger('and/or')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the at-mention live while walking into subfolders', () => {
|
||||
// A `/` inside the query is path navigation, not the end of the token —
|
||||
// the popover has to stay open so the next directory level can load.
|
||||
expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 })
|
||||
expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 })
|
||||
expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 })
|
||||
expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 })
|
||||
expect(detectTrigger('@apps/desktop/src')).toEqual({
|
||||
kind: '@',
|
||||
query: 'apps/desktop/src',
|
||||
tokenLength: 17
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the at-mention live for a typed ref kind with a path', () => {
|
||||
expect(detectTrigger('@file:src/main.tsx')).toEqual({
|
||||
kind: '@',
|
||||
query: 'file:src/main.tsx',
|
||||
tokenLength: 18
|
||||
})
|
||||
expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 })
|
||||
})
|
||||
|
||||
it('still ends the at-mention token at whitespace', () => {
|
||||
// The token is whitespace-delimited; a path doesn't change that.
|
||||
expect(detectTrigger('@./src and more')).toBeNull()
|
||||
expect(detectTrigger('look at @apps/desktop')).toEqual({
|
||||
kind: '@',
|
||||
query: 'apps/desktop',
|
||||
tokenLength: 13
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a mid-message slash as an inline reference', () => {
|
||||
// Skills have to be reachable anywhere in a prompt, not just at position 0.
|
||||
expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 })
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ export interface TriggerState {
|
|||
}
|
||||
|
||||
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||
// single tokens. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids
|
||||
// single tokens, and a path is part of that token: `@./src/`, `@~/Desktop/`,
|
||||
// and `@file:src/foo` all have to keep the popover live while the user walks
|
||||
// into subdirectories. Excluding `/` from the query class would end the token
|
||||
// at the first separator, which is exactly the "can't browse into a folder"
|
||||
// bug. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids
|
||||
// matching file paths like `src/foo/bar`.
|
||||
//
|
||||
// `/` triggers fire in two shapes, because a slash means two different things
|
||||
|
|
@ -27,7 +31,7 @@ export interface TriggerState {
|
|||
// The inline shape is what makes skills reachable anywhere in a prompt. Both
|
||||
// shapes need the trailing `$`: detection runs against the text BEFORE the
|
||||
// caret, so the match must end where the user is typing.
|
||||
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
|
||||
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/
|
||||
const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||
const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ const SLASH_CHIP_VARIANT: Record<SlashChipKind, string> = {
|
|||
}
|
||||
|
||||
export const SLASH_CHIP_BASE_CLASS =
|
||||
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none'
|
||||
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-medium leading-none'
|
||||
|
||||
export function slashChipClass(kind: SlashChipKind): string {
|
||||
return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
|
||||
|
|
@ -145,9 +145,15 @@ const DirectiveIcon: FC<{ type: string; className?: string }> = ({
|
|||
|
||||
/** Shared chip styling — used by both the rendered <DirectiveChip> and the
|
||||
* raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain
|
||||
* muted-foreground text so chips read as quiet tags on any bubble color. */
|
||||
* muted-foreground text so chips read as quiet tags on any bubble color.
|
||||
*
|
||||
* `align-[-0.12em]` rather than `align-middle`: `middle` centers the pill on
|
||||
* the x-height midpoint, which sits above the center of the surrounding text
|
||||
* box, so the chip visibly rides low next to the words it's nestled in. The
|
||||
* em nudge lands the chip's own text baseline on the line's baseline (measured
|
||||
* to within 0.08px) without growing the line box. */
|
||||
export const DIRECTIVE_CHIP_CLASS =
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
|
||||
|
||||
/**
|
||||
* Parses our composer's `@type:value` references into directive segments
|
||||
|
|
|
|||
|
|
@ -277,3 +277,79 @@ def test_fuzzy_paths_relative_to_cwd_inside_subdir(tmp_path, monkeypatch):
|
|||
readme_texts = [t for t, _, _ in _items("@README")]
|
||||
|
||||
assert not any("README.md" in t for t in readme_texts), readme_texts
|
||||
|
||||
|
||||
# ── Fuzzy DIRECTORY matching ─────────────────────────────────────────────
|
||||
# `@Desktop` used to return nothing: the fuzzy scanner ranks basenames from
|
||||
# `_list_repo_files`, which lists FILES only, so a directory whose name no
|
||||
# file inside it happens to match was unreachable without typing a `/`.
|
||||
|
||||
|
||||
def test_fuzzy_finds_directory_by_name(tmp_path, monkeypatch):
|
||||
"""A folder is reachable by bare name, with no trailing slash typed."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "Desktop" / "nested").mkdir(parents=True)
|
||||
# Deliberately named so NO file basename fuzzy-matches "Desktop".
|
||||
(tmp_path / "Desktop" / "nested" / "zzz.txt").write_text("x")
|
||||
|
||||
entries = _items("@Desktop")
|
||||
texts = [t for t, _, _ in entries]
|
||||
|
||||
assert "@folder:Desktop/" in texts, texts
|
||||
|
||||
row = next(r for r in entries if r[0] == "@folder:Desktop/")
|
||||
assert row[1] == "Desktop/"
|
||||
assert row[2] == "dir"
|
||||
|
||||
|
||||
def test_fuzzy_directory_prefix_match(tmp_path, monkeypatch):
|
||||
"""Partial folder names match too — `@Desk` finds `Desktop/`."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "Desktop").mkdir()
|
||||
(tmp_path / "Desktop" / "zzz.txt").write_text("x")
|
||||
|
||||
assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desk")]
|
||||
|
||||
|
||||
def test_fuzzy_ranks_folder_above_file_at_same_tier(tmp_path, monkeypatch):
|
||||
"""At an equal match tier the folder leads: `@docs` means the directory."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "intro.md").write_text("x")
|
||||
(tmp_path / "docs.md").write_text("x")
|
||||
|
||||
texts = [t for t, _, _ in _items("@docs")]
|
||||
|
||||
assert texts[0] == "@folder:docs/", texts
|
||||
assert "@file:docs.md" in texts
|
||||
|
||||
|
||||
def test_fuzzy_hides_dot_directories_unless_asked(tmp_path, monkeypatch):
|
||||
"""Dot-folders follow the same rule as dotfiles."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".config").mkdir()
|
||||
(tmp_path / ".config" / "zzz.txt").write_text("x")
|
||||
|
||||
assert not any(".config" in t for t, _, _ in _items("@config"))
|
||||
assert any(t.startswith("@folder:.config") for t, _, _ in _items("@.config"))
|
||||
|
||||
|
||||
def test_fuzzy_finds_top_level_entries_outside_a_git_repo(tmp_path, monkeypatch):
|
||||
"""Outside a repo the fallback walk can exhaust its file budget on one
|
||||
deep subtree before reaching a sibling, hiding top-level folders. The
|
||||
root listdir seed guarantees immediate children are always candidates.
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(server, "_FUZZY_CACHE_MAX_FILES", 5)
|
||||
|
||||
# A deep subtree that soaks up the entire (patched) file budget...
|
||||
deep = tmp_path / "aaa_hog"
|
||||
deep.mkdir()
|
||||
for i in range(40):
|
||||
(deep / f"f{i:03d}.txt").write_text("x")
|
||||
|
||||
# ...and the folder the user actually wants, sorted after it.
|
||||
(tmp_path / "Desktop").mkdir()
|
||||
(tmp_path / "Desktop" / "note.txt").write_text("x")
|
||||
|
||||
assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desktop")]
|
||||
|
|
|
|||
|
|
@ -16184,24 +16184,54 @@ def _(rid, params: dict) -> dict:
|
|||
and "/" not in path_part
|
||||
and prefix_tag != "folder"
|
||||
):
|
||||
ranked: list[tuple[tuple[int, int], str, str]] = []
|
||||
for rel in _list_repo_files(root):
|
||||
basename = os.path.basename(rel)
|
||||
if basename.startswith(".") and not path_part.startswith("."):
|
||||
continue
|
||||
rank = _fuzzy_basename_rank(basename, path_part)
|
||||
if rank is None:
|
||||
continue
|
||||
ranked.append((rank, rel, basename))
|
||||
ranked: list[tuple[tuple[int, int], str, str, bool]] = []
|
||||
walked_dirs: set[str] = set()
|
||||
seen: set[str] = set()
|
||||
want_hidden = path_part.startswith(".")
|
||||
|
||||
ranked.sort(key=lambda r: (r[0], len(r[1]), r[1]))
|
||||
def _consider(rel: str, name: str, is_dir: bool) -> None:
|
||||
if rel in seen or (name.startswith(".") and not want_hidden):
|
||||
return
|
||||
rank = _fuzzy_basename_rank(name, path_part)
|
||||
if rank is not None:
|
||||
seen.add(rel)
|
||||
ranked.append((rank, rel, name, is_dir))
|
||||
|
||||
# Seed with root's immediate children. `_list_repo_files` is capped
|
||||
# at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback
|
||||
# walk can burn that whole budget on one deep subtree before ever
|
||||
# reaching a sibling — which is why `@Desk` in a non-repo $HOME
|
||||
# found nothing. One listdir keeps the top level always reachable.
|
||||
try:
|
||||
for entry in os.listdir(root):
|
||||
if entry not in _FUZZY_FALLBACK_EXCLUDES:
|
||||
_consider(entry, entry, os.path.isdir(os.path.join(root, entry)))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for rel in _list_repo_files(root):
|
||||
_consider(rel, os.path.basename(rel), False)
|
||||
|
||||
# Directories are only implied by the file listing, so rank each
|
||||
# ancestor too. Without this a bare `@Desktop` finds nothing —
|
||||
# a folder with no name-matching file inside it is invisible to
|
||||
# a file-only scan, which is the "can't @ a folder by name" bug.
|
||||
parent = os.path.dirname(rel)
|
||||
while parent and parent not in walked_dirs:
|
||||
walked_dirs.add(parent)
|
||||
_consider(parent, os.path.basename(parent), True)
|
||||
parent = os.path.dirname(parent)
|
||||
|
||||
# Same rank tier: folders first, so `@Desktop` leads with the folder
|
||||
# rather than a file that merely fuzzy-matches the same letters.
|
||||
ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1]))
|
||||
tag = prefix_tag or "file"
|
||||
for _, rel, basename in ranked[:30]:
|
||||
for _, rel, basename, is_dir in ranked[:30]:
|
||||
items.append(
|
||||
{
|
||||
"text": f"@{tag}:{rel}",
|
||||
"display": basename,
|
||||
"meta": os.path.dirname(rel),
|
||||
"text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}",
|
||||
"display": basename + ("/" if is_dir else ""),
|
||||
"meta": "dir" if is_dir else os.path.dirname(rel),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue