feat(settings): one page per section with a grouped nav and search
Settings moves from a tab bar to /settings/<section>: a left nav grouped General / Agents / Routing / Git / Projects (a select on narrow screens), a search box over every section (Ctrl/Cmd+F, Enter opens the first match), and row primitives. Each AI tool has its page under agents/<id>, the AI Tools modal becomes the Agents page, Pricing shows every vendor, Git → Worktrees sets which untracked worktrees the sidebar lists, and each project gets a page with its own worktree override and Forget all. ?tool= and #ai-tools links redirect to the path form.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @file settingsSearch.test.ts
|
||||
* @description `rankSettings`: an empty query matches nothing, a section title
|
||||
* outranks an entry title, which outranks a description and a keyword, exact
|
||||
* beats prefix beats substring, and a query matching nothing returns nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rankSettings, type SettingsEntry } from "../settingsSearch";
|
||||
|
||||
const entries: SettingsEntry[] = [
|
||||
{ section: "pricing", title: "Pricing", sectionTitle: true },
|
||||
{ section: "claude", title: "Claude Code", sectionTitle: true },
|
||||
{ section: "claude", title: "Model Pricing" },
|
||||
{ section: "about", title: "About", description: "Server pricing info" },
|
||||
{ section: "kw", title: "Other", keywords: ["pricing"] },
|
||||
{ section: "prefix", title: "Pricing tables", sectionTitle: true },
|
||||
];
|
||||
|
||||
describe("rankSettings", () => {
|
||||
it("returns nothing for an empty or blank query", () => {
|
||||
expect(rankSettings("", entries)).toEqual([]);
|
||||
expect(rankSettings(" ", entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders by field tier, then exact > prefix > substring", () => {
|
||||
expect(rankSettings("Pricing", entries)).toEqual([
|
||||
{ section: "pricing", score: 900 },
|
||||
{ section: "prefix", score: 850 },
|
||||
{ section: "claude", score: 600 },
|
||||
{ section: "about", score: 400 },
|
||||
{ section: "kw", score: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing when no field matches", () => {
|
||||
expect(rankSettings("zzz", entries)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
* ONE localStorage key (`agenthub.sidebar.projects`): which projects are
|
||||
* collapsed, whether a repo's untracked worktrees are listed, and which
|
||||
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
|
||||
* LANES_ROOT) are hidden. A `useSyncExternalStore` reader that caches nothing
|
||||
* LANES_ROOT) are hidden — globally, or per project root, where a project's own
|
||||
* list (Settings → Projects) replaces the global one. A `useSyncExternalStore` reader that caches nothing
|
||||
* but the parsed blob for the current raw string, so the sidebar and Settings
|
||||
* can never disagree. Nothing here reaches the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -17,9 +18,16 @@ export interface ProjectTreePrefs {
|
||||
collapsed: Record<string, boolean>;
|
||||
showUntracked: boolean;
|
||||
hideSources: string[];
|
||||
/** Per project root: replaces `hideSources` for that project. */
|
||||
projectHideSources: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const DEFAULTS: ProjectTreePrefs = { collapsed: {}, showUntracked: true, hideSources: [] };
|
||||
const DEFAULTS: ProjectTreePrefs = {
|
||||
collapsed: {},
|
||||
showUntracked: true,
|
||||
hideSources: [],
|
||||
projectHideSources: {},
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS };
|
||||
|
||||
@@ -41,6 +49,10 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
|
||||
collapsed: parsed.collapsed && typeof parsed.collapsed === "object" ? parsed.collapsed : {},
|
||||
showUntracked: parsed.showUntracked !== false,
|
||||
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
||||
projectHideSources:
|
||||
parsed.projectHideSources && typeof parsed.projectHideSources === "object"
|
||||
? parsed.projectHideSources
|
||||
: {},
|
||||
};
|
||||
} catch {
|
||||
value = DEFAULTS;
|
||||
@@ -58,6 +70,11 @@ export function writeProjectTreePrefs(patch: Partial<ProjectTreePrefs>): void {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** The sources hidden for one project: its own override, else the global list. */
|
||||
export function hiddenSourcesFor(prefs: ProjectTreePrefs, root: string | null): string[] {
|
||||
return (root !== null && prefs.projectHideSources[root]) || prefs.hideSources;
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file settingsSearch — the Settings page's search: `SETTINGS_INDEX` names the
|
||||
* rows each section holds (as i18n keys, resolved at render), and
|
||||
* `rankSettings` scores sections against a query. Per field, exact beats
|
||||
* prefix beats substring: section title 900/850/800, entry title 700/650/600,
|
||||
* description 500/450/400, keywords 300/250/200; a section scores its best hit.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export interface SettingsEntry {
|
||||
section: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
/** The section's own title (scored in the top tier). */
|
||||
sectionTitle?: boolean;
|
||||
}
|
||||
|
||||
/** One searchable row per section, by i18n key in the `settings` namespace. A
|
||||
* section whose id starts with `agents/` and is not listed here is a catalog
|
||||
* tool's page and takes the `agents/*` entries. */
|
||||
export const SETTINGS_INDEX: {
|
||||
section: string;
|
||||
titleKey: string;
|
||||
descKey?: string;
|
||||
keywords?: string[];
|
||||
}[] = [
|
||||
{ section: "general", titleKey: "lanesSection.title", descKey: "lanesSection.description" },
|
||||
{ section: "general", titleKey: "lanesSection.runHousekeeping" },
|
||||
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] },
|
||||
{ section: "general", titleKey: "remoteSources.title", keywords: ["ssh", "machines"] },
|
||||
{ section: "general", titleKey: "sagi.title", descKey: "sagi.description" },
|
||||
{ section: "general", titleKey: "data.title", descKey: "data.description" },
|
||||
{ section: "general", titleKey: "danger.title", keywords: ["clear", "delete", "export"] },
|
||||
{ section: "general", titleKey: "about.title", keywords: ["version", "server", "database"] },
|
||||
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
|
||||
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
|
||||
{ section: "appearance", titleKey: "appearance.lightThemes" },
|
||||
{ section: "notifications", titleKey: "notifications.enable", keywords: ["push", "browser"] },
|
||||
{ section: "notifications", titleKey: "notifications.awaitingInput" },
|
||||
{ section: "notifications", titleKey: "notifications.turnComplete" },
|
||||
{ section: "notifications", titleKey: "alertsHub.title", descKey: "alertsHub.description" },
|
||||
{ section: "notifications", titleKey: "webhooks.title", keywords: ["slack", "discord"] },
|
||||
{
|
||||
section: "agents",
|
||||
titleKey: "aiTools.title",
|
||||
descKey: "aiTools.description",
|
||||
keywords: ["install", "update", "default"],
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "pricing.title", keywords: ["cost", "price"] },
|
||||
{ section: "agents/claude-code", titleKey: "hooks.title" },
|
||||
{ section: "agents/claude-code", titleKey: "claudeHome.title", keywords: ["profiles"] },
|
||||
{ section: "agents/codex", titleKey: "pricing.title", keywords: ["cost", "price"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
{ section: "agents/codex", titleKey: "codexHome.title" },
|
||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
||||
{ section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
|
||||
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "sidebar"] },
|
||||
{ section: "worktrees", titleKey: "worktrees.claude" },
|
||||
{ section: "worktrees", titleKey: "worktrees.agenthub" },
|
||||
];
|
||||
|
||||
function tier(hay: string | undefined, q: string, exact: number): number {
|
||||
if (!hay) return 0;
|
||||
const h = hay.toLowerCase();
|
||||
if (h === q) return exact;
|
||||
if (h.startsWith(q)) return exact - 50;
|
||||
if (h.includes(q)) return exact - 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Sections matching `query`, best first; ties keep the entries' order. */
|
||||
export function rankSettings(
|
||||
query: string,
|
||||
entries: SettingsEntry[]
|
||||
): { section: string; score: number }[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const best = new Map<string, number>();
|
||||
for (const e of entries) {
|
||||
const score = Math.max(
|
||||
tier(e.title, q, e.sectionTitle ? 900 : 700),
|
||||
tier(e.description, q, 500),
|
||||
...(e.keywords ?? []).map((k) => tier(k, q, 300))
|
||||
);
|
||||
if (score > (best.get(e.section) ?? 0)) best.set(e.section, score);
|
||||
}
|
||||
return [...best]
|
||||
.map(([section, score]) => ({ section, score }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
Reference in New Issue
Block a user