Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 "<value>". 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".
157 changes: 119 additions & 38 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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<QuickPickItem>;
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<typeof setTimeout> | 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
Expand Down
22 changes: 22 additions & 0 deletions src/picker-filter.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
45 changes: 45 additions & 0 deletions test/picker-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});