diff --git a/CHANGELOG.md b/CHANGELOG.md index ce95d53..cdf8ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased +- "Did you mean" suggestions now appear live while you type. When your query stops matching any file, the closest file names (by fuzzy and edit distance) show up under a `did you mean` separator, without waiting for you to press Enter. Previously these suggestions only ever appeared in the large-workspace search box, and only after Enter. (#84) - The picker now also honors VS Code's built-in `search.exclude` by default, so you no longer have to duplicate those globs in `relativePath.ignore`. (`files.exclude` was already applied to the file scan by VS Code; it is now also applied to files created after the scan, keeping the cache consistent.) Opt out with the new `relativePath.respectSearchExclude: false`. (#31) ## 1.7.0 diff --git a/docs/superpowers/specs/2026-07-07-live-did-you-mean-suggestions-design.md b/docs/superpowers/specs/2026-07-07-live-did-you-mean-suggestions-design.md new file mode 100644 index 0000000..b457c16 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-live-did-you-mean-suggestions-design.md @@ -0,0 +1,115 @@ +# Live "did you mean" suggestions in the quick-filter picker + +**Date:** 2026-07-07 +**Status:** Approved + +## Problem + +`getClosestMatches` (subsequence + Damerau-Levenshtein) produces "did you +mean" suggestions when a query matches no file, but users almost never see +them. + +`findRelativePath()` has two picker paths: + +- **Quick-filter path** (`allowQuickFilter`, the common case: file count below + `relativePath.searchCountLimit`, default 10,000): uses `window.showQuickPick`. + VS Code does the live filtering; when the typed value matches nothing it shows + its native empty state and `getClosestMatches` is **never called**. +- **Input-box path** (>10k files): the closest-match fallback runs, but only + **after the user presses Enter** (`window.showInputBox`). + +So in the path nearly everyone hits, typing a typo shows an empty list and no +suggestions ever appear. + +## Goal + +In the quick-filter picker, when the current query matches no file, show the +`getClosestMatches` suggestions **live** (after a short debounce) without +requiring Enter. + +## Scope + +- **Changes:** the `showQuickPick()` method in `src/extension.ts` (used by the + `allowQuickFilter` branch). +- **New:** `src/picker-filter.ts` — the pure substring filter, plus + `test/picker-filter.test.ts`. +- **Unchanged:** `src/closest-match.ts`, the input-box path, and the + `searchCountLimit` threshold. + +## Design + +### Own the filtering (the core decision) + +The picker must offer suggestions exactly when the file list comes up empty. Two +attempts to derive that from VS Code's built-in filter both failed: + +1. **Predict its emptiness** with a home-grown subsequence matcher. VS Code's + fuzzy matcher is stricter than a raw subsequence (`tp` is a subsequence of + `types.ts`, yet VS Code shows nothing), so the proxy reported "has a match" + while the user saw a blank list — and, over a large list, reported "has a + match" for nearly every short query, so suggestions basically never fired. +2. **Read its result** via `quickPick.activeItems`. That value goes **stale** the + instant the filter empties — after typing `t` (matches `types.ts`) then `p` + (matches nothing), `activeItems` still held `types.ts`, so the code thought + there was a match and never offered suggestions. + +The fix is to stop cooperating with the native filter and **own the match**. +Every rendered row is given `alwaysShow: true`, which forces it past VS Code's +built-in filter, so the picker displays exactly the items we set. On each +keystroke we compute the matches ourselves (`filterPaths`), and "empty" becomes a +value we control rather than a signal we read. + +`filterPaths` uses the same rule as the large-workspace input-box path: a file +matches when the query is a case-insensitive substring of its workspace-relative +path. Both picker paths now share one definition of "match", so +`getClosestMatches` kicks in under exactly the same condition everywhere. + +### Picker wiring + +Migrate `showQuickPick()` from the fire-and-forget `window.showQuickPick` to a +managed `window.createQuickPick()`: + +1. Build the base item list (recently-used separators + files) as today, mark + every item `alwaysShow: true`, and set it as the initial `items`. +2. `onDidChangeValue(value)`: clear any pending timer, then: + - `value === ""` → show the full base list. + - `filterPaths(value, items)` non-empty → show those matches (each + `alwaysShow`), with the base placeholder. + - otherwise → set `items` to `[]` (VS Code shows its empty state) and start a + ~150ms debounce. On fire, `getClosestMatches(value, items)`; if non-empty, + set `items` to a `did you mean` separator followed by the suggestions (each + `alwaysShow`) and update the placeholder to + `No files match "". Showing the N closest names.` The debounce keeps + mid-word typing (`x` → `xy` → `xyz`) from flashing a suggestion list you're + about to type past. +3. `onDidAccept`: resolve `selectedItems[0]` via `returnRelativeLink`, then + `hide()`. Works for both real matches and suggestion items (both carry a + `description` = relative path). +4. `onDidHide`: clear the debounce timer and `dispose()` the quick pick, so the + timer never fires against a disposed picker. + +`matchOnDescription = true` stays on purely for match highlighting; it no longer +affects which rows show, because `alwaysShow` overrides filtering. + +Both `showQuickPick` callers (quick-filter path and the input-box sub-lists) +share this logic; it operates on whatever `items` were passed, so no caller +needs special-casing. + +## Testing + +- Unit-test `filterPaths`: substring hit, path-segment match, case + insensitivity, abbreviation → no match, no workspace-prefix match, empty + query. +- `closest-match.ts` retains its existing coverage. +- The picker-wiring layer (createQuickPick events, `alwaysShow`, debounce) + depends on the VS Code API and is exercised manually in the Extension + Development Host. + +## Edge cases + +- Empty value → base list, no suggestions. +- Debounce timer cleared on every keystroke and on hide/accept. +- Selecting a suggestion resolves a real relative path (same item shape as + matches). +- `getClosestMatches` returns nothing (empty workspace) → leave the native + empty state rather than an empty "did you mean". diff --git a/src/extension.ts b/src/extension.ts index 5c49285..dd386e9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,6 +22,7 @@ import { collectExcludeGlobs, normalizeIncludeGlob, } from "./globs"; +import { filterPaths } from "./picker-filter"; import { partitionByRecency, recordRecentPath } from "./recent-paths"; import { replaceSelections } from "./replace-selections"; import { isTruncated, resolveMaxResults } from "./search-limit"; @@ -46,6 +47,12 @@ export function activate(context: ExtensionContext) { const RECENTLY_USED_KEY = "relativePath.recentlyUsed"; +// How long the picker waits after the last keystroke before deciding the query +// matches nothing and swapping in "did you mean" suggestions. Short enough to +// feel live, long enough that mid-word typing (x -> xy -> xyz) doesn't flash a +// suggestion list you're about to type past (issue #84). +const SUGGESTION_DEBOUNCE_MS = 150; + class RelativePath { private _fileNames: string[]; private _truncated: boolean; @@ -305,46 +312,120 @@ class RelativePath { editor: TextEditor, placeHolder?: string ): void { - if (items) { - const toQuickPickItem = (val: string): QuickPickItem => ({ - description: val.replace(this._workspacePath, ""), - label: val.split("/").pop(), - }); - - // Surface the paths the user picked before above the rest of the - // list, mirroring VS Code's own "recently opened" behavior. - const { recent, rest } = this._configuration.get("showRecentlyUsed") - ? partitionByRecency(items, this._state.get(RECENTLY_USED_KEY)) - : { recent: [], rest: items }; - - const paths: QuickPickItem[] = - recent.length > 0 - ? [ - { - label: "recently used", - kind: QuickPickItemKind.Separator, - }, - ...recent.map(toQuickPickItem), - { - label: "other files", - kind: QuickPickItemKind.Separator, - }, - ...rest.map(toQuickPickItem), - ] - : items.map(toQuickPickItem); - - let pickResult: Thenable; - pickResult = window.showQuickPick(paths, { - matchOnDescription: true, - placeHolder: - placeHolder ?? `Type to filter ${items.length} files`, - }); - pickResult.then((item: QuickPickItem) => - this.returnRelativeLink(item, editor) - ); - } else { + if (!items) { window.showInformationMessage("No files to show."); + return; } + + const toQuickPickItem = (val: string): QuickPickItem => ({ + description: val.replace(this._workspacePath, ""), + label: val.split("/").pop(), + }); + + // Surface the paths the user picked before above the rest of the + // list, mirroring VS Code's own "recently opened" behavior. + const { recent, rest } = this._configuration.get("showRecentlyUsed") + ? partitionByRecency(items, this._state.get(RECENTLY_USED_KEY)) + : { recent: [], rest: items }; + + const basePaths: QuickPickItem[] = + recent.length > 0 + ? [ + { + label: "recently used", + kind: QuickPickItemKind.Separator, + }, + ...recent.map(toQuickPickItem), + { + label: "other files", + kind: QuickPickItemKind.Separator, + }, + ...rest.map(toQuickPickItem), + ] + : items.map(toQuickPickItem); + + const basePlaceholder = + placeHolder ?? `Type to filter ${items.length} files`; + + // Force every rendered row past VS Code's built-in filter so WE decide + // what shows. Two earlier attempts cooperated with the native filter + // (predicting its emptiness, then reading its `activeItems`) and both + // failed: the fuzzy matcher is opaque and `activeItems` goes stale the + // instant the filter empties. Owning the match makes "no results" a + // value we compute, so the suggestion fallback fires reliably (#84). + const asAlwaysShown = (item: QuickPickItem): QuickPickItem => ({ + ...item, + alwaysShow: true, + }); + + // A managed quick pick, not the fire-and-forget window.showQuickPick, + // so we can watch typing and offer closest-match suggestions when the + // query matches nothing (issue #84). + const quickPick = window.createQuickPick(); + quickPick.matchOnDescription = true; + quickPick.placeholder = basePlaceholder; + quickPick.items = basePaths.map(asAlwaysShown); + + let debounce: ReturnType | undefined; + + quickPick.onDidChangeValue((value) => { + if (debounce) { + clearTimeout(debounce); + } + + if (value === "") { + quickPick.items = basePaths.map(asAlwaysShown); + quickPick.placeholder = basePlaceholder; + return; + } + + const matches = filterPaths(value, items, this._workspacePath); + if (matches.length > 0) { + quickPick.items = matches.map((match) => + asAlwaysShown(toQuickPickItem(match)) + ); + quickPick.placeholder = basePlaceholder; + return; + } + + // No file matched. Show the empty state now, then swap in the + // closest names after a short pause so mid-word typing + // (x -> xy -> xyz) doesn't flash a suggestion list you're about to + // type past. + quickPick.items = []; + quickPick.placeholder = `No files match "${value}".`; + debounce = setTimeout(() => { + const suggestions = getClosestMatches(value, items); + if (suggestions.length === 0) { + return; + } + quickPick.items = [ + { + label: "did you mean", + kind: QuickPickItemKind.Separator, + }, + ...suggestions.map((suggestion) => + asAlwaysShown(toQuickPickItem(suggestion)) + ), + ]; + quickPick.placeholder = `No files match "${value}". Showing the ${suggestions.length} closest names.`; + }, SUGGESTION_DEBOUNCE_MS); + }); + + quickPick.onDidAccept(() => { + const [selected] = quickPick.selectedItems; + quickPick.hide(); + this.returnRelativeLink(selected, editor); + }); + + quickPick.onDidHide(() => { + if (debounce) { + clearTimeout(debounce); + } + quickPick.dispose(); + }); + + quickPick.show(); } // Check if the current extension should be excluded diff --git a/src/picker-filter.ts b/src/picker-filter.ts new file mode 100644 index 0000000..15ff304 --- /dev/null +++ b/src/picker-filter.ts @@ -0,0 +1,22 @@ +// The live picker owns its own filtering instead of leaning on VS Code's +// built-in quick-pick filter. Two earlier attempts tried to cooperate with the +// native filter (predict its emptiness, then read its `activeItems`) and both +// failed: its fuzzy matcher is opaque and `activeItems` goes stale the moment +// the filter empties. Owning the match makes "no results" something we compute, +// so the "did you mean" fallback (getClosestMatches) fires reliably. +// +// The rule matches the large-workspace input-box path (extension.ts): a file +// matches when the query is a case-insensitive substring of its workspace- +// relative path. Keeping both paths on the same rule means suggestions kick in +// under exactly the same condition everywhere. + +export function filterPaths( + query: string, + items: string[], + workspacePath: string +): string[] { + const needle = query.toLowerCase(); + return items.filter((item) => + item.replace(workspacePath, "").toLowerCase().includes(needle) + ); +} diff --git a/test/picker-filter.test.ts b/test/picker-filter.test.ts new file mode 100644 index 0000000..d7b825f --- /dev/null +++ b/test/picker-filter.test.ts @@ -0,0 +1,45 @@ +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import { filterPaths } from "../src/picker-filter"; + +const WORKSPACE = "/home/me/project"; +const FILES = [ + "/home/me/project/notes.md", + "/home/me/project/generated/schema.ts", + "/home/me/project/generated/types.ts", + "/home/me/project/src/index.ts", +]; + +test("keeps files whose relative path contains the query", () => { + assert.deepEqual(filterPaths("types", FILES, WORKSPACE), [ + "/home/me/project/generated/types.ts", + ]); +}); + +test("matches on any part of the relative path, not just the base name", () => { + assert.deepEqual(filterPaths("generated", FILES, WORKSPACE), [ + "/home/me/project/generated/schema.ts", + "/home/me/project/generated/types.ts", + ]); +}); + +test("is case-insensitive", () => { + assert.deepEqual(filterPaths("TYPES.TS", FILES, WORKSPACE), [ + "/home/me/project/generated/types.ts", + ]); +}); + +test("returns nothing for a non-substring query like an abbreviation", () => { + // "tp" is a subsequence of "types.ts" but not a substring, so it falls + // through to the getClosestMatches suggestion path (issue #84). + assert.deepEqual(filterPaths("tp", FILES, WORKSPACE), []); +}); + +test("does not match against the workspace prefix", () => { + // "project" only appears in the stripped-off workspace path. + assert.deepEqual(filterPaths("project", FILES, WORKSPACE), []); +}); + +test("an empty query keeps every file", () => { + assert.deepEqual(filterPaths("", FILES, WORKSPACE), FILES); +});