mirror of
https://github.com/Heretek-AI/ompweb.git
synced 2026-11-09 02:39:49 -10:00
* feat(explorer): add file search backed by the existing file index
In a monorepo, finding a file in the explorer means expanding directory
levels by hand. The @ mention autocomplete already searches the workspace
through /api/file-index (git-aware listing, fuzzy ranking, TTL cache), but
the explorer had no way to use it.
Add a search toggle to the explorer toolbar. It opens a debounced input
that queries the same endpoint, and folds the matches into the tree the
explorer already renders, so opening a result, the @ mention action, the
download action and the git status badges all keep working. No second
index and no new API route.
Details worth noting:
- lib/search-tree.ts folds flat result paths into a directory tree,
directories first, alphabetical per level, duplicates collapsed.
- TreeNode's refreshToken is now optional. Search-result nodes arrive with
their children already resolved, so they must not re-fetch a full
directory listing on mount and overwrite the filtered children.
- Directories containing a match auto-expand as you type, while manual
collapses survive further keystrokes.
- Closing the panel (button or Escape) resets query, results and expansion
state, matching how the session search behaves.
Verified on this repo: typing search-tree finds the module and its test
nested under lib/, clicking a result opens it in a tab, the mention button
inserts @ompweb/lib/search-tree.ts into the composer, Escape restores the
regular tree. typecheck, lint and npm test (430) pass.
* fix(explorer): raise the search result cap and refresh results with the tree
Three problems an independent review pass turned up, plus one I hit while
measuring the feature on this repo.
The result cap was inherited from the @ menu: /api/file-index called
filterFileEntries without a limit, so it fell back to AT_RESULT_LIMIT of
20. That is right for a popup and wrong for a scrollable search panel, and
it was invisible: searching "route" in this repo matched 52 paths and
showed 20 of them with no indication. The endpoint now accepts an optional
limit, clamped by parseResultLimit to MAX_RESULT_LIMIT (200), defaulting
to the old value so the @ menu is untouched. The explorer asks for the
maximum; "route" now renders all 55 rows.
The search request now depends on refreshToken, so the toolbar refresh and
a finished upload re-run the visible query instead of leaving stale rows
next to a freshly refreshed tree. Verified in the browser: refreshing with
a query active re-issues the request and keeps the query.
buildSearchTree no longer trusts its input shape. Empty segments from a
stray leading, doubled or trailing slash used to create nameless nodes, and
a name that arrived as a leaf before its children ("a" then "a/b") stayed
marked as a file, which made those children unreachable in the rendered
tree. Both are covered by tests, along with a 500-level path.
Also added the abort guard to the success branch of the fetch chain, which
had it only on catch and finally.
* fix(explorer): show search results ranked instead of folded into a tree
The index already ranks matches: exact name, then name prefix, then name
substring, then path substring, then subsequence. Folding those matches
into a directory tree threw that ranking away and re-sorted everything
alphabetically, directories first. Searching "agent" in this repo put six
app/api/agent/**/route.ts files at the top, which match only because an
ancestor directory is named agent, while AGENTS.md, AgentsConfig.tsx and
agents-service.ts sat at the bottom.
Render a flat list in the order the index returned, one row per file, with
the directory beside the name in dimmed text so a row is still
identifiable when twenty files are called route.ts. The name keeps its
width and the directory truncates from the left, since the name is what
the user is scanning for.
lib/search-tree.ts and its tests are gone with the tree; lib/search-results.ts
replaces them and its tests pin the property that actually matters, that
row order equals index order.
Verified in the browser: "agent" now lists AGENTS.md, AgentsConfig.tsx,
agent-client.ts, agents-service.* first and the path-only matches last;
"auth" leads with web-auth.ts rather than app/api/auth/**/route.ts.
* fix(explorer): keep search results fresh and spend the limit on files
Two problems a review pass found in the previous commits.
Directories were dropped client-side, after the server had already applied
the limit, and directories carry a ranking bonus. Searching "api" in this
repo matches 67 directories out of 129 results, so half the budget went to
rows the panel throws away, and a repo with more matching directories than
the limit would push files out of the response entirely. The endpoint now
takes kind=file and filters before ranking, cached alongside the existing
lazy entries so the extra pass runs once per listing rather than per
keystroke. The @ menu, which does show directories, is untouched.
Refreshing re-ran the query but could still be answered from the index's
ten second TTL cache, so a file just written by an agent stayed invisible.
refresh=1 rebuilds the listing, and the client sends it only when the
refresh token actually changed, consumed at request time so an aborted
debounce keeps it pending. Verified: a file created a moment earlier is
missing without the flag and present with it.
Search rows also put their directory in the accessible name, since twenty
rows reading "route.ts (file)" are indistinguishable to a screen reader.
* fix(explorer): bound forced index refreshes and fix their accounting
A review pass found four problems with the refresh flag added in the
previous commit.
refresh=1 skipped the TTL cache on every request, so each one started its
own git scan. Concurrent refreshes could pile scans up, and a slower older
scan could overwrite a newer cache entry. Rebuilds for a cwd now share a
single in-flight scan, and a forced rebuild is allowed once per 250ms
window. The window is deliberately short: the flag exists so a file written
a moment ago shows up, and a longer window would defeat that.
Replacing an existing cache key does not grow the cache, so it no longer
triggers the clear-on-cap path that wiped the listings of unrelated
projects.
On the client, the refresh token was marked consumed before the request
came back, so a failed or aborted request swallowed the refresh. It is
acknowledged on success now, with the baseline seeded when the request is
scheduled rather than when one first succeeds, otherwise a refresh clicked
while the very first search is still in flight finds no baseline and is
quietly answered from the cache. The sidebar bumps the explorer key once on
mount, which is not a refresh anyone asked for, so the first search of a
session never forces a rebuild.
Verified against a running instance: a file created moments earlier is
missing without the flag and present with it; a plain search sends no
refresh while the toolbar button does; twelve concurrent forced refreshes
finish in tens of milliseconds with no lingering git scans; kind=file still
returns no directories and the @ menu still returns twenty matches with
directories included.
* fix(explorer): report a truncated result set and drop deleted index entries
Two things the automated PR review caught, both confirmed against a
running instance.
A broad query silently lost matches. The panel asks for the maximum the
endpoint allows, and the endpoint sliced to that number without saying so:
"ts" matches more than 200 files here, so the list looked complete while
a third of it was missing. The endpoint now ranks one past the limit to
learn whether anything was cut, never ships that extra row, and returns a
truncated flag; the panel shows a line saying how many matches it is
showing. The @ menu path is unchanged and still returns no such field.
Search could also list files that no longer exist. git ls-files --cached
reports index entries, so a file deleted from the working tree but not yet
staged stayed in the listing, and opening that row answered 404. The
listing now subtracts git ls-files --deleted. This was pre-existing and
also affected @ mentions; the search panel just made it easy to hit.
Verified: "ts" returns 200 matches with truncated true and the panel shows
the notice, "agent" returns 24 with truncated false and no notice, the @
menu still returns 20 matches with directories and no truncated field, and
a file staged then deleted from the working tree disappears from results.
* fix(explorer): stop rate-limiting the refresh the user asked for
An audit pass against a running instance found four problems in the refresh
and truncation handling added in the previous commits.
The 250ms forced-refresh window defeats the flag's only purpose. Reproduced:
write a file, answer a plain search (which builds the listing), then send
refresh=1 immediately — the new file is missing, with nothing in the response
or the UI saying so. The same query 320ms later finds it. loadListing already
collapses concurrent rebuilds onto one scan, which is the bound that matters;
an age gate on top of it can only suppress the one refresh a user explicitly
asked for, so it is gone, along with builtAt.
--deleted shared a try/catch with the main listing, so a failure of the
auxiliary query discarded the whole git listing and fell back to listWithWalk
— depth-capped at 8 and blind to .gitignore. A secondary query must not be
able to silently degrade every result, so its failure is now tolerated.
hardTruncated was reported only on the no-query branch, so a listing that hit
GIT_HARD_CAP / WALK_HARD_CAP still returned a search result set that looked
complete. truncated now covers both causes, and the notice no longer promises
"the first N" when fewer than N matched.
Also pinned the search box (with up to 200 rows it scrolled out of view, so
the query became uneditable), armed the loading state on the keystroke rather
than after paint, and dropped a flexShrink longhand that only overrode the
shorthand two lines above it.
lib/file-index-route.test.mjs drives the handler against temp git repos,
following lib/sse-lifecycle.test.mjs (jiti + @/ alias) and lib/worktree.test.mjs
(git fixtures). Against the pre-change handler it fails with
"expected ['late.ts'], actual []" — the cooldown bug above.
Verified against a running instance: a file created after a cached listing is
visible to refresh=1 with no wait; 12 concurrent forced refreshes still
coalesce; a .gitignore'd file stays out of results, so the git path survives
the --deleted call; limit/kind/truncated boundaries unchanged; the @ menu still
answers {matches} only, 20 rows, directories included. typecheck clean, lint
clean (2 pre-existing warnings in untouched files), npm test 430 (426 pass,
4 skipped).
---------
Co-authored-by: Khaled Ahmed <[email protected]>
40 lines
1.4 KiB
JavaScript
40 lines
1.4 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import React from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { createJiti } from "jiti";
|
|
|
|
const jiti = createJiti(import.meta.url, {
|
|
jsx: { runtime: "automatic" },
|
|
tsconfigPaths: true,
|
|
});
|
|
const { FileExplorer } = await jiti.import("./FileExplorer.tsx");
|
|
|
|
function render(props = {}) {
|
|
return renderToStaticMarkup(
|
|
React.createElement(FileExplorer, {
|
|
cwd: "/tmp/project",
|
|
onOpenFile() {},
|
|
...props,
|
|
}),
|
|
);
|
|
}
|
|
|
|
test("renders the search input only while the search panel is open", () => {
|
|
const closed = render();
|
|
assert.doesNotMatch(closed, /aria-label="(Search files|fileExplorer\.searchFiles)"/);
|
|
|
|
const open = render({ fileSearchOpen: true });
|
|
assert.match(open, /aria-label="(Search files|fileExplorer\.searchFiles)"/);
|
|
assert.match(open, /placeholder="(Search files\.\.\.|fileExplorer\.searchPlaceholder)"/);
|
|
});
|
|
|
|
test("keeps the regular file tree visible while the search query is empty", () => {
|
|
// An open panel with no query must not hide the tree behind an empty
|
|
// search-results state.
|
|
const open = render({ fileSearchOpen: true });
|
|
assert.match(open, /role="tree"/);
|
|
assert.match(open, /(Loading files\.\.\.|fileExplorer\.loadingFiles)/);
|
|
assert.doesNotMatch(open, /(No matching files|fileExplorer\.noMatchingFiles)/);
|
|
});
|