Skip to content

Commit 36bac52

Browse files
DragonnZhangclaude
andauthored
Add searchable filtering to the settings navigator (#40)
* Add searchable filtering to the settings navigator The settings navigator rendered a flat, unfilterable list of 16 sections. Add a search box at the top that filters sections by title and description (case-insensitive substring), mirroring the existing session-list search styling — search icon, clear button, focus ring, Esc-to-clear, and a "No results found" empty state. This matches the searchable-settings affordance in comparable desktop apps. Implementation is frontend-only and reuses existing i18n keys (common.search, common.noResultsFound), so no locale strings change. Also: - LeftSidebar: emit data-testid={link.id} on nav buttons so the settings entry point (and other nav items) are addressable from e2e. - e2e/app.ts: give each launch its own profile dir (keyed on the debug port) and launch under setsid on headless Linux so teardown reaps the whole xvfb-run tree. Without this, a lingering Electron instance held the single-instance lock and the next assertion could never get a renderer target — which blocked running more than one CDP assertion. - e2e/assertions/settings-search.assert.ts: drives the real app over CDP to verify the filter narrows, shows the empty state, and restores. * Update feature ledger: settings-search PR open (#40) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c8ccee commit 36bac52

5 files changed

Lines changed: 237 additions & 28 deletions

File tree

apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps & R
485485
if (!isOverlay && itemProps?.ref) itemProps.ref(el)
486486
}}
487487
onClick={isOverlay ? undefined : link.onClick}
488+
data-testid={link.id}
488489
data-tutorial={link.dataTutorial}
489490
className={cn(
490491
"group flex w-full items-center gap-2 rounded-[6px] text-[13px] select-none outline-none",

apps/electron/src/renderer/pages/settings/SettingsNavigator.tsx

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
*
44
* Navigator panel content for settings. Displays a list of settings sections
55
* (App, Workspace, Shortcuts, Preferences) that can be selected to show in the details panel.
6+
* A search box at the top filters the sections by their title and description,
7+
* matching the searchable-settings affordance found in comparable desktop apps.
68
*
79
* Styling follows SessionList/SourcesListPanel patterns for visual consistency.
810
*/
911

10-
import { useState, useMemo } from 'react'
12+
import { useMemo, useRef, useState } from 'react'
1113
import { useTranslation } from 'react-i18next'
12-
import { MoreHorizontal, AppWindow } from 'lucide-react'
14+
import { MoreHorizontal, AppWindow, Search, X } from 'lucide-react'
1315
import {
1416
DropdownMenu,
1517
DropdownMenuTrigger,
@@ -148,6 +150,8 @@ export default function SettingsNavigator({
148150
onSelectSubpage,
149151
}: SettingsNavigatorProps) {
150152
const { t } = useTranslation()
153+
const [query, setQuery] = useState('')
154+
const inputRef = useRef<HTMLInputElement>(null)
151155

152156
const settingsItems: SettingsItem[] = useMemo(() =>
153157
SETTINGS_ITEMS.map((item) => ({
@@ -159,21 +163,79 @@ export default function SettingsNavigator({
159163
[t]
160164
)
161165

166+
const normalizedQuery = query.trim().toLowerCase()
167+
const filteredItems = useMemo(() => {
168+
if (!normalizedQuery) return settingsItems
169+
return settingsItems.filter((item) =>
170+
`${item.label} ${item.description}`.toLowerCase().includes(normalizedQuery)
171+
)
172+
}, [settingsItems, normalizedQuery])
173+
174+
const hasQuery = normalizedQuery.length > 0
175+
162176
return (
163-
<div className="flex flex-col h-full">
164-
<div className="flex-1 overflow-y-auto">
165-
<div className="pt-2">
166-
{settingsItems.map((item, index) => (
167-
<SettingsItemRow
168-
key={item.id}
169-
item={item}
170-
isSelected={selectedSubpage === item.id}
171-
isFirst={index === 0}
172-
onSelect={() => onSelectSubpage(item.id)}
173-
/>
174-
))}
177+
<div className="flex flex-col h-full" data-testid="settings-navigator">
178+
{/* Search box — filters sections by title + description */}
179+
<div className="shrink-0 px-2 pt-2 pb-1.5 border-b border-border/50">
180+
<div className="relative rounded-[8px] shadow-minimal bg-muted/50 has-[:focus-visible]:bg-background">
181+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
182+
<input
183+
ref={inputRef}
184+
type="text"
185+
role="searchbox"
186+
aria-label={t('common.search')}
187+
data-testid="settings-search-input"
188+
value={query}
189+
onChange={(e) => setQuery(e.target.value)}
190+
onKeyDown={(e) => {
191+
if (e.key === 'Escape' && query) {
192+
e.preventDefault()
193+
e.stopPropagation()
194+
setQuery('')
195+
}
196+
}}
197+
placeholder={t('common.search')}
198+
className="w-full h-8 pl-8 pr-8 text-sm bg-transparent border-0 rounded-[8px] outline-none focus-visible:ring-0 focus-visible:outline-none placeholder:text-muted-foreground/50"
199+
/>
200+
{hasQuery && (
201+
<button
202+
type="button"
203+
onClick={() => {
204+
setQuery('')
205+
inputRef.current?.focus()
206+
}}
207+
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-foreground/10 rounded"
208+
title={t('common.clear')}
209+
aria-label={t('common.clear')}
210+
>
211+
<X className="h-3.5 w-3.5 text-muted-foreground" />
212+
</button>
213+
)}
175214
</div>
176215
</div>
216+
217+
<div className="flex-1 overflow-y-auto">
218+
{filteredItems.length > 0 ? (
219+
<div className="pt-2">
220+
{filteredItems.map((item, index) => (
221+
<SettingsItemRow
222+
key={item.id}
223+
item={item}
224+
isSelected={selectedSubpage === item.id}
225+
isFirst={index === 0}
226+
onSelect={() => onSelectSubpage(item.id)}
227+
/>
228+
))}
229+
</div>
230+
) : (
231+
<div
232+
data-testid="settings-search-empty"
233+
className="px-4 py-8 text-center text-sm text-muted-foreground"
234+
>
235+
{t('common.noResultsFound')}
236+
</div>
237+
)}
238+
</div>
177239
</div>
178240
)
179241
}

docs/loop/feature-ledger.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ log, not the system of record.
3232

3333
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3434
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
35-
| _(empty)_ | _first run appends here_ | | | | | | | | |
35+
| settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | pr-open | #39 | #40 | loop/settings-search | 2026-06-30 | Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. CDP assertion `e2e/assertions/settings-search.assert.ts` passes (2/2). |

e2e/app.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,16 @@ export async function launchApp(options: LaunchOptions = {}): Promise<LaunchedAp
120120
// - CRAFT_USER_DATA_DIR → Electron userData (cache, cookies, local storage)
121121
// - CRAFT_CONFIG_DIR → app config + project-workspace registry (~/.craft-agent)
122122
// - QWEN_DEFAULT_WORKSPACE_DIR → the default conversation workspace (else ~/Documents)
123-
const userDataDir = join(E2E_DIR, 'user-data');
124-
const configDir = join(E2E_DIR, 'config');
125-
const workspaceDir = join(E2E_DIR, 'workspace');
123+
//
124+
// Keyed on the (unique-per-launch) debug port so each assertion gets its own
125+
// profile. Electron's single-instance lock lives in userData; if a prior
126+
// instance lingers a moment past teardown, a shared profile would make the
127+
// next launch hand off to the old instance and exit — and no new renderer
128+
// target would ever appear.
129+
const profileKey = `p${port}`;
130+
const userDataDir = join(E2E_DIR, 'user-data', profileKey);
131+
const configDir = join(E2E_DIR, 'config', profileKey);
132+
const workspaceDir = join(E2E_DIR, 'workspace', profileKey);
126133
mkdirSync(userDataDir, { recursive: true });
127134
mkdirSync(configDir, { recursive: true });
128135
mkdirSync(workspaceDir, { recursive: true });
@@ -138,9 +145,17 @@ export async function launchApp(options: LaunchOptions = {}): Promise<LaunchedAp
138145
// Electron under a virtual framebuffer and disable the Chromium sandbox, which
139146
// cannot initialize as root inside a container.
140147
const headlessLinux = process.platform === 'linux' && !process.env.DISPLAY;
141-
const cmd = headlessLinux
148+
const baseCmd = headlessLinux
142149
? ['xvfb-run', '-a', ELECTRON_BIN, ...electronArgs, '--no-sandbox']
143150
: [ELECTRON_BIN, ...electronArgs];
151+
// Under headless Linux, launch in a fresh process group (via setsid) so
152+
// teardown can signal the whole tree. `xvfb-run` forks Xvfb and Electron as
153+
// separate children; a plain kill() of the wrapper orphans them, leaking the
154+
// Electron instance (and its single-instance lock) into the next assertion.
155+
// Other platforms launch Electron directly, so the wrapper problem — and the
156+
// need for `setsid`, which isn't present on macOS — does not apply.
157+
const useProcessGroup = headlessLinux;
158+
const cmd = useProcessGroup ? ['setsid', ...baseCmd] : baseCmd;
144159

145160
const proc: Subprocess = spawn({
146161
cmd,
@@ -158,25 +173,34 @@ export async function launchApp(options: LaunchOptions = {}): Promise<LaunchedAp
158173
});
159174

160175
let stopped = false;
161-
const stop = async (): Promise<void> => {
162-
if (stopped) return;
163-
stopped = true;
176+
// Signal the whole process group when we launched via setsid, so Xvfb and
177+
// Electron die with the wrapper instead of being orphaned.
178+
const signalTree = (signal: number): void => {
179+
if (useProcessGroup && typeof proc.pid === 'number') {
180+
try {
181+
process.kill(-proc.pid, signal);
182+
return;
183+
} catch {
184+
// group already gone — fall through to the single-process kill
185+
}
186+
}
164187
try {
165-
proc.kill();
188+
proc.kill(signal);
166189
} catch {
167190
// already gone
168191
}
192+
};
193+
const stop = async (): Promise<void> => {
194+
if (stopped) return;
195+
stopped = true;
196+
signalTree(15);
169197
// Escalate if it doesn't exit promptly.
170198
const exited = await Promise.race([
171199
proc.exited.then(() => true),
172200
Bun.sleep(5000).then(() => false),
173201
]);
174202
if (!exited) {
175-
try {
176-
proc.kill(9);
177-
} catch {
178-
// already gone
179-
}
203+
signalTree(9);
180204
}
181205
};
182206

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Feature assertion: the settings navigator has a working search box that
3+
* filters the settings sections by their title/description.
4+
*
5+
* Drives the real UI: opens Settings from the sidebar, types into the search
6+
* box, and asserts that the rendered section list narrows to matches, shows an
7+
* empty state for a no-match query, and restores fully when cleared.
8+
*/
9+
10+
import type { Assertion } from '../runner';
11+
12+
const SEARCH_INPUT = '[data-testid="settings-search-input"]';
13+
const ROW = '.settings-item';
14+
const EMPTY = '[data-testid="settings-search-empty"]';
15+
16+
/** Text content (lowercased) of every rendered settings row. */
17+
function readRowTextsExpr(): string {
18+
return `JSON.stringify(Array.from(document.querySelectorAll(${JSON.stringify(
19+
ROW,
20+
)})).map((el) => (el.textContent || '').toLowerCase()))`;
21+
}
22+
23+
/** Set a controlled input's value the way React expects, then fire `input`. */
24+
function setInputExpr(value: string): string {
25+
return `(() => {
26+
const input = document.querySelector(${JSON.stringify(SEARCH_INPUT)});
27+
if (!input) return false;
28+
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
29+
setter.call(input, ${JSON.stringify(value)});
30+
input.dispatchEvent(new Event('input', { bubbles: true }));
31+
return true;
32+
})()`;
33+
}
34+
35+
const assertion: Assertion = {
36+
name: 'settings navigator search filters sections',
37+
async run(app) {
38+
const { session } = app;
39+
40+
// App fully mounted.
41+
await session.waitForFunction(
42+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
43+
{ timeoutMs: 30000, message: 'app did not mount' },
44+
);
45+
46+
// Open Settings from the sidebar (real user path).
47+
await session.click('[data-testid="nav:settings"]', { timeoutMs: 15000 });
48+
49+
// The search box is part of the feature under test — its presence is the
50+
// first signal the feature shipped.
51+
await session.waitForSelector(SEARCH_INPUT, {
52+
timeoutMs: 15000,
53+
message: 'settings search input did not render',
54+
});
55+
await session.waitForFunction(
56+
`document.querySelectorAll(${JSON.stringify(ROW)}).length > 1`,
57+
{ timeoutMs: 10000, message: 'settings rows did not render' },
58+
);
59+
60+
const allTexts = JSON.parse(await session.evaluate<string>(readRowTextsExpr()));
61+
const total: number = allTexts.length;
62+
if (total < 2) throw new Error(`expected multiple settings rows, saw ${total}`);
63+
64+
// Derive a query from the first row's visible title so the test is
65+
// locale-independent (the row label is whatever the active language renders).
66+
const firstLabel = (
67+
await session.evaluate<string | null>(
68+
`(() => { const el = document.querySelector(${JSON.stringify(
69+
ROW,
70+
)} + ' .font-medium'); return el ? el.textContent : null; })()`,
71+
)
72+
)?.trim();
73+
if (!firstLabel) throw new Error('could not read a settings row label to search for');
74+
const query = firstLabel.toLowerCase();
75+
76+
// Type the query and wait for the list to settle to the predicted subset.
77+
if (!(await session.evaluate<boolean>(setInputExpr(firstLabel)))) {
78+
throw new Error('search input disappeared before typing');
79+
}
80+
const expectedMatches = allTexts.filter((t: string) => t.includes(query)).length;
81+
await session.waitForFunction(
82+
`document.querySelectorAll(${JSON.stringify(ROW)}).length === ${expectedMatches}`,
83+
{
84+
timeoutMs: 8000,
85+
message: `filtered list did not narrow to the ${expectedMatches} predicted match(es)`,
86+
},
87+
);
88+
89+
// Every visible row must actually contain the query, and the searched-for
90+
// section must still be present.
91+
const visTexts: string[] = JSON.parse(await session.evaluate<string>(readRowTextsExpr()));
92+
if (visTexts.length === 0) throw new Error('query matched nothing — expected at least its own row');
93+
if (visTexts.length >= total) {
94+
throw new Error(`filtering did not reduce the list (${visTexts.length} of ${total})`);
95+
}
96+
for (const t of visTexts) {
97+
if (!t.includes(query)) {
98+
throw new Error(`visible row "${t}" does not contain query "${query}"`);
99+
}
100+
}
101+
102+
// A no-match query shows the empty state and hides every row.
103+
await session.evaluate(setInputExpr('zzqqxxnomatchzzqqxx'));
104+
await session.waitForSelector(EMPTY, {
105+
timeoutMs: 8000,
106+
message: 'empty state did not show for a no-match query',
107+
});
108+
const noneLeft = await session.evaluate<number>(
109+
`document.querySelectorAll(${JSON.stringify(ROW)}).length`,
110+
);
111+
if (noneLeft !== 0) throw new Error(`expected 0 rows for a no-match query, saw ${noneLeft}`);
112+
113+
// Clearing restores the full list.
114+
await session.evaluate(setInputExpr(''));
115+
await session.waitForFunction(
116+
`document.querySelectorAll(${JSON.stringify(ROW)}).length === ${total}`,
117+
{ timeoutMs: 8000, message: 'clearing the query did not restore all rows' },
118+
);
119+
},
120+
};
121+
122+
export default assertion;

0 commit comments

Comments
 (0)