From b378cc0a72228fa6ec2201a7d7d8dd9b7a5fa77b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:24:35 -0500 Subject: [PATCH] fix(gateway): let `@` completion find folders by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuzzy branch of `complete.path` ranked basenames from `_list_repo_files`, which lists files only, so a directory was only ever reachable by typing a `/` — `@Desktop` returned nothing at all. Rank each ancestor directory alongside the files, and break same-tier ties toward the folder so `@docs` leads with `docs/` rather than `docs.md`. Outside a git repo the fallback `os.walk` compounded this: it can spend the whole `_FUZZY_CACHE_MAX_FILES` budget inside one deep subtree before reaching a sibling, hiding top-level folders entirely. Seed the scan with a `listdir` of the root so immediate children are always candidates. --- tests/gateway/test_complete_path_at_filter.py | 76 +++++++++++++++++++ tui_gateway/server.py | 58 ++++++++++---- 2 files changed, 120 insertions(+), 14 deletions(-) diff --git a/tests/gateway/test_complete_path_at_filter.py b/tests/gateway/test_complete_path_at_filter.py index 4a3e292b01fe..f46631dea271 100644 --- a/tests/gateway/test_complete_path_at_filter.py +++ b/tests/gateway/test_complete_path_at_filter.py @@ -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")] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 863601d68eb3..5ab0dd446760 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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), } )