From c80075b98c31832be7869621b440afc5e039256f Mon Sep 17 00:00:00 2001 From: Hector Martinez Date: Mon, 11 May 2026 11:39:30 +0200 Subject: [PATCH 1/5] feat: add search filter to docs sidebar file tree Real-time text input in the sidebar filters the document tree by title and directory name. Matching is case-insensitive substring; ancestor directories are preserved for context. Filtered trees auto-expand all directories so matches are immediately visible. Closes #794 Co-Authored-By: Claude Opus 4.6 --- web/docs/src/App.svelte | 15 +++- web/docs/src/app.css | 22 +++++ web/docs/src/lib/DocTreeNav.svelte | 5 +- web/docs/src/lib/filterTree.test.ts | 133 ++++++++++++++++++++++++++++ web/docs/src/lib/filterTree.ts | 29 ++++++ 5 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 web/docs/src/lib/filterTree.test.ts create mode 100644 web/docs/src/lib/filterTree.ts diff --git a/web/docs/src/App.svelte b/web/docs/src/App.svelte index 7727988c..623cd4d7 100644 --- a/web/docs/src/App.svelte +++ b/web/docs/src/App.svelte @@ -14,6 +14,7 @@ import { bfsFirstRouteKeyUnderDir } from "./lib/manifestBfsDefault"; import { persistExpandedPathForRouteKey } from "./lib/treeSession"; import { DocTreeNav } from "./lib/tree"; + import { filterTree } from "./lib/filterTree"; const NAV_COLLAPSED_KEY = "fullsend-docs-nav-collapsed"; const WIDTH_STORAGE_KEY = "fullsend-docs-sidebar-width-px"; @@ -34,6 +35,8 @@ let narrowViewport = $state(false); /** Bumps when outline session keys change outside the tree (e.g. directory hash); keeps DocTreeNav in sync with sessionStorage. */ let outlineSessionEpoch = $state(0); + let filterQuery = $state(""); + let filteredManifest = $derived(filterTree(manifest, filterQuery)); let outlineExpanded = $derived( narrowViewport ? mobileNavOpen : !navCollapsed, @@ -422,11 +425,21 @@ +
+ +
diff --git a/web/docs/src/app.css b/web/docs/src/app.css index f917cec1..b9220fd2 100644 --- a/web/docs/src/app.css +++ b/web/docs/src/app.css @@ -260,6 +260,28 @@ body { display: inline-flex; } +.docs-tree-filter-wrap { + flex: 0 0 auto; + padding: 0.35rem 0.5rem 0; +} + +.docs-tree-filter { + width: 100%; + padding: 0.3rem 0.5rem; + font: inherit; + font-size: 0.85rem; + border: 1px solid var(--docs-border); + border-radius: 0.3rem; + background: var(--docs-surface); + color: var(--docs-text); + outline: none; +} + +.docs-tree-filter:focus { + border-color: var(--docs-link); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--docs-link) 25%, transparent); +} + .docs-tree-wrap { flex: 1; min-height: 0; diff --git a/web/docs/src/lib/DocTreeNav.svelte b/web/docs/src/lib/DocTreeNav.svelte index 63fb6228..979cb174 100644 --- a/web/docs/src/lib/DocTreeNav.svelte +++ b/web/docs/src/lib/DocTreeNav.svelte @@ -13,6 +13,7 @@ outlineSessionEpoch?: number; /** POSIX path segments for this level’s parent (e.g. `guides/admin`). */ parentDirPath?: string; + forceExpandAll?: boolean; } let { @@ -20,6 +21,7 @@ activeRouteKey, outlineSessionEpoch = 0, parentDirPath = "", + forceExpandAll = false, }: Props = $props(); /** Bumps when a folder is toggled so `isExpanded` re-reads sessionStorage. */ @@ -57,7 +59,7 @@
  • {#if node.type === "dir"} {@const dirPath = parentDirPath ? `${parentDirPath}/${node.name}` : node.name} - {@const expanded = isExpanded(dirPath)} + {@const expanded = forceExpandAll || isExpanded(dirPath)} {@const subId = childListId(dirPath)}
    {/if} diff --git a/web/docs/src/lib/filterTree.test.ts b/web/docs/src/lib/filterTree.test.ts new file mode 100644 index 00000000..e7bd9668 --- /dev/null +++ b/web/docs/src/lib/filterTree.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { filterTree } from "./filterTree"; +import type { ManifestNode } from "virtual:fullsend-docs"; + +const tree: ManifestNode[] = [ + { + type: "dir", + name: "guides", + children: [ + { + type: "dir", + name: "admin", + children: [ + { + type: "file", + name: "installation", + routeKey: "guides/admin/installation", + title: "Installation Guide", + }, + { + type: "file", + name: "config", + routeKey: "guides/admin/config", + title: "Configuration", + }, + ], + }, + { + type: "file", + name: "quickstart", + routeKey: "guides/quickstart", + title: "Quickstart", + }, + ], + }, + { + type: "file", + name: "readme", + routeKey: "readme", + title: "README", + }, +]; + +describe("filterTree", () => { + it("returns full tree when query is empty", () => { + expect(filterTree(tree, "")).toBe(tree); + expect(filterTree(tree, " ")).toBe(tree); + }); + + it("matches file by title", () => { + const result = filterTree(tree, "quickstart"); + expect(result).toEqual([ + { + type: "dir", + name: "guides", + children: [ + { + type: "file", + name: "quickstart", + routeKey: "guides/quickstart", + title: "Quickstart", + }, + ], + }, + ]); + }); + + it("keeps ancestor dirs for nested match", () => { + const result = filterTree(tree, "installation"); + expect(result).toEqual([ + { + type: "dir", + name: "guides", + children: [ + { + type: "dir", + name: "admin", + children: [ + { + type: "file", + name: "installation", + routeKey: "guides/admin/installation", + title: "Installation Guide", + }, + ], + }, + ], + }, + ]); + }); + + it("prunes branches with no matches", () => { + const result = filterTree(tree, "zzz"); + expect(result).toEqual([]); + }); + + it("matches case-insensitively", () => { + const result = filterTree(tree, "README"); + expect(result).toEqual([ + { type: "file", name: "readme", routeKey: "readme", title: "README" }, + ]); + }); + + it("matches dir name and keeps full subtree", () => { + const result = filterTree(tree, "admin"); + expect(result).toEqual([ + { + type: "dir", + name: "guides", + children: [ + { + type: "dir", + name: "admin", + children: [ + { + type: "file", + name: "installation", + routeKey: "guides/admin/installation", + title: "Installation Guide", + }, + { + type: "file", + name: "config", + routeKey: "guides/admin/config", + title: "Configuration", + }, + ], + }, + ], + }, + ]); + }); +}); diff --git a/web/docs/src/lib/filterTree.ts b/web/docs/src/lib/filterTree.ts new file mode 100644 index 00000000..2ff7a928 --- /dev/null +++ b/web/docs/src/lib/filterTree.ts @@ -0,0 +1,29 @@ +import type { ManifestNode } from "virtual:fullsend-docs"; + +export function filterTree( + nodes: ManifestNode[], + query: string, +): ManifestNode[] { + const q = query.trim().toLowerCase(); + if (!q) return nodes; + return pruneNodes(nodes, q); +} + +function pruneNodes(nodes: ManifestNode[], q: string): ManifestNode[] { + const out: ManifestNode[] = []; + for (const node of nodes) { + if (node.type === "file") { + if (node.title.toLowerCase().includes(q)) out.push(node); + } else { + if (node.name.toLowerCase().includes(q)) { + out.push(node); + } else { + const children = pruneNodes(node.children, q); + if (children.length > 0) { + out.push({ type: "dir", name: node.name, children }); + } + } + } + } + return out; +} From e7757535576eb811d73eed45dd329dbf462c00f9 Mon Sep 17 00:00:00 2001 From: Hector Martinez Date: Mon, 11 May 2026 13:48:40 +0200 Subject: [PATCH 2/5] feat: improve docs search with fuzzy matching and UX polish Address PR feedback: multi-word fuzzy search, path-inclusive matching (routeKey for files, dir path for dirs), substring highlighting via tags, search icon, and clear button. Co-Authored-By: Claude Opus 4.6 --- web/docs/src/App.svelte | 18 ++++- web/docs/src/app.css | 39 ++++++++++- web/docs/src/lib/DocTreeNav.svelte | 8 ++- web/docs/src/lib/filterTree.test.ts | 100 +++++++++++++++++++++++++++- web/docs/src/lib/filterTree.ts | 74 ++++++++++++++++++-- 5 files changed, 229 insertions(+), 10 deletions(-) diff --git a/web/docs/src/App.svelte b/web/docs/src/App.svelte index 623cd4d7..2e1f7d7a 100644 --- a/web/docs/src/App.svelte +++ b/web/docs/src/App.svelte @@ -426,13 +426,28 @@
    + + {#if filterQuery} + + {/if}
    diff --git a/web/docs/src/app.css b/web/docs/src/app.css index b9220fd2..ad4d4f8f 100644 --- a/web/docs/src/app.css +++ b/web/docs/src/app.css @@ -263,11 +263,21 @@ body { .docs-tree-filter-wrap { flex: 0 0 auto; padding: 0.35rem 0.5rem 0; + position: relative; +} + +.docs-tree-filter-icon { + position: absolute; + left: 0.85rem; + top: 50%; + transform: translateY(-50%); + color: var(--docs-muted, #888); + pointer-events: none; } .docs-tree-filter { width: 100%; - padding: 0.3rem 0.5rem; + padding: 0.3rem 0.5rem 0.3rem 1.8rem; font: inherit; font-size: 0.85rem; border: 1px solid var(--docs-border); @@ -282,6 +292,27 @@ body { box-shadow: 0 0 0 2px color-mix(in srgb, var(--docs-link) 25%, transparent); } +.docs-tree-filter-clear { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.15rem; + border: none; + border-radius: 0.2rem; + background: transparent; + color: var(--docs-muted, #888); + cursor: pointer; +} + +.docs-tree-filter-clear:hover { + color: var(--docs-text); + background: var(--docs-border); +} + .docs-tree-wrap { flex: 1; min-height: 0; @@ -406,6 +437,12 @@ body { font-weight: 600; } +.doc-tree-match { + background: color-mix(in srgb, var(--docs-link) 25%, transparent); + color: inherit; + border-radius: 0.15rem; +} + @media (max-width: 768px) { .docs-shell-inner { position: relative; diff --git a/web/docs/src/lib/DocTreeNav.svelte b/web/docs/src/lib/DocTreeNav.svelte index 979cb174..5c19367e 100644 --- a/web/docs/src/lib/DocTreeNav.svelte +++ b/web/docs/src/lib/DocTreeNav.svelte @@ -1,6 +1,7 @@