From 71b8739639302e7b09400d3e8da7c45a579d00bc Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 23 Sep 2026 22:51:17 +0800 Subject: [PATCH 1/2] feat(ui): search settings by field, name event types, say what the clock counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 034. Settings search matched page names only (#143 had relabelled the box "Filter categories…" to be honest about it). It now matches the text each page renders — group titles, field labels, hints — in the displayed language. Choosing a result opens its page and brings the text into view. The index is generated from the page sources (npm run gen:settings-search) and a test fails when a page renders a key the index lacks. The box says "Search settings…" again, and the IA test now allows that label only while the search is wired. The filter bar named types by storage name (http_navigation, credential_use). It now uses the Timeline's lane names from one shared table (LANE_LABEL_KEYS / agentTypeLabel); the stored name is the tooltip, and a plugin type with no lane keeps its own name. The status-bar clock counts from the project's creation — deliberately, per audit P1 #33 — but next to REC it read as this session's recording time. It keeps its meaning and now says it: tooltip and aria-label name the creation date and "not this session". Co-Authored-By: Claude Opus 5.5 --- package.json | 3 +- scripts/gen-settings-search.mjs | 26 ++ scripts/settings-search-sources.mjs | 38 +++ .../checklists/requirements.md | 6 + specs/034-operator-words/plan.md | 27 ++ specs/034-operator-words/quickstart.md | 8 + specs/034-operator-words/research.md | 9 + specs/034-operator-words/spec.md | 62 +++++ specs/034-operator-words/tasks.md | 8 + specs/034-operator-words/verification.md | 28 +++ src/renderer/src/components/FilterBar.tsx | 17 +- src/renderer/src/components/Settings.tsx | 44 +++- src/renderer/src/components/StatusBar.tsx | 14 +- src/renderer/src/components/Timeline.tsx | 26 +- src/renderer/src/i18n/en.json | 8 +- src/renderer/src/i18n/zh-TW.json | 8 +- src/renderer/src/lib/settingsSearch.ts | 34 +++ src/renderer/src/lib/settingsSearchIndex.ts | 237 ++++++++++++++++++ src/renderer/src/lib/timelineDomain.ts | 29 +++ test/agent-type-label.test.ts | 32 +++ test/renderer-smoke.test.tsx | 20 +- test/settings-ia.test.ts | 19 +- test/settings-search.test.ts | 40 +++ 23 files changed, 689 insertions(+), 54 deletions(-) create mode 100644 scripts/gen-settings-search.mjs create mode 100644 scripts/settings-search-sources.mjs create mode 100644 specs/034-operator-words/checklists/requirements.md create mode 100644 specs/034-operator-words/plan.md create mode 100644 specs/034-operator-words/quickstart.md create mode 100644 specs/034-operator-words/research.md create mode 100644 specs/034-operator-words/spec.md create mode 100644 specs/034-operator-words/tasks.md create mode 100644 specs/034-operator-words/verification.md create mode 100644 src/renderer/src/lib/settingsSearch.ts create mode 100644 src/renderer/src/lib/settingsSearchIndex.ts create mode 100644 test/agent-type-label.test.ts create mode 100644 test/settings-search.test.ts diff --git a/package.json b/package.json index 6fb57c34..3d273a64 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "pree2e": "electron-rebuild -f -o better-sqlite3", "e2e": "playwright test", "pree2e:ui": "electron-rebuild -f -o better-sqlite3", - "e2e:ui": "playwright test --ui" + "e2e:ui": "playwright test --ui", + "gen:settings-search": "node scripts/gen-settings-search.mjs" }, "dependencies": { "@fontsource/jetbrains-mono": "^5.3.0", diff --git a/scripts/gen-settings-search.mjs b/scripts/gen-settings-search.mjs new file mode 100644 index 00000000..bd764389 --- /dev/null +++ b/scripts/gen-settings-search.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Regenerate src/renderer/src/lib/settingsSearchIndex.ts from the Settings +// page sources. Run after adding or removing a setting; the settings-search +// test fails until you do. +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { PAGE_SOURCES, pageKeys } from './settings-search-sources.mjs' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const read = (f) => fs.readFileSync(path.join(root, 'src/renderer/src/components', f), 'utf8') +let out = `// GENERATED by \`npm run gen:settings-search\` from the Settings page sources +// (scripts/settings-search-sources.mjs). test/settings-search.test.ts fails +// when a page renders a key this index does not have — regenerate. + +import type { SettingsPage } from '../components/Settings' + +export const SETTINGS_SEARCH_KEYS: Record = { +` +const pages = Object.keys(PAGE_SOURCES) +pages.forEach((p, i) => { + out += ` ${p}: [\n${pageKeys(p, read).map((k) => ` '${k}'`).join(',\n')}\n ]${i < pages.length - 1 ? ',' : ''}\n` +}) +out += '}\n' +fs.writeFileSync(path.join(root, 'src/renderer/src/lib/settingsSearchIndex.ts'), out) +console.log('settingsSearchIndex.ts regenerated') diff --git a/scripts/settings-search-sources.mjs b/scripts/settings-search-sources.mjs new file mode 100644 index 00000000..9efd50f7 --- /dev/null +++ b/scripts/settings-search-sources.mjs @@ -0,0 +1,38 @@ +// Which source files make up each Settings page, for the field-search index +// (src/renderer/src/lib/settingsSearchIndex.ts) and the test that keeps that +// index in step with the pages. `after` takes the file from that marker on; +// `before` up to it — AgentsPanel.tsx holds two panels shown on two pages. +export const PAGE_SOURCES = { + hooks: [ + { file: 'settings/HooksPanel.tsx' }, + { file: 'WslPanel.tsx' }, + { file: 'settings/AgentsPanel.tsx', after: 'export function HookWatchPathsPanel' } + ], + agents: [{ file: 'settings/AgentsPanel.tsx', before: 'export function HookWatchPathsPanel' }], + captureControl: [{ file: 'settings/CaptureControlPage.tsx' }, { file: 'settings/LootRulesGroup.tsx' }], + scope: [{ file: 'settings/ScopePage.tsx' }], + network: [{ file: 'settings/NetworkPage.tsx' }], + integrity: [{ file: 'settings/IntegrityPanel.tsx' }], + plugins: [{ file: 'settings/PluginsPanel.tsx' }], + general: [{ file: 'settings/GeneralPage.tsx' }], + hud: [{ file: 'settings/HudPage.tsx' }] +} + +const KEY = /\bt\(\s*['"]([a-zA-Z]+\.[A-Za-z0-9_.]+)['"]/g + +/** The i18n keys a page renders, in first-seen order. `read` returns a + * component file's source by its path under src/renderer/src/components. */ +export function pageKeys(page, read) { + const keys = [] + for (const { file, after, before } of PAGE_SOURCES[page]) { + let src = read(file) + if (after) src = src.slice(src.indexOf(after)) + if (before) src = src.slice(0, src.indexOf(before)) + for (const m of src.matchAll(KEY)) { + // Toasts and shared button words are not something you look a setting up by. + if (/^(toast|common)\./.test(m[1])) continue + if (!keys.includes(m[1])) keys.push(m[1]) + } + } + return keys +} diff --git a/specs/034-operator-words/checklists/requirements.md b/specs/034-operator-words/checklists/requirements.md new file mode 100644 index 00000000..d6d1f13c --- /dev/null +++ b/specs/034-operator-words/checklists/requirements.md @@ -0,0 +1,6 @@ +# Requirements Checklist: Settings Search, Type Names, Clock Meaning + +- [x] Each FR has an observable, tested outcome. +- [x] Empty and no-match states specified. +- [x] The clock decision records the prior audit decision it keeps. +- [x] The label change is reconciled with #143's earlier relabel. diff --git a/specs/034-operator-words/plan.md b/specs/034-operator-words/plan.md new file mode 100644 index 00000000..6b7e43bc --- /dev/null +++ b/specs/034-operator-words/plan.md @@ -0,0 +1,27 @@ +# Implementation Plan: Settings Search, Type Names, Clock Meaning + +## Canonical module interface + +- `lib/settingsSearch.ts` `searchSettings(query, t)` over + `lib/settingsSearchIndex.ts` (generated by `scripts/gen-settings-search.mjs` + from `scripts/settings-search-sources.mjs`). +- `lib/timelineDomain.ts` `LANE_LABEL_KEYS`, `agentTypeLabel(type, t)` — + the Timeline and the filter bar both read them. + +## Constitution Check + +- **Surface Truthfulness**: the search label is tied to a wired search; the + clock states what it counts; types have names an operator uses. +- **Architectural Restraint**: no new settings pages or rearrangement; one + shared name table instead of a second one. +- **Risk-Based Test-First**: copy-level changes, tested by render and source + guards; the index guard prevents silent drift. + +## Design + +1. Index: per page, the i18n keys its source renders (toast/common excluded); + `AgentsPanel.tsx` is split at `HookWatchPathsPanel`, which renders on Hooks. +2. Settings: results under the page list; choosing one switches page and + scrolls the first element containing the text into view with a brief ring. +3. Filter bar and Timeline read `LANE_LABEL_KEYS`. +4. Status bar keeps its project-creation clock; adds tooltip and aria-label. diff --git a/specs/034-operator-words/quickstart.md b/specs/034-operator-words/quickstart.md new file mode 100644 index 00000000..0f005071 --- /dev/null +++ b/specs/034-operator-words/quickstart.md @@ -0,0 +1,8 @@ +# Quickstart: Settings Search, Type Names, Clock Meaning + +1. Settings → type `retention` (or `保存`) → pick the result → Capture control + opens at "Retention and disk budgets". +2. Timeline → Filter → Type: options read Shell, HTTP, Proxy…; hover shows the + stored type. +3. Hover the status-bar clock: "Time since this project was created (…), not + this session". diff --git a/specs/034-operator-words/research.md b/specs/034-operator-words/research.md new file mode 100644 index 00000000..c116268a --- /dev/null +++ b/specs/034-operator-words/research.md @@ -0,0 +1,9 @@ +# Research: Settings Search, Type Names, Clock Meaning + +- **Why generated keys, not a hand list**: a hand list drifts silently; a + generated list with a test that compares it to the source cannot. +- **Why not a runtime DOM crawl of all pages**: pages render only when open, + and some fetch data first; the i18n keys are static and complete. +- **Clock**: `StatusBar.tsx` documents audit P1 #33 — the counter is + engagement-scoped by decision. The operator-feedback item offered "session + start, or say project-creation"; the second keeps that decision. diff --git a/specs/034-operator-words/spec.md b/specs/034-operator-words/spec.md new file mode 100644 index 00000000..b2604493 --- /dev/null +++ b/specs/034-operator-words/spec.md @@ -0,0 +1,62 @@ +# Feature Specification: Settings Search, Type Names, Clock Meaning + +**Feature Branch**: `feat/settings-field-search` +**Created**: 2026-09-23 +**Status**: Verified +**Input**: Three places where the app speaks in its own terms instead of the operator's: Settings search only knew page names, the filter bar named types by storage name, and the status-bar clock did not say what it counts. + +## Clarifications + +### Session 2026-09-23 + +- Q: What does Settings search match? → A: the text each page renders — + group titles, field labels and hints — in the displayed language; page names + still filter the page list. +- Q: How is the index kept current? → A: generated from each page's source + (`npm run gen:settings-search`); a test fails when a page renders a key the + index lacks. +- Q: What names do types get? → A: the Timeline's lane names, from one shared + table; a plugin type with no lane keeps its stored name; the stored name is + the tooltip. +- Q: Should the clock count this session instead? → A: No. It runs from the + project's creation on purpose (audit P1 #33: reopening the app reset a + session clock mid-engagement). It now says so. +- Q: The box was relabelled "Filter categories…" in #143. → A: it searches + settings again, so it is "Search settings…" again; a test ties the label to + the search being wired. + +## User Scenarios & Testing + +### User Story 1 - Find a setting by what it is called (Priority: P1) + +**Independent Test**: Typing "loot detection" lists the Loot detection group; +choosing it opens Capture control with the group on screen. + +### User Story 2 - Filter by a type's name (Priority: P2) + +**Independent Test**: The type picker and chip say "HTTP", not +`http_navigation`; the stored name is the tooltip. + +### User Story 3 - Know what the clock counts (Priority: P2) + +**Independent Test**: The status-bar clock's tooltip says it runs from the +project's creation date, not this session. + +### Edge Cases + +- A query matching nothing says "No setting matches …". +- A result whose text is truncated keeps its full text in a tooltip. + +## Requirements + +- **FR-001**: Settings search MUST match rendered setting text per page and + open the page of a chosen result, bringing the text into view. +- **FR-002**: The search index MUST fail a test when it falls behind a page. +- **FR-003**: Type names in the filter bar MUST come from the Timeline's lane + name table. +- **FR-004**: The clock MUST state that it counts from project creation. + +## Success Criteria + +- **SC-001**: `settings-search`, `agent-type-label`, the new renderer-smoke + cases and `settings-ia` pass. diff --git a/specs/034-operator-words/tasks.md b/specs/034-operator-words/tasks.md new file mode 100644 index 00000000..ed61126f --- /dev/null +++ b/specs/034-operator-words/tasks.md @@ -0,0 +1,8 @@ +# Tasks: Settings Search, Type Names, Clock Meaning + +- [x] T001 Page-source map and generator; generate the index. +- [x] T002 `searchSettings` with index-drift, language and empty-query tests. +- [x] T003 Settings UI: results, navigation, scroll/highlight, labels; IA test rewritten for the wired search. +- [x] T004 Shared lane name table; filter bar chip and options use it; tests. +- [x] T005 Status-bar clock tooltip and aria-label; render test. +- [x] T006 Full suite, typecheck, build; record verification and gates. diff --git a/specs/034-operator-words/verification.md b/specs/034-operator-words/verification.md new file mode 100644 index 00000000..331b66fd --- /dev/null +++ b/specs/034-operator-words/verification.md @@ -0,0 +1,28 @@ +# Verification: Settings Search, Type Names, Clock Meaning + +## RED + +Not required as a failing-first run for the copy/labeling parts (Constitution +VIII, low-risk copy); the search module and its tests were written together. +The one guard that changed meaning — `settings-ia` asserted the box said +"Filter categories…" because it matched page names only — was rewritten to +require the search to be wired whenever the label says "Search settings…". + +## GREEN + +- `settings-search` 4, `agent-type-label` 3, `settings-ia`, and renderer-smoke + (21, including "search finds a field and opens its page" and the clock + tooltip) pass. +- The truncation guard caught a truncated result without its own tooltip; + fixed. +- Full suite outside the sandbox: 214 files pass (one run failed only on the + truncation guard above, before the fix). Typecheck and production build pass. + +## Gates + +| Gate | Result | Date | +|------|--------|------| +| Clarify | 5 answered — in spec.md | 2026-09-23 | +| Checklist | `checklists/requirements.md`: 4 items, all pass | 2026-09-23 | +| Analyze | 1 finding fixed: the plan (task list) called for a session clock, which conflicts with audit P1 #33's documented decision; FR-004 labels the existing clock instead | 2026-09-23 | +| Converge | 1 finding fixed: truncation guard (result text without its own tooltip) | 2026-09-23 | diff --git a/src/renderer/src/components/FilterBar.tsx b/src/renderer/src/components/FilterBar.tsx index 21d8d6fc..581570a9 100644 --- a/src/renderer/src/components/FilterBar.tsx +++ b/src/renderer/src/components/FilterBar.tsx @@ -3,6 +3,7 @@ import { Filter, X, ChevronDown } from 'lucide-react' import { useSharedFilter } from '../lib/FilterContext' import { useI18n } from '../i18n' import { formatTime } from '../lib/time' +import { agentTypeLabel } from '../lib/timelineDomain' export function FilterBar(): JSX.Element | null { const { filter, setTargetId, setAgentType, setTimeRange, setInScopeOnly, setHidePersonal, clearAll, @@ -41,7 +42,11 @@ export function FilterBar(): JSX.Element | null { setTargetId(null)} /> )} {filter.agentType && ( - setAgentType(null)} /> + setAgentType(null)} + /> )} {filter.timeRange && ( ({ value: at, label: at }))} + options={knownAgentTypes.map((at) => ({ value: at, label: agentTypeLabel(at, t), title: at }))} placeholder={t('filter.allTypes')} /> @@ -144,9 +149,9 @@ export function FilterBar(): JSX.Element | null { ) } -function Chip({ label, onClear }: { label: string; onClear: () => void }): JSX.Element { +function Chip({ label, title, onClear }: { label: string; title?: string; onClear: () => void }): JSX.Element { return ( - + {label} + ))} + + )} {saved && (

@@ -159,7 +193,7 @@ export default function Settings({ request = null }: { request?: { page: Setting {/* The right pane is a column: content scrolls, the save bar stays put. */}

-
+
{tab === 'general' && } {tab === 'hud' && } {tab === 'network' && } diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx index a070134f..28371147 100644 --- a/src/renderer/src/components/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar.tsx @@ -4,7 +4,7 @@ import { toast } from './Toast' import { toggleRecordingWithFeedback } from '../lib/recordingToggle' import { Gem } from 'lucide-react' import { useIssues, raiseIssue, clearIssue } from '../lib/issues' -import { formatTime } from '../lib/time' +import { formatTime, formatDateTime } from '../lib/time' import { useAppCounts } from '../lib/useAppCounts' export default function StatusBar(): JSX.Element { @@ -12,6 +12,9 @@ export default function StatusBar(): JSX.Element { const [ipStatus, setIpStatus] = useState(null) const [loggedCount, setLoggedCount] = useState(0) const [uptime, setUptime] = useState(0) + // The counter runs from the project's creation (audit P1 #33), which a bare + // number next to REC does not say — it reads as this session's recording time. + const [since, setSince] = useState(null) const [recording, setRecording] = useState(true) const [pausedAt, setPausedAt] = useState(null) const [pauseElapsed, setPauseElapsed] = useState(0) @@ -29,7 +32,7 @@ export default function StatusBar(): JSX.Element { // first render can be off by a second, subsequent poll ticks correct. let start = Date.now() window.redlog.project.active().then((p) => { - if (p?.createdAt) start = p.createdAt + if (p?.createdAt) { start = p.createdAt; setSince(p.createdAt) } }) window.redlog.ip.getStatus().then(setIpStatus) // v0.13.0: fetch the logged-tier count for the chained·logged split. @@ -209,7 +212,12 @@ export default function StatusBar(): JSX.Element { : captureVerdict === 'dark' || (recording && !lastEventAt) ? t('statusBar.captureWaiting') : t('statusBar.rec') } - {uptimeStr} + {uptimeStr} ) })()} diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index f6bfdc3e..99b0ab07 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -27,7 +27,7 @@ import { isHookSource, isHousekeeping } from '../lib/housekeeping' import { isMac } from '../lib/platform' import { useContributeExport } from '../lib/exportScope' import { - LANES, type LaneId, BANDS, type BandId, BAND_OF, EXTERNAL_ONLY_LANES, LANE_COLORS, + LANES, LANE_LABEL_KEYS, type LaneId, BANDS, type BandId, BAND_OF, EXTERNAL_ONLY_LANES, LANE_COLORS, type PluginEventType, type DotShape, IO_MARK_COLOR, displayTs, toLane, eventCompare, binarySearchInsert, axisLabel, formatBehind, ioMark, dotShape, shapeTitle, ioTitle, @@ -499,26 +499,10 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr const pendingZoomAnchor = useRef<{ frac: number; cursorX: number } | null>(null) const { t } = useI18n() - const laneLabels: Record = useMemo(() => ({ - shell: t('timeline.shell'), - agent: t('timeline.agent'), - http_navigation: t('timeline.http'), - scanner: t('timeline.scanner'), - browser: t('timeline.browser'), - dns: t('timeline.dns'), - pivot: t('timeline.pivot'), - screenshot: t('timeline.screenshot'), - clipboard: t('timeline.clipboard'), - file_transfer: t('timeline.files'), - credential_use: t('timeline.credentialUse'), - c2_checkin: t('timeline.c2Checkin'), - marker: t('timeline.markers'), - loot: t('timeline.loot'), - cleanup: t('timeline.cleanup'), - scope: t('timeline.scope'), - process: t('timeline.process'), - system: t('timeline.system') - }), [t]) + const laneLabels = useMemo( + () => Object.fromEntries(LANES.map((id) => [id, t(LANE_LABEL_KEYS[id])])) as Record, + [t] + ) useEffect(() => { if (!containerRef.current) return diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index ee11dc43..388e1645 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -865,8 +865,6 @@ "palette.hostHits": "{{count}} events", "palette.operatorPrimary": "primary", "settings.categories": "Settings categories", - "settings.filterPages": "Filter categories…", - "settings.noPageMatches": "No category matches “{{query}}”", "settings.groupCapture": "What to record", "settings.groupScope": "Scope", "settings.groupEvidence": "Evidence", @@ -1198,5 +1196,9 @@ "settings.lootGroup": "Loot detection", "settings.lootHint": "Rules that are off are not recorded as loot. What they match is still masked in the record and in exports.", "settings.lootBuiltin": "built-in", - "settings.lootRulesFailed": "Could not load the loot rules." + "settings.lootRulesFailed": "Could not load the loot rules.", + "settings.searchSettings": "Search settings…", + "settings.noSettingMatches": "No setting matches “{{query}}”", + "settings.searchResults": "Settings", + "statusBar.uptimeSince": "Time since this project was created ({{date}}), not this session" } diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index 93c9b922..c8810ad3 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -865,8 +865,6 @@ "palette.hostHits": "{{count}} 筆", "palette.operatorPrimary": "主要", "settings.categories": "設定分類", - "settings.filterPages": "篩選分類…", - "settings.noPageMatches": "沒有符合「{{query}}」的分類", "settings.groupCapture": "記錄什麼", "settings.groupScope": "範圍", "settings.groupEvidence": "證據保全", @@ -1198,5 +1196,9 @@ "settings.lootGroup": "戰利品偵測", "settings.lootHint": "關閉的規則不會記成戰利品;它比對到的內容在紀錄與匯出中仍會被遮蔽。", "settings.lootBuiltin": "內建", - "settings.lootRulesFailed": "無法載入戰利品規則。" + "settings.lootRulesFailed": "無法載入戰利品規則。", + "settings.searchSettings": "搜尋設定…", + "settings.noSettingMatches": "沒有符合「{{query}}」的設定", + "settings.searchResults": "設定項目", + "statusBar.uptimeSince": "自專案建立({{date}})起的時間,不是本次工作階段" } diff --git a/src/renderer/src/lib/settingsSearch.ts b/src/renderer/src/lib/settingsSearch.ts new file mode 100644 index 00000000..d43badd7 --- /dev/null +++ b/src/renderer/src/lib/settingsSearch.ts @@ -0,0 +1,34 @@ +import type { SettingsPage } from '../components/Settings' +import { SETTINGS_SEARCH_KEYS } from './settingsSearchIndex' + +export interface SettingsHit { + page: SettingsPage + /** The rendered text that matched — a group title, a field label or a hint. */ + text: string +} + +/** Every setting whose rendered text contains `query`, page by page. The box + * used to match page names only, so "proxy" or "保留" found nothing unless it + * happened to be a page name. */ +export function searchSettings( + query: string, + t: (key: string) => string, + limit = 30 +): SettingsHit[] { + const q = query.trim().toLowerCase() + if (!q) return [] + const hits: SettingsHit[] = [] + const seen = new Set() + for (const [page, keys] of Object.entries(SETTINGS_SEARCH_KEYS) as Array<[SettingsPage, readonly string[]]>) { + for (const key of keys) { + const text = t(key) + if (!text || text === key || !text.toLowerCase().includes(q)) continue + const id = `${page}\0${text}` + if (seen.has(id)) continue + seen.add(id) + hits.push({ page, text }) + if (hits.length >= limit) return hits + } + } + return hits +} diff --git a/src/renderer/src/lib/settingsSearchIndex.ts b/src/renderer/src/lib/settingsSearchIndex.ts new file mode 100644 index 00000000..262b11fa --- /dev/null +++ b/src/renderer/src/lib/settingsSearchIndex.ts @@ -0,0 +1,237 @@ +// GENERATED by `npm run gen:settings-search` from the Settings page sources +// (scripts/settings-search-sources.mjs). test/settings-search.test.ts fails +// when a page renders a key this index does not have — regenerate. + +import type { SettingsPage } from '../components/Settings' + +export const SETTINGS_SEARCH_KEYS: Record = { + hooks: [ + 'settings.hookInstallFailed', + 'settings.hookFailedWhy', + 'settings.hooksDetected', + 'settings.hooksHint', + 'settings.hookActive', + 'settings.hookManual', + 'settings.hookNotFound', + 'settings.hookDisable', + 'settings.hookEnable', + 'settings.hookHideSetup', + 'settings.hookShowSetup', + 'settings.hookCopy', + 'settings.hookManualNote', + 'settings.wsl.hookInstalled', + 'settings.wsl.hookInstallFailed', + 'settings.wsl.hookInstallFailedWhy', + 'settings.wsl.distroUnreachableWhy', + 'settings.wsl.hookUninstalled', + 'settings.wsl.hookUninstallFailed', + 'settings.wsl.hookUninstallFailedWhy', + 'settings.wsl.diagnosticsFailed', + 'settings.wsl.title', + 'settings.wsl.refresh', + 'settings.wsl.noDistros', + 'settings.wsl.installed', + 'settings.wsl.notInstalled', + 'settings.wsl.noShell', + 'settings.wsl.networkMirrored', + 'settings.wsl.networkNat', + 'settings.wsl.natFix', + 'settings.wsl.networkNotConfigured', + 'settings.wsl.running', + 'settings.wsl.stoppedBadge', + 'settings.wsl.default', + 'settings.wsl.stopped', + 'settings.wsl.installing', + 'settings.wsl.install', + 'settings.wsl.uninstall', + 'settings.wsl.diagnose', + 'settings.wsl.diagTitle', + 'settings.hookWatchPaths.saved', + 'settings.hookWatchPaths.title', + 'settings.hookWatchPaths.hint', + 'settings.hookWatchPaths.empty', + 'settings.hookWatchPaths.remove', + 'settings.hookWatchPaths.pickFolder' + ], + agents: [ + 'settings.agents', + 'settings.agents.hint', + 'settings.agents.enable', + 'settings.agents.enableHint', + 'settings.agents.emitThinking', + 'settings.agents.emitThinkingHint', + 'settings.agents.selfExclusionHint' + ], + captureControl: [ + 'settings.clipboardGroup', + 'settings.clipboardEnable', + 'settings.clipboardEnableHint', + 'settings.clipboardStorePreview', + 'settings.clipboardStorePreviewHint', + 'settings.fileWatcherGroup', + 'settings.fileWatcherEnable', + 'settings.fileWatcherEnableHint', + 'settings.fileWatcherPaths', + 'settings.fileWatcherPathsPlaceholder', + 'settings.fileWatcherIgnore', + 'settings.fileWatcherIgnorePlaceholder', + 'settings.fileWatcherIgnoreHint', + 'settings.processMonitorGroup', + 'settings.processMonitorEnable', + 'settings.processMonitorEnableHint', + 'settings.processMonitorIgnore', + 'settings.processMonitorIgnorePlaceholder', + 'settings.processMonitorIgnoreHint', + 'settings.connectionMonitorGroup', + 'settings.connectionMonitorEnable', + 'settings.connectionMonitorEnableHint', + 'settings.connectionMonitorSynNote', + 'settings.powershellTranscriptGroup', + 'settings.powershellTranscriptEnable', + 'settings.powershellTranscriptEnableHint', + 'settings.screenshotGroup', + 'settings.screenshot.intervalHint', + 'settings.jpegQuality', + 'settings.qualityHint', + 'settings.screenshot.diffLabel', + 'settings.screenshot.diffHint', + 'settings.screenshot.onCommand', + 'settings.screenshot.onCommandHint', + 'settings.retentionGroup', + 'settings.rotationHint', + 'settings.retentionLoggedTierHint', + 'settings.retentionLoggedTier', + 'settings.lootGroup', + 'settings.lootHint', + 'settings.lootRulesFailed', + 'settings.lootBuiltin' + ], + scope: [ + 'settings.scopeEnforcement', + 'settings.warnOnViolation', + 'settings.warnOnViolationHint', + 'settings.inScopeTargets', + 'settings.targetsLabel', + 'settings.targetsPlaceholder', + 'settings.excludedTargets', + 'settings.excludeLabel', + 'settings.excludePlaceholder', + 'settings.personalDomains', + 'settings.personalDomainsLabel', + 'settings.personalDomainsPlaceholder', + 'settings.personalDomainsHint', + 'settings.scopeFile', + 'settings.scopeFileLabel', + 'settings.scopeFileHint' + ], + network: [ + 'settings.vpnAdapters', + 'settings.vpnAdaptersHint', + 'settings.vpnAddCustom', + 'settings.vpnNamePlaceholder', + 'settings.vpnPatternPlaceholder', + 'settings.cdp', + 'settings.cdpHint', + 'settings.cdpConnected', + 'settings.cdpNotConnectedTitle', + 'settings.cdpNotConnected', + 'settings.testConnection', + 'settings.ipSafety', + 'settings.whitelist', + 'settings.safeIpPlaceholder', + 'settings.blacklist', + 'settings.exposedIpPlaceholder', + 'settings.ownTraffic', + 'settings.ipMode', + 'settings.ipModeHint', + 'settings.showWifiName', + 'settings.showWifiNameHint', + 'settings.checkInterval', + 'settings.pollingOpsecHint', + 'settings.confirmations', + 'settings.confirmationsHint', + 'settings.ipProviders', + 'settings.ipProvidersHint' + ], + integrity: [ + 'settings.anchored', + 'settings.anchorFailed', + 'settings.anchorFailedWhy', + 'settings.integrityNoAnchors', + 'settings.integrityNoAnchorsWhy', + 'issues.chainBroken', + 'settings.integrity', + 'settings.integrityHint', + 'settings.integrityAnchoring', + 'settings.integrityAnchorNow', + 'settings.integrityVerifying', + 'settings.integrityVerifyFull', + 'settings.integrityUpgraded', + 'settings.integrityUpgradeAll', + 'settings.integrityFullWalked', + 'settings.integrityFullBrokenAt', + 'settings.integrityFullReason', + 'settings.integrityHeadHash', + 'settings.integrityFullAnchorMatch', + 'settings.integrityFullAnchorMismatch', + 'settings.integrityFullNoAnchor', + 'settings.integrityFullClockAnomalies', + 'settings.integrityEvents' + ], + plugins: [ + 'plugins.disabled', + 'plugins.disabledWhy', + 'plugins.granted', + 'plugins.grantFailed', + 'plugins.grantFailedWhy', + 'settings.plugins', + 'plugins.hint', + 'plugins.openFolderHint', + 'plugins.openFolder', + 'plugins.reload', + 'plugins.empty', + 'plugins.tier.privileged', + 'plugins.tier.declarative', + 'plugins.contributes', + 'plugins.capabilities', + 'plugins.enable', + 'plugins.disable', + 'plugins.review', + 'plugins.revoke', + 'plugins.dir', + 'plugins.consentTitle', + 'plugins.consentBody', + 'plugins.consentWarn', + 'plugins.grantRun' + ], + general: [ + 'settings.engagement', + 'settings.id', + 'settings.name', + 'settings.operatorGroup', + 'settings.handoffProfile', + 'settings.exportProfile', + 'settings.handoffProfileHint', + 'settings.language', + 'settings.uiScale', + 'settings.disclosure', + 'settings.showAllPages', + 'settings.showAllPagesHint' + ], + hud: [ + 'settings.overlayGroup', + 'settings.overlayShowMark', + 'settings.overlayShowMarkHint', + 'settings.overlayFlashExposed', + 'settings.overlayFlashExposedHint', + 'settings.overlayScale', + 'settings.overlayScaleHint', + 'settings.overlayEmphasizeIp', + 'settings.overlayEmphasizeIpHint', + 'settings.overlayPassThrough', + 'settings.overlayPassThroughHint', + 'settings.overlayPassThroughOpacity', + 'settings.overlayShowInDock', + 'settings.overlayShowInDockHint' + ] +} diff --git a/src/renderer/src/lib/timelineDomain.ts b/src/renderer/src/lib/timelineDomain.ts index b1641a6d..93fe0241 100644 --- a/src/renderer/src/lib/timelineDomain.ts +++ b/src/renderer/src/lib/timelineDomain.ts @@ -117,6 +117,35 @@ export function firstStringArg(input: Record, cap: number): str return keys.length ? `{${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', …' : ''}}` : '' } +/** The i18n key of each lane's name — shared by the Timeline's lane labels and + * the filter bar's type picker, so both say "HTTP", not `http_navigation`. */ +export const LANE_LABEL_KEYS: Record = { + shell: 'timeline.shell', + agent: 'timeline.agent', + http_navigation: 'timeline.http', + scanner: 'timeline.scanner', + browser: 'timeline.browser', + dns: 'timeline.dns', + pivot: 'timeline.pivot', + screenshot: 'timeline.screenshot', + clipboard: 'timeline.clipboard', + file_transfer: 'timeline.files', + credential_use: 'timeline.credentialUse', + c2_checkin: 'timeline.c2Checkin', + marker: 'timeline.markers', + loot: 'timeline.loot', + cleanup: 'timeline.cleanup', + scope: 'timeline.scope', + process: 'timeline.process', + system: 'timeline.system' +} + +/** What an operator calls an event type. A type with no lane of its own (a + * plugin's) keeps its stored name — there is nothing better to show. */ +export function agentTypeLabel(agentType: string, t: (key: string) => string): string { + return (LANES as readonly string[]).includes(agentType) ? t(LANE_LABEL_KEYS[agentType as LaneId]) : agentType +} + export function toLane(agentType: string, subtype?: string, pluginTypes?: PluginEventType[]): LaneId { // Scope violations are stored under agent_type='system' for historical reasons // (historical: a since-removed webhook filter watched 'system'). Route them into their own diff --git a/test/agent-type-label.test.ts b/test/agent-type-label.test.ts new file mode 100644 index 00000000..5b5f950c --- /dev/null +++ b/test/agent-type-label.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import fs from 'fs' +import path from 'path' +import { agentTypeLabel, LANES, LANE_LABEL_KEYS } from '../src/renderer/src/lib/timelineDomain' +import en from '../src/renderer/src/i18n/en.json' +import zh from '../src/renderer/src/i18n/zh-TW.json' + +// The filter bar named types by their storage names (`http_navigation`, +// `credential_use`). It now uses the Timeline's lane names, from one table. +const tr = (d: Record) => (k: string): string => d[k] ?? k + +describe('agentTypeLabel', () => { + it('names every lane in both languages', () => { + for (const lane of LANES) { + expect(en[LANE_LABEL_KEYS[lane] as keyof typeof en], lane).toBeTruthy() + expect(zh[LANE_LABEL_KEYS[lane] as keyof typeof zh], lane).toBeTruthy() + } + }) + + it('shows the operator word, and keeps a plugin type it has no word for', () => { + expect(agentTypeLabel('http_navigation', tr(en))).toBe('HTTP') + expect(agentTypeLabel('shell', tr(en))).toBe('Shell') + expect(agentTypeLabel('acme_scanner', tr(en))).toBe('acme_scanner') + }) + + it('is what the filter bar shows for the type chip and options', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'src/renderer/src/components/FilterBar.tsx'), 'utf8') + expect(src).toMatch(/agentTypeLabel\(filter\.agentType, t\)/) + expect(src).toMatch(/label: agentTypeLabel\(at, t\)/) + expect(src).not.toMatch(/label: at \}/) + }) +}) diff --git a/test/renderer-smoke.test.tsx b/test/renderer-smoke.test.tsx index f1899ea9..5a80e430 100644 --- a/test/renderer-smoke.test.tsx +++ b/test/renderer-smoke.test.tsx @@ -8,7 +8,7 @@ // least one event of every agent_type, which is exactly the shape that broke. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { render, cleanup, screen, fireEvent, act } from '@testing-library/react' +import { render, cleanup, screen, fireEvent, act, within, waitFor } from '@testing-library/react' import { I18nProvider } from '../src/renderer/src/i18n' import App from '../src/renderer/src/App' @@ -93,7 +93,7 @@ function installBridge(): void { create: async () => ({ id: 'p1', name: 'Proj', createdAt: 1, lastOpened: 2, path: '/tmp/p1' }), open: async () => ({ id: 'p1', name: 'Proj', createdAt: 1, lastOpened: 2, path: '/tmp/p1' }), delete: async () => true, - active: async () => ({ id: 'p1', name: 'Proj' }) + active: async () => ({ id: 'p1', name: 'Proj', createdAt: Date.UTC(2026, 8, 1, 9, 0) }) }, ip: { getStatus: async () => ({ @@ -330,6 +330,22 @@ describe('renderer views render without throwing', () => { // #47: the artifact-rotation eviction budgets live in the "Capture control" // Settings tab. Switching to it must surface the controls. + it('StatusBar says its clock runs from the project\'s creation, not this session', async () => { + renderView() + const clock = await screen.findByTestId('statusbar-uptime') + await waitFor(() => expect(clock.getAttribute('title')).toMatch(/since this project was created .*not this session/)) + }) + + it('Settings search finds a field and opens the page that holds it', async () => { + renderView() + const box = await screen.findByPlaceholderText('Search settings…') + fireEvent.change(box, { target: { value: 'loot detection' } }) + const results = await screen.findByTestId('settings-search-results') + fireEvent.click(within(results).getByText('Loot detection')) + // The Capture control page is open: its Loot detection hint is on screen. + expect(await screen.findByText(/Rules that are off are not recorded as loot/)).toBeTruthy() + }) + it('Settings exposes the artifact-rotation budgets under "Capture control"', async () => { renderView() const tab = await screen.findByText('Capture control') diff --git a/test/settings-ia.test.ts b/test/settings-ia.test.ts index a3dab1a4..3dd3caea 100644 --- a/test/settings-ia.test.ts +++ b/test/settings-ia.test.ts @@ -67,18 +67,17 @@ describe('settings information architecture', () => { expect(SRC).not.toMatch(/tab === 'export'/) }) - it('has a filter box over the categories', () => { - expect(SRC).toMatch(/settings\.filterPages/) - expect(SRC).toMatch(/pageQuery/) - }) - - // It matches page names only. "Search settings…" promised the fields on - // those pages, and typing a setting's own name found nothing. - it('says the box filters categories, not settings', () => { + // History: the box once said "Search settings…" but matched page names only, + // so #143 relabelled it "Filter categories…". It now searches the settings + // themselves (lib/settingsSearch.ts), so the label says so again — and the + // label is only allowed while the search is wired. + it('has a search box that searches settings, not just categories', () => { + expect(SRC).toMatch(/settings\.searchSettings/) + expect(SRC).toMatch(/searchSettings\(pageQuery/) const en = JSON.parse(R('src/renderer/src/i18n/en.json')) as Record const zh = JSON.parse(R('src/renderer/src/i18n/zh-TW.json')) as Record - expect(en['settings.filterPages']).toBe('Filter categories…') - expect(zh['settings.filterPages']).toBe('篩選分類…') + expect(en['settings.searchSettings']).toBe('Search settings…') + expect(zh['settings.searchSettings']).toBe('搜尋設定…') }) it('flattens plugins to one level', () => { diff --git a/test/settings-search.test.ts b/test/settings-search.test.ts new file mode 100644 index 00000000..e3311004 --- /dev/null +++ b/test/settings-search.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import fs from 'fs' +import path from 'path' +import { searchSettings } from '../src/renderer/src/lib/settingsSearch' +import { SETTINGS_SEARCH_KEYS } from '../src/renderer/src/lib/settingsSearchIndex' +import en from '../src/renderer/src/i18n/en.json' +import zh from '../src/renderer/src/i18n/zh-TW.json' +// @ts-expect-error — plain ESM helper shared with the generator script +import { PAGE_SOURCES, pageKeys } from '../scripts/settings-search-sources.mjs' + +// Settings search finds settings, not only page names (the box matched page +// labels alone, so a search for a field found nothing). +const tr = (dict: Record) => (k: string): string => dict[k] ?? k +const read = (f: string): string => + fs.readFileSync(path.join(__dirname, '..', 'src/renderer/src/components', f), 'utf8') + +describe('settings search', () => { + it('has an index entry for every key each page renders — regenerate with npm run gen:settings-search', () => { + for (const page of Object.keys(PAGE_SOURCES)) { + expect({ page, keys: SETTINGS_SEARCH_KEYS[page as keyof typeof SETTINGS_SEARCH_KEYS] }) + .toEqual({ page, keys: pageKeys(page, read) }) + } + }) + + it('finds a field by words in its label, on the page that holds it', () => { + const hits = searchSettings('loot', tr(en)) + expect(hits.some((h) => h.page === 'captureControl')).toBe(true) + }) + + it('searches the displayed language', () => { + const hits = searchSettings('戰利品', tr(zh)) + expect(hits.some((h) => h.page === 'captureControl' && h.text.includes('戰利品'))).toBe(true) + expect(searchSettings('保存', tr(zh)).some((h) => h.page === 'captureControl')).toBe(true) + }) + + it('returns nothing for an empty query, and nothing for a key with no translation', () => { + expect(searchSettings(' ', tr(en))).toEqual([]) + expect(searchSettings('settings.', (k) => k)).toEqual([]) + }) +}) From 7596736e7b3e201b5c4b1ae48b94cd9425e96ce1 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 23 Sep 2026 23:01:43 +0800 Subject: [PATCH 2/2] chore: regenerate the settings search index after merging main (Spec 033 added a Loot string) Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/lib/settingsSearchIndex.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/settingsSearchIndex.ts b/src/renderer/src/lib/settingsSearchIndex.ts index 262b11fa..b0c66c6d 100644 --- a/src/renderer/src/lib/settingsSearchIndex.ts +++ b/src/renderer/src/lib/settingsSearchIndex.ts @@ -104,7 +104,8 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.lootGroup', 'settings.lootHint', 'settings.lootRulesFailed', - 'settings.lootBuiltin' + 'settings.lootBuiltin', + 'settings.lootRuleStopped' ], scope: [ 'settings.scopeEnforcement',