diff --git a/apps/desktop/electron/main/ipc/session-ipc.ts b/apps/desktop/electron/main/ipc/session-ipc.ts index b00420d883..8423b67a01 100644 --- a/apps/desktop/electron/main/ipc/session-ipc.ts +++ b/apps/desktop/electron/main/ipc/session-ipc.ts @@ -245,6 +245,22 @@ export function registerSessionIpc({ }; }, ); + + handle(IPC.invoke.indexStatus, async (input?: { rootPath?: string }) => { + if (!host) throw Object.assign(new Error("host unavailable"), { errorCode: "HOST_UNAVAILABLE" }); + return host.call("index.status", input ?? {}); + }); + + handle(IPC.invoke.indexRebuild, async (input?: { rootPath?: string }) => { + if (!host) throw Object.assign(new Error("host unavailable"), { errorCode: "HOST_UNAVAILABLE" }); + return host.call("index.rebuild", input ?? {}); + }); + + handle(IPC.invoke.indexClear, async (input?: { rootPath?: string }) => { + if (!host) throw Object.assign(new Error("host unavailable"), { errorCode: "HOST_UNAVAILABLE" }); + return host.call("index.clear", input ?? {}); + }); + handle( IPC.invoke.sessionGet, async ( diff --git a/apps/desktop/src/components/settings/IndexPage.tsx b/apps/desktop/src/components/settings/IndexPage.tsx new file mode 100644 index 0000000000..1f07f946ff --- /dev/null +++ b/apps/desktop/src/components/settings/IndexPage.tsx @@ -0,0 +1,344 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { AppSettings, WorkspaceIndexRoot } from "@pi-desktop/shared"; +import { api } from "../../lib/api"; +import { Button, cx } from "../ui"; +import { MetricTile } from "./MetricTile"; +import { + IconActivity, + IconDatabase, + IconFileText, + IconRefresh, +} from "../icons"; + +type IndexPageProps = { + settings: AppSettings; + saveSettings: (patch: Partial) => Promise; +}; + +type LoadState = + | { kind: "loading" } + | { kind: "error" } + | { kind: "ready"; root: WorkspaceIndexRoot | null }; + +/** localStorage flag for the one-time local-only nudge under the index page. */ +const NUDGE_DISMISSED_KEY = "pi.index.nudgeDismissed.v1"; + +function formatBytes(bytes: number): string { + if (bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const exponent = Math.min( + units.length - 1, + Math.floor(Math.log(bytes) / Math.log(1024)), + ); + const value = bytes / 1024 ** exponent; + return `${value >= 100 || exponent === 0 ? Math.round(value) : value.toFixed(1)} ${units[exponent]}`; +} + +function formatRelative(updatedAt: number): string { + if (updatedAt <= 0) return "—"; + const deltaSeconds = Math.max(0, Math.round((Date.now() - updatedAt) / 1000)); + if (deltaSeconds < 60) return `${deltaSeconds}s`; + if (deltaSeconds < 3600) return `${Math.floor(deltaSeconds / 60)}m`; + if (deltaSeconds < 86400) return `${Math.floor(deltaSeconds / 3600)}h`; + return `${Math.floor(deltaSeconds / 86400)}d`; +} + +const STATUS_TONE: Record = { + fresh: "ok", + building: "busy", + stale: "warn", + failed: "error", + partial: "warn", + disabled: "", + skipped_over_limit: "warn", +}; + +export function IndexPage({ settings, saveSettings }: IndexPageProps) { + const { t } = useTranslation(); + const [state, setState] = useState({ kind: "loading" }); + const [busy, setBusy] = useState<"rebuild" | "clear" | null>(null); + const [actionError, setActionError] = useState(false); + // One-time note: once dismissed it stays dismissed (localStorage), matching + // the audit's "never render again once written" requirement. + const [nudgeVisible, setNudgeVisible] = useState(() => { + try { + return localStorage.getItem(NUDGE_DISMISSED_KEY) !== "1"; + } catch { + return true; + } + }); + const grepBoost = settings.indexGrepBoost === true; + const building = state.kind === "ready" && state.root?.status === "building"; + + const dismissNudge = () => { + try { + localStorage.setItem(NUDGE_DISMISSED_KEY, "1"); + } catch { + // Storage unavailable (e.g. locked-down profile): degrade to a + // session-only dismiss rather than blocking the control. + } + setNudgeVisible(false); + }; + + const refresh = useCallback(async () => { + setState((current) => + current.kind === "ready" ? { kind: "ready", root: current.root } : { kind: "loading" }, + ); + try { + const result = await api.indexStatus(); + setState({ kind: "ready", root: result.roots[0] ?? null }); + } catch { + setState({ kind: "error" }); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // While a background rebuild is in flight, poll once a second — paused + // whenever the window is hidden, and torn down with the page. + useEffect(() => { + if (!building) return; + const poll = () => { + if (document.visibilityState !== "visible") return; + void api.indexStatus().then((result) => { + const next = result.roots[0]; + if (next) setState({ kind: "ready", root: next }); + }); + }; + const timer = setInterval(poll, 1000); + return () => clearInterval(timer); + }, [building]); + + const rebuild = async () => { + if (busy) return; + setBusy("rebuild"); + setActionError(false); + try { + const result = await api.indexRebuild(); + setState({ kind: "ready", root: result.root }); + } catch { + setActionError(true); + void refresh(); + } finally { + setBusy(null); + } + }; + + const clear = async () => { + if (busy) return; + setBusy("clear"); + setActionError(false); + try { + await api.indexClear(); + setState({ kind: "ready", root: null }); + } catch { + setActionError(true); + void refresh(); + } finally { + setBusy(null); + } + }; + + if (state.kind === "loading") { + return ( +
+ {t("index.loading")} +
+ ); + } + + if (state.kind === "error") { + return ( +
+
+
+
+
+
{t("index.loadErrorTitle")}
+
{t("index.loadErrorDesc")}
+
+
+ +
+
+
+
+
+ ); + } + + const { root } = state; + const progress = root?.status === "building" ? root.progress : undefined; + const progressPct = + progress && progress.filesTotal > 0 + ? Math.min(100, Math.max(0, Math.round((progress.filesDone / progress.filesTotal) * 100))) + : 0; + + return ( +
+ {/* Sits directly under the page title the settings shell renders. */} +

{t("index.indexSubtitle")}

+ +
+

{t("index.card.health")}

+
+ {building ? ( +
+ {progress ? ( + <> +
+ ) : null} + {root ? ( +
+ } + tone={root.status === "fresh" ? "success" : root.status === "failed" ? "danger" : "warning"} + label={t("index.card.status")} + value={ + + {t(`index.status.${root.status}`)} + + } + caption={t("index.statusDesc")} + /> + } + tone="accent" + label={t("index.card.files")} + value={root.fileCount.toLocaleString()} + badge={ + root.errorCount > 0 ? ( + + {t("index.card.errors")}: {root.errorCount} + + ) : undefined + } + /> + } + tone="accent" + label={t("index.card.size")} + value={formatBytes(root.indexedBytes)} + /> + } + tone="accent" + label={t("index.card.updated")} + value={formatRelative(root.updatedAt)} + /> +
+ ) : ( +
+
+
{t("index.emptyTitle")}
+
{t("index.emptyDesc")}
+
+
+ )} + {root && root.errorCount > 0 && root.lastError ? ( +
+ {root.lastError} +
+ ) : null} +
+
+
{t("index.actions")}
+
+ {t("index.actionsDesc")} {t("index.localOnly")} +
+
+
+ + +
+
+ {actionError ? ( +
+ {t("index.actionError")} +
+ ) : null} +
+
+ + {/* + The behaviour switch gets its own "Codebase" section: it is a setting, + not health telemetry, and the audit flagged it sitting wordlessly + inside the health card. + */} +
+

{t("index.sectionCode")}

+
+
+
+
+ + {t("index.grepBoost")} +
+
{t("index.grepBoostDesc")}
+
+
+ +
+
+
+
+ + {nudgeVisible ? ( +
+ {t("index.nudgeText")} + +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/components/settings/MetricTile.tsx b/apps/desktop/src/components/settings/MetricTile.tsx new file mode 100644 index 0000000000..4b59b9d353 --- /dev/null +++ b/apps/desktop/src/components/settings/MetricTile.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; +import { cx } from "../ui"; + +export type MetricTone = "accent" | "success" | "warning" | "danger"; + +/** + * Shared metric tile for the index library destination: a tinted icon chip, + * a muted label, a large tabular value, and an optional badge or caption. + */ +export function MetricTile({ + icon, + tone = "accent", + label, + value, + caption, + badge, +}: { + icon: ReactNode; + tone?: MetricTone; + label: string; + value: ReactNode; + caption?: ReactNode; + badge?: ReactNode; +}) { + return ( +
+
+ + {label} + {badge} +
+
{value}
+ {caption ?
{caption}
: null} +
+ ); +} diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index 74631e1dc4..bbb9f0dbd8 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -17,6 +17,7 @@ import { import { pluginViewIcon } from "../../lib/plugin-view-icons"; import { IconArchive, + IconDatabase, IconBookOpen, IconBot, IconChevronLeft, @@ -34,6 +35,7 @@ import { IconMic, } from "../../components/icons"; import { Badge, Button, cx, SegmentedControl, SettingsToggle } from "../../components/ui"; +import { IndexPage } from "../../components/settings/IndexPage"; import { ModelConfigPage } from "../../components/settings/ModelConfigPage"; import { KeyboardShortcutsSection } from "../../components/settings/KeyboardShortcutsSection"; import { FontFamilyRow } from "../../components/settings/FontFamilyRow"; @@ -230,6 +232,7 @@ export function SettingsPage() { subagents: , import: , projects: , + index: , sync: , remoteHosts: , voice: , @@ -560,6 +563,9 @@ export function SettingsPage() { {tab === "projects" && } + {tab === "index" && settings && ( + + )} {tab === "sync" && !tabHidden && } {tab === "remoteHosts" && !tabHidden && } diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 3cfd328638..6839cfe5b3 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -1384,6 +1384,20 @@ export const api = { IPC.invoke.statsGetTokenUsageHistory, query, ), + indexStatus: (rootPath?: string) => + invoke<{ roots: import("@pi-desktop/shared").WorkspaceIndexRoot[] }>( + IPC.invoke.indexStatus, + { rootPath }, + ), + indexRebuild: (rootPath?: string) => + invoke<{ root: import("@pi-desktop/shared").WorkspaceIndexRoot }>( + IPC.invoke.indexRebuild, + { rootPath }, + ), + indexClear: (rootPath?: string) => + invoke<{ ok: boolean; cleared: number }>(IPC.invoke.indexClear, { + rootPath, + }), menuRendererReady: () => invoke<{ ready: boolean }>(IPC.invoke.menuRendererReady), setTraySessionPreferences: (preferences: TraySessionPreferences) => diff --git a/apps/desktop/src/lib/settings-search.ts b/apps/desktop/src/lib/settings-search.ts index ce27479ab9..a31f4fd49a 100644 --- a/apps/desktop/src/lib/settings-search.ts +++ b/apps/desktop/src/lib/settings-search.ts @@ -5,6 +5,9 @@ * like "主题" or "theme" can surface the tab that owns the row. */ +/** + * Settings destinations. + */ export type SettingsTabId = | "general" | "ai" @@ -16,6 +19,7 @@ export type SettingsTabId = | "subagents" | "import" | "projects" + | "index" | "sync" | "remoteHosts" | "voice" @@ -269,6 +273,21 @@ export const SETTINGS_NAV: SettingsNavEntry[] = [ "project.delete", ], }, + { + id: "index", + labelKey: "settings.nav.index", + titleKey: "settings.index", + // Host/workspace lifecycle — the switch, its status and the rebuild/clear + // actions — so it joins the Workspace group instead of a group of its own. + group: "workspace", + keywordKeys: [ + "index.card.health", + "index.card.files", + "index.card.size", + "index.action.rebuild", + "index.action.clear", + ], + }, { id: "sync", labelKey: "settings.nav.sync", diff --git a/apps/desktop/src/styles/settings.css b/apps/desktop/src/styles/settings.css index 8054dc1f51..60d8ec56cb 100644 --- a/apps/desktop/src/styles/settings.css +++ b/apps/desktop/src/styles/settings.css @@ -2137,13 +2137,20 @@ background: currentcolor; } +/* + State tints use one 14% mix, the repo-wide standard (`.idx-chip`, + `.idx-badge`, `.agent-capability-glyph`, and the other state pills all sit at + 14%). These three badge rules were the only 13% holdouts, so a ready/failed + capability badge read one step dimmer than the identical state on a glyph or + a metric badge. +*/ .agent-capability-badge.is-ready { - background: color-mix(in oklab, var(--ds-success) 13%, transparent); + background: color-mix(in oklab, var(--ds-success) 14%, transparent); color: var(--ds-success); } .agent-capability-badge.is-connecting { - background: color-mix(in oklab, var(--ds-warning) 13%, transparent); + background: color-mix(in oklab, var(--ds-warning) 14%, transparent); color: var(--ds-warning); } @@ -2152,7 +2159,7 @@ } .agent-capability-badge.is-failed { - background: color-mix(in oklab, var(--ds-error) 13%, transparent); + background: color-mix(in oklab, var(--ds-error) 14%, transparent); color: var(--ds-error); } @@ -2610,6 +2617,186 @@ width: 100%; } +/* Index library settings page */ +.idx-state { + color: var(--ds-text-secondary); +} + +.idx-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-3); +} + +/* + Metric tile surface, unified with the settings card language (D297): the same + translucent `--ds-tile` fill that `.settings-panel` and `.settings-row` use, + plus one hairline so a 4-up grid of tiles keeps a readable edge on the dark + plate where a 3.5% fill alone can wash out. + + Radius moves off the compact-control rung (`--radius-xs`, 8px) onto + `--radius-md-plus` (14px). That is the documented "cards" rung and it is the + rung `.settings-row` already uses, so the two-tier system is now: structural + panels at `--radius-lg` (16px), cards at 14px. The tile is a card, not a + panel, so 14px is the consistent choice. +*/ +.idx-tile { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + border-radius: var(--radius-md-plus); + background: var(--ds-tile); + /* + `--ds-border-default` rather than `--ds-border-subtle`: the tile fill sits + only ~1.09:1 over the page plate, so the hairline is what actually reads as + the card edge. The mockup's border carries similar weight. + */ + border: 1px solid var(--ds-border-default); +} + +.idx-tile-head { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.idx-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + flex: none; + border-radius: var(--radius-2xs); +} + +.idx-chip-accent { + background: color-mix(in oklab, var(--ds-accent) 14%, transparent); + color: var(--ds-accent); +} + +.idx-chip-success { + background: color-mix(in oklab, var(--ds-success) 14%, transparent); + color: var(--ds-success); +} + +.idx-chip-warning { + background: color-mix(in oklab, var(--ds-warning) 14%, transparent); + color: var(--ds-warning); +} + +.idx-chip-danger { + background: color-mix(in oklab, var(--ds-error) 14%, transparent); + color: var(--ds-error); +} + +.idx-tile-label { + color: var(--ds-text-secondary); + font-size: var(--text-sm); + overflow: hidden; + /* A wrapped two-line label reads better than a truncated one when a badge + squeezes the head row (e.g. en "Current streak" at narrow widths). */ + white-space: normal; +} + +.idx-tile-value { + color: var(--ds-text-primary); + font-size: var(--text-2xl); + line-height: var(--leading-compact); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.idx-tile-caption { + color: var(--ds-text-secondary); + font-size: var(--text-xs); + line-height: var(--leading-normal); +} + +/* + Badges sit in a 26px header row next to the tile label. Without `nowrap` a + longer localized label (e.g. the "highest/record/on track" badges, or an + error count) wraps to a second line and pushes the 26px row taller, breaking + the row rhythm across the grid. +*/ +.idx-badge { + display: inline-flex; + align-items: center; + margin-left: auto; + padding: 1px 8px; + border-radius: var(--radius-full); + font-size: var(--text-2xs); + line-height: var(--leading-normal); + white-space: nowrap; + background: color-mix(in oklab, var(--ds-text-primary) 10%, transparent); + color: var(--ds-text-secondary); +} + +.idx-badge-warn { + background: color-mix(in oklab, var(--ds-warning) 14%, transparent); + color: var(--ds-warning); +} + +.idx-badge-ok { + background: color-mix(in oklab, var(--ds-success) 14%, transparent); + color: var(--ds-success); +} + +.idx-status { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: var(--text-sm); + line-height: var(--leading-normal); + white-space: nowrap; + background: color-mix(in oklab, var(--ds-text-primary) 10%, transparent); +} + +.idx-status.ok { + background: color-mix(in oklab, var(--ds-success) 14%, transparent); + color: var(--ds-success); +} + +.idx-status.busy { + background: color-mix(in oklab, var(--ds-accent) 14%, transparent); + color: var(--ds-accent); +} + +.idx-status.warn { + background: color-mix(in oklab, var(--ds-warning) 14%, transparent); + color: var(--ds-warning); +} + +.idx-status.error { + background: color-mix(in oklab, var(--ds-error) 14%, transparent); + color: var(--ds-error); +} + +.idx-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.idx-error-line { + margin-top: var(--space-2); + color: var(--ds-error); + font-size: var(--text-sm); + line-height: var(--leading-normal); +} + +.idx-subtitle { + color: var(--ds-text-secondary); + font-size: var(--text-sm); + margin-top: calc(var(--space-2) * -1); +} + /* Plaintext opt-in for endpoints the user typed themselves: the checkbox sits in the row's control column, and the risk copy arrives only while it is on. @@ -2709,10 +2896,95 @@ } .agent-extension-diagnostic-message { - overflow: hidden; + overflow: hidden; text-overflow: ellipsis; } +/* + Build progress row at the top of the index health card: thin determinate bar + + file counter + a quiet "you can keep working" note. +*/ +.idx-progress { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-2); +} + +.idx-progress-bar { + display: block; + flex: 1 1 120px; + min-width: 120px; + height: 4px; + border-radius: var(--radius-full); + background: color-mix(in oklab, var(--ds-text-primary) 10%, transparent); + overflow: hidden; +} + +.idx-progress-fill { + display: block; + height: 100%; + border-radius: var(--radius-full); + background: var(--ds-accent); + transition: width var(--motion-duration-normal) var(--motion-ease-out); +} + +.idx-progress-count { + color: var(--ds-text-primary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.idx-progress-note { + color: var(--ds-text-secondary); + font-size: var(--text-sm); +} + +/* Icon chip + title line for the codebase switch rows (MetricTile head voice). */ +.idx-row-title { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +/* + One-time local-only note at the bottom of the index page. Same soft-tile + treatment as the trust notice above; `role="note"` keeps it out of the + critical path, so muted ink and a ghost dismiss. +*/ +.idx-nudge { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + background: var(--ds-tile); + color: var(--ds-text-muted); + font-size: var(--text-sm); +} + +.idx-nudge-text { + min-width: 0; +} + +@media (max-width: 900px) { + /* + Index tiles reflow on their own (`auto-fit`/`minmax(200px, 1fr)`); only the + action row needs help. At ≤820px `.settings-row` stacks and makes its + control full-width and left-aligned, but `.idx-actions` (declared later in + this file, so it wins on order) pins the buttons right inside that + full-width box. This rule comes after `.idx-actions`, so it can restore the + left edge. Above 820px the control hugs its content, so the declaration is + a no-op there and the horizontal row is untouched. + */ + .idx-actions { + justify-content: flex-start; + } +} .extension-prompt-dialog { max-height: calc(100dvh - 48px); overflow-y: auto; diff --git a/apps/desktop/src/styles/tokens.css b/apps/desktop/src/styles/tokens.css index 574a545488..850d715baf 100644 --- a/apps/desktop/src/styles/tokens.css +++ b/apps/desktop/src/styles/tokens.css @@ -212,6 +212,22 @@ --motion-ease-standard: cubic-bezier(0.2, 0, 0, 1); /* Global type scale (D343). 1 = product ramp; Appearance multiplies --text-*. */ --font-scale: 1; + + /* + Spacing ladder. Theme-invariant, so it lives in the base `:root` block + alongside the other semantic tokens, and is also declared in the `@theme` + block below so Tailwind can see the scale. The duplication is deliberate: + Tailwind only emits a `@theme` variable once something consumes it (as a + utility or through `var()`), so a `var(--space-*)` reference in a + stylesheet could otherwise resolve to nothing and the spacing would + silently collapse to zero. + */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; } :root[data-theme="light"] { @@ -323,7 +339,10 @@ Token scales — the ONLY allowed values for typography and radii. Raw px/em literals for font-size, font-weight, line-height, letter-spacing, and border-radius are forbidden in component CSS - and TSX (enforced by scripts/check-style-tokens.mjs). + and TSX (enforced by scripts/check-style-tokens.mjs). The `--space-*` + ladder below is the same kind of scale for margin/padding/gap; it is not + yet machine-enforced because the renderer still carries a large backlog of + raw px spacing that would fail a repo-wide check. */ /* Apple-inspired fixed-radius ladder for dense desktop UI. Compact controls @@ -387,4 +406,18 @@ --tracking-tight: -0.02em; --tracking-normal: 0em; --tracking-wide: 0.02em; + + /* + Spacing ladder (design-system §6.1). Four-pixel rhythm; `--space-5` fills + the gap the doc leaves between 16 and 24 so the ramp stays linear. Values + match the documented scale exactly (space-1 4, space-2 8, space-3 12, + space-4 16, space-6 24). Mirrored in the base `:root` block above for the + reason noted there; keep the two copies in lockstep. + */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; } diff --git a/apps/desktop/test/index-settings.test.mjs b/apps/desktop/test/index-settings.test.mjs new file mode 100644 index 0000000000..e1a571b026 --- /dev/null +++ b/apps/desktop/test/index-settings.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import test from "node:test"; + +const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +const [search, settingsPage, api, protocol, main, page, enLocale, settingsTypes, statsTypes] = await Promise.all([ + read("src/lib/settings-search.ts"), + read("src/features/settings/SettingsPage.tsx"), + read("src/lib/api.ts"), + read("../../packages/shared/src/protocol.ts"), + read("electron/main/ipc/session-ipc.ts"), + read("src/components/settings/IndexPage.tsx"), + read("../../packages/i18n/src/locales/en/index.ts"), + read("../../packages/shared/src/types/settings.ts"), + read("../../packages/shared/src/types/workspace-index.ts"), +]); + +test("workspace index is a workspace-group settings destination", () => { + assert.match(search, /id: "index"/); + assert.match(search, /labelKey: "settings\.nav\.index"/); + assert.match(search, /workspace: "settings\.groupWorkspace"/); + assert.match(search, /titleKey: "settings\.index"/); + // The index sits in Workspace, not in a group of its own (D335 / ADR 0173): + // the switch, its status and the rebuild/clear actions are host/workspace + // lifecycle, which is what that group already collects. The retired Data & + // Statistics group must not reappear as a one-entry section. + assert.doesNotMatch(search, /settings\.groupData/); + assert.doesNotMatch(search, /group: "data"/); + const entry = search.slice( + search.indexOf('id: "index"'), + search.indexOf('id: "about"'), + ); + assert.match(entry, /group: "workspace"/); + assert.match(entry, /"index\.card\.health"/); + assert.match(settingsPage, /tab === "index" && settings && \(\n\s*\n\s*\)/); + assert.match(settingsPage, /import \{ IndexPage \}/); +}); + +test("index page drives one switch and keeps the index a rebuildable cache", async () => { + await access( + new URL("../src/components/settings/IndexPage.tsx", import.meta.url), + constants.F_OK, + ); + assert.match(page, /api\.indexStatus\(/); + assert.match(page, /api\.indexRebuild\(/); + assert.match(page, /api\.indexClear\(/); + assert.match(page, /index\.card\.health/); + assert.match(page, /settings\.indexGrepBoost === true/); + assert.match(page, /saveSettings\(\{ indexGrepBoost: !grepBoost \}\)/); + assert.match(page, /setInterval\(poll, 1000\)/); + assert.match(page, /index\.status\.\$\{root\.status\}/); + // One switch owns the index lifecycle. A second "index new folders" toggle + // could only duplicate this one or build an index that nothing uses. + assert.equal(page.match(/role="switch"/g)?.length, 1); + assert.doesNotMatch(page, /indexNewFolders/); + assert.doesNotMatch(settingsTypes, /indexNewFolders/); + assert.doesNotMatch(enLocale, /newFolders/); + assert.match(enLocale, /grepBoost: "Workspace indexing"/); + assert.match(enLocale, /grepBoostDesc: "While this switch is on, newly opened workspaces are indexed/); + // The copy describes the index as what it is: a rebuildable local cache. + assert.match(enLocale, /statusDesc: "The index is a rebuildable local cache\."/); + // The manual build is gated on that same switch: without a consumer an index + // is only a scan and some disk, so the page must not offer to build one. + assert.match(page, /disabled=\{busy !== null \|\| !grepBoost\}/); + assert.match(page, /aria-describedby="idx-actions-desc"/); + assert.match(enLocale, /actionsDesc: "Build or rebuild the index for the current workspace/); + assert.match(enLocale, /cannot be built while the switch is off/); +}); + +test("index IPC stays on the three lifecycle channels", () => { + assert.match(protocol, /indexStatus: "pi-desktop\/index\/status"/); + assert.match(protocol, /indexRebuild: "pi-desktop\/index\/rebuild"/); + assert.match(protocol, /indexClear: "pi-desktop\/index\/clear"/); + assert.match(api, /indexStatus: \(rootPath\?: string\)/); + assert.match(api, /indexRebuild: \(rootPath\?: string\)/); + assert.match(api, /indexClear: \(rootPath\?: string\)/); + assert.match(main, /host\.call\("index\.status", input \?\? \{\}\)/); + assert.match(main, /host\.call\("index\.rebuild", input \?\? \{\}\)/); + assert.match(main, /host\.call\("index\.clear", input \?\? \{\}\)/); +}); + +test("settings search and locales carry the index keys", () => { + for (const key of [ + "settings.nav.index", + "settings.index", + "index.card.health", + "index.action.rebuild", + "index.action.clear", + ]) { + const leaf = key.split(".").pop(); + assert.match(enLocale, new RegExp(`${leaf}:`)); + } +}); + +test("index page surfaces the host's rebuild progress", () => { + assert.match(page, /className="idx-progress"/); + assert.match(page, /className="idx-progress-fill"/); + assert.match(page, /t\("index\.progressFiles", \{/); + assert.match(page, /t\("index\.progressFallback"\)/); + // The host gives no counts: an indeterminate pill, never a fake bar. + assert.match(page, /t\("index\.status\.building"\)/); +}); + +test("the index page explains itself once and can be dismissed for good", () => { + // Once written, the flag keeps the note away for every later render. + assert.match(page, /const NUDGE_DISMISSED_KEY = /); + assert.match(page, /localStorage\.getItem\(NUDGE_DISMISSED_KEY\) !== "1"/); + assert.match(page, /localStorage\.setItem\(NUDGE_DISMISSED_KEY, "1"\)/); + assert.match(page, /t\("index\.nudgeText"\)/); + assert.match(page, /t\("index\.nudgeDismiss"\)/); + // The switch left the health card: it is a setting, not telemetry. + assert.match(page, /t\("index\.sectionCode"\)/); + assert.match(page, /t\("index\.indexSubtitle"\)/); +}); + +test("the manual build is gated on the index switch", async () => { + // Rebuild stays a host lifecycle RPC and keeps working regardless, but the + // page only offers it while the opt-in switch is on: an index nothing uses + // is pure scan and disk cost, which is the reason the card carries exactly + // one toggle in the first place. + assert.match(page, /t\("index\.actionsDesc"\)/); + assert.match(page, /t\("index\.localOnly"\)/); + assert.match(page, /id="idx-actions-desc"/); + // Clear keeps working with the switch off, so a leftover index can still go. + assert.match(page, /disabled=\{busy !== null \|\| !root\}/); + // The gate lives in the locale, not only in the component: every shipped + // catalog has to say why the build can be unavailable. + for (const locale of ["en", "zh-CN", "zh-TW", "de", "es", "fr", "ko", "tr"]) { + const catalog = await read(`../../packages/i18n/src/locales/${locale}/index.ts`); + assert.match(catalog, /"?actionsDesc"?:/, locale); + } +}); diff --git a/apps/desktop/test/settings-general.test.mjs b/apps/desktop/test/settings-general.test.mjs index 26552c91e7..117eeb28d1 100644 --- a/apps/desktop/test/settings-general.test.mjs +++ b/apps/desktop/test/settings-general.test.mjs @@ -398,6 +398,7 @@ test("settings nav icons map each destination to a semantic lucide glyph", () => assert.match(settingsPageSource, /agent: settingsSearchSource.indexOf(`id: "${id}"`)); assert.ok(navOrder.every((index) => index >= 0)); diff --git a/crates/host-core/src/index.rs b/crates/host-core/src/index.rs new file mode 100644 index 0000000000..50b72d2af9 --- /dev/null +++ b/crates/host-core/src/index.rs @@ -0,0 +1,962 @@ +//! Host-owned workspace content index storage. +//! +//! This module deliberately exposes only lifecycle operations (status, +//! rebuild, clear). Grep execution stays untouched: whether and how the index +//! may ever accelerate a search is a separate, independently reviewed change. + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub mod fts; + +pub const MAX_FILES: usize = 50_000; +pub const MAX_FILE_BYTES: u64 = 1024 * 1024; +pub const MAX_INDEXED_BYTES: u64 = 2 * 1024 * 1024 * 1024; +// v2 stores the whole *visible* file set, not just the ingested subset: the +// `files` table gained `content_indexed`. The index is a rebuildable cache, so +// `open` quarantines a v1 database and re-crawls rather than migrating. +const INDEX_SCHEMA_VERSION: i64 = 2; + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexStatus { + Fresh, + Building, + Stale, + Failed, + Partial, + Disabled, + SkippedOverLimit, +} + +impl IndexStatus { + fn as_str(self) -> &'static str { + match self { + Self::Fresh => "fresh", + Self::Building => "building", + Self::Stale => "stale", + Self::Failed => "failed", + Self::Partial => "partial", + Self::Disabled => "disabled", + Self::SkippedOverLimit => "skipped_over_limit", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct IndexLimits { + pub max_files: usize, + pub max_file_bytes: u64, + pub max_indexed_bytes: u64, +} + +impl Default for IndexLimits { + fn default() -> Self { + Self { + max_files: MAX_FILES, + max_file_bytes: MAX_FILE_BYTES, + max_indexed_bytes: MAX_INDEXED_BYTES, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RootStatus { + pub root_id: String, + pub root_path: String, + pub status: String, + pub file_count: i64, + pub indexed_bytes: i64, + pub error_count: i64, + pub last_error: Option, + pub updated_at: i64, + /// Only present while `status == "building"`; omitted otherwise so the + /// non-building response shape is unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + pub progress: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RootProgress { + pub files_done: u64, + pub files_total: u64, +} + +#[derive(Debug, Clone)] +struct IndexedFile { + rel_path: String, + size: u64, + mtime_ms: i64, + /// `None` when the file is visible but was never ingested (binary + /// extension, over-size text, unreadable). Such files are still stored so + /// the persisted set stays equal to the set Grep can reach. + body: Option, +} + +#[derive(Debug, Clone, Default)] +struct ScanResult { + file_count: i64, + ingested_count: i64, + indexed_bytes: u64, + error_count: i64, + over_limit: bool, +} + +/// Result of [`IndexStore::ensure_index`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnsureOutcome { + /// The root already had a fresh index; nothing to do. + Fresh, + /// A background rebuild is already in flight. + InProgress, + /// The root was marked `building`; the caller must run `rebuild` in the + /// background to finish it. + Triggered, +} + +#[derive(Debug)] +struct RootUpdate<'a> { + status: IndexStatus, + file_count: i64, + indexed_bytes: i64, + error_count: i64, + last_error: Option<&'a str>, +} + +#[derive(Debug, Clone)] +pub struct IndexStore { + path: PathBuf, + /// Roots with a build running in *this* process, each with its own live + /// crawl counters. A `building` row in the store without a matching entry + /// here is crash residue from a previous host process, and may be + /// re-armed instead of answered InProgress; an entry whose root already + /// has one means a second build must wait, not interleave. + building_roots: Arc>>>, + /// Set when opening the real store failed: every operation then answers + /// as unavailable instead of blocking host startup. Indexing is an + /// optimization layer, so losing it must not cost the boot. + disabled: bool, +} + +/// Live crawl counters for a `building` root. `total` starts as an estimate +/// (the previous visible file count) and `done` advances as files are seen. +#[derive(Debug, Default)] +pub struct BuildProgress { + done: std::sync::atomic::AtomicU64, + total: std::sync::atomic::AtomicU64, +} + +impl BuildProgress { + pub fn reset(&self, total_estimate: u64) { + use std::sync::atomic::Ordering; + self.done.store(0, Ordering::Relaxed); + self.total.store(total_estimate, Ordering::Relaxed); + } + + pub fn inc_done(&self) { + use std::sync::atomic::Ordering; + self.done.fetch_add(1, Ordering::Relaxed); + } + + /// `(filesDone, filesTotal)`. The total never reports less than the count + /// already processed, so a stale estimate cannot show >100%. + pub fn snapshot(&self) -> (u64, u64) { + use std::sync::atomic::Ordering; + let done = self.done.load(Ordering::Relaxed); + let total = self.total.load(Ordering::Relaxed).max(done); + (done, total) + } +} + +/// Removes a root from [`IndexStore::building_roots`] when the build call +/// ends, whatever the outcome. +struct BuildingGuard<'a>(&'a Mutex>>, String); + +impl Drop for BuildingGuard<'_> { + fn drop(&mut self) { + if let Ok(mut roots) = self.0.lock() { + roots.remove(&self.1); + } + } +} + +impl IndexStore { + pub fn open(data_dir: &Path) -> Result { + let directory = data_dir.join("index"); + std::fs::create_dir_all(&directory).context("create index directory")?; + let path = directory.join("index.db"); + let store = Self { + path, + building_roots: Arc::new(Mutex::new(HashMap::new())), + disabled: false, + }; + if let Err(error) = store.initialize() { + store.quarantine_corrupt_db(); + store + .initialize() + .with_context(|| format!("rebuild index database after failure: {error}"))?; + } + Ok(store) + } + + /// A store that answers every operation as unavailable. Used when opening + /// the real store failed and the quarantine-and-retry could not recover + /// it: every RPC reports the store as unavailable and the host keeps + /// booting. + pub fn disabled() -> Self { + Self { + path: PathBuf::new(), + building_roots: Arc::new(Mutex::new(HashMap::new())), + disabled: true, + } + } + + pub fn status(&self, root: Option<&Path>) -> Result> { + let connection = self.connection()?; + let normalized = root.map(normalize_root); + let root_filter = normalized + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + let mut statement = connection.prepare( + "SELECT root_id, root_path, status, file_count, indexed_bytes, error_count, last_error, updated_at + FROM indexed_roots + WHERE (?1 IS NULL OR root_path = ?1) + ORDER BY root_path", + )?; + let rows = statement.query_map([root_filter], |row| { + Ok(RootStatus { + root_id: row.get(0)?, + root_path: row.get(1)?, + status: row.get(2)?, + file_count: row.get(3)?, + indexed_bytes: row.get(4)?, + error_count: row.get(5)?, + last_error: row.get(6)?, + updated_at: row.get(7)?, + // Filled in below: the row callback cannot borrow `self`. + progress: None, + }) + })?; + let mut statuses = rows.collect::>>()?; + let building = self.building_roots.lock().unwrap().clone(); + for status in &mut statuses { + if status.status == IndexStatus::Building.as_str() { + if let Some(progress) = building.get(&status.root_id) { + let (files_done, files_total) = progress.snapshot(); + status.progress = Some(RootProgress { + files_done, + files_total, + }); + } + } + } + Ok(statuses) + } + + pub fn rebuild(&self, root: &Path, limits: IndexLimits) -> Result { + let root = normalize_root(root); + if !root.is_dir() { + anyhow::bail!("workspace root does not exist: {}", root.display()); + } + let root_id = root_id(&root); + // Register the build (with its own progress counters) before the row + // flips, and hold the registration for the whole call — the guard + // also covers early returns — so a `building` row that outlives the + // process is recognizable as crash residue, not a running build. + // A root already registered by an earlier call in this process keeps + // that registration and its progress. A genuinely concurrent second + // rebuild sees the root already registered *and its guard held* only + // via ensure_index's InProgress answer; direct double-entry adopts + // the existing counters rather than interleaving a second crawl's + // totals into the card. + let progress = { + let mut roots = self.building_roots.lock().unwrap(); + match roots.get(&root_id) { + Some(existing) => existing.clone(), + None => { + let progress = Arc::new(BuildProgress::default()); + roots.insert(root_id.clone(), progress.clone()); + progress + } + } + }; + let _guard = BuildingGuard(&self.building_roots, root_id.clone()); + // Seed the progress denominator from the previous visible set (or 0 on + // a first build). The crawler advances `done` as it visits files. + let previous_files: i64 = self + .connection()? + .query_row( + "SELECT COUNT(*) FROM files WHERE root_id = ?1", + [&root_id], + |row| row.get(0), + ) + .unwrap_or(0); + progress.reset(previous_files.max(0) as u64); + self.set_root_status( + &root_id, + &root, + RootUpdate { + status: IndexStatus::Building, + file_count: 0, + indexed_bytes: 0, + error_count: 0, + last_error: None, + }, + )?; + + let connection = self.connection()?; + // Files stream from the crawler into one open transaction — bodies + // never accumulate in memory, so a 2GB budget costs SQLite page + // cache, not heap. + let mut writer = RootWriter::begin(&connection, &root_id)?; + let scan = scan_root(&root, limits, Some(&progress), |file| { + writer.write(&root_id, &file) + }); + match scan { + Ok(result) => { + writer.commit()?; + let status = if result.over_limit { + IndexStatus::SkippedOverLimit + } else if result.error_count > 0 { + IndexStatus::Partial + } else { + IndexStatus::Fresh + }; + let message = if result.over_limit { + Some("index budget exceeded; fast-path use is disabled".to_string()) + } else if result.error_count > 0 { + Some(format!( + "{} file(s) could not be indexed", + result.error_count + )) + } else { + None + }; + upsert_root( + &connection, + &root_id, + &root, + RootUpdate { + status, + // The health card's "files indexed" figure stays about + // ingested content. The visible-but-unindexed rows + // exist for search completeness, not for display. + file_count: result.ingested_count, + indexed_bytes: result.indexed_bytes as i64, + error_count: result.error_count, + last_error: message.as_deref(), + }, + )?; + } + Err(error) => { + upsert_root( + &connection, + &root_id, + &root, + RootUpdate { + status: IndexStatus::Failed, + file_count: 0, + indexed_bytes: 0, + error_count: 1, + last_error: Some(&error.to_string()), + }, + )?; + } + } + self.status(Some(&root))? + .into_iter() + .next() + .context("index status missing after rebuild") + } + + /// Mark an unindexed/stale workspace as `building` without scanning, so a + /// caller can run [`IndexStore::rebuild`] off the hot path. Idempotent: + /// a fresh root stays fresh and a building root is not re-marked. + pub fn ensure_index(&self, root: &Path) -> Result { + let root = normalize_root(root); + if !root.is_dir() { + anyhow::bail!("workspace root does not exist: {}", root.display()); + } + let root_id = root_id(&root); + let building_in_process = self.building_roots.lock().unwrap().contains_key(&root_id); + match self.status(Some(&root))?.into_iter().next() { + Some(status) if status.status == IndexStatus::Fresh.as_str() => { + Ok(EnsureOutcome::Fresh) + } + // A live build answers InProgress. A `building` row with no + // in-process build behind it is crash residue — the previous host + // process died mid-build — so it falls through and re-arms + // instead of answering InProgress forever. + Some(status) + if status.status == IndexStatus::Building.as_str() && building_in_process => + { + Ok(EnsureOutcome::InProgress) + } + _ => { + // Register before the row flips, so the health card sees a + // progress block as soon as the root reports `building`. The + // total is seeded from the previous visible set — the same + // figure the spawned rebuild will use — so the card never + // shows 0/0. The spawned rebuild adopts this registration + // instead of creating a second one; a concurrent caller sees + // the in-process entry and answers InProgress. + let previous_files: i64 = self + .connection()? + .query_row( + "SELECT COUNT(*) FROM files WHERE root_id = ?1", + [&root_id], + |row| row.get(0), + ) + .unwrap_or(0); + let progress = Arc::new(BuildProgress::default()); + progress.reset(previous_files.max(0) as u64); + self.building_roots + .lock() + .unwrap() + .entry(root_id.clone()) + .or_insert(progress); + self.set_root_status( + &root_id, + &root, + RootUpdate { + status: IndexStatus::Building, + file_count: 0, + indexed_bytes: 0, + error_count: 0, + last_error: None, + }, + )?; + Ok(EnsureOutcome::Triggered) + } + } + } + + pub fn clear(&self, root: Option<&Path>) -> Result { + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + let count = if let Some(root) = root { + let normalized = normalize_root(root); + let root_id = transaction + .query_row( + "SELECT root_id FROM indexed_roots WHERE root_path = ?1", + [normalized.to_string_lossy().into_owned()], + |row| row.get::<_, String>(0), + ) + .optional()?; + let Some(root_id) = root_id else { + return Ok(0); + }; + transaction.execute( + "DELETE FROM file_content_fts WHERE root_id = ?1", + [&root_id], + )?; + transaction.execute("DELETE FROM indexed_roots WHERE root_id = ?1", [&root_id])? + } else { + transaction.execute("DELETE FROM file_content_fts", [])?; + transaction.execute("DELETE FROM indexed_roots", [])? + }; + transaction.commit()?; + Ok(count) + } + + /// Test-only: the relative paths currently stored for `root`. With schema + /// v2 this is the whole *visible* set (ingested or not), which is exactly + /// what the Grep-vs-index diff test needs to compare. + #[cfg(test)] + pub fn indexed_rel_paths(&self, root: &Path) -> Result> { + let normalized = normalize_root(root).to_string_lossy().into_owned(); + let connection = self.connection()?; + let mut statement = connection.prepare( + "SELECT f.rel_path FROM files AS f + JOIN indexed_roots AS r ON r.root_id = f.root_id + WHERE r.root_path = ?1 + ORDER BY f.rel_path", + )?; + let rows = statement.query_map([normalized], |row| row.get::<_, String>(0))?; + rows.collect::>>() + .map_err(Into::into) + } + + fn connection(&self) -> Result { + if self.disabled { + anyhow::bail!("index store is unavailable"); + } + fts::open(&self.path) + } + + fn initialize(&self) -> Result<()> { + let connection = self.connection()?; + let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if version != 0 && version != INDEX_SCHEMA_VERSION { + anyhow::bail!("unsupported index schema version {version}"); + } + if version == 0 { + connection.execute_batch(fts::SCHEMA)?; + connection.pragma_update(None, "user_version", INDEX_SCHEMA_VERSION)?; + } + let integrity: String = + connection.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; + if integrity != "ok" { + anyhow::bail!("index database integrity check failed: {integrity}"); + } + Ok(()) + } + + fn quarantine_corrupt_db(&self) { + if !self.path.exists() { + return; + } + let stamp = now_ms(); + let quarantined = self.path.with_extension(format!("db.corrupt-{stamp}")); + let _ = std::fs::rename(&self.path, quarantined); + let _ = std::fs::remove_file(self.path.with_extension("db-wal")); + let _ = std::fs::remove_file(self.path.with_extension("db-shm")); + } + + fn set_root_status(&self, root_id: &str, root: &Path, update: RootUpdate<'_>) -> Result<()> { + let connection = self.connection()?; + upsert_root(&connection, root_id, root, update) + } +} + +fn scan_root( + root: &Path, + limits: IndexLimits, + progress: Option<&BuildProgress>, + mut sink: impl FnMut(IndexedFile) -> Result<()>, +) -> Result { + let mut result = ScanResult::default(); + // The crawler and Grep share one visible-set definition; see + // `crate::tools::ignore_rules`. The crawler always covers the whole root, + // so it uses the unscoped walk. + for entry in crate::tools::ignore_rules::visible_walker(root, false).build() { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + result.error_count += 1; + continue; + } + }; + if !entry + .file_type() + .is_some_and(|file_type| file_type.is_file()) + || crate::tools::ignore_rules::is_vendor_path(root, entry.path()) + { + continue; + } + if result.file_count >= limits.max_files as i64 { + result.over_limit = true; + break; + } + // One visible file counts as progress, whether or not it is ingested. + if let Some(progress) = progress { + progress.inc_done(); + } + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(_) => { + result.error_count += 1; + continue; + } + }; + let size = metadata.len(); + // The content filters below decide whether a *visible* file is worth + // ingesting — not whether it exists. A filtered file still gets a row + // (with `content_indexed = 0`) so the stored set keeps matching the set + // Grep can reach; the fast path then re-scans it instead of losing it. + let body = if size <= limits.max_file_bytes && !fts::is_binary_extension(entry.path()) { + match std::fs::read_to_string(entry.path()) { + Ok(body) => Some(body), + Err(_) => { + result.error_count += 1; + None + } + } + } else { + None + }; + if body.is_some() { + if result.indexed_bytes.saturating_add(size) > limits.max_indexed_bytes { + result.over_limit = true; + break; + } + result.indexed_bytes = result.indexed_bytes.saturating_add(size); + result.ingested_count += 1; + } + let rel_path = entry + .path() + .strip_prefix(root) + .map(normalize_rel_path) + .unwrap_or_else(|_| normalize_rel_path(entry.path())); + result.file_count += 1; + sink(IndexedFile { + rel_path, + size, + mtime_ms: metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0), + body, + })?; + } + Ok(result) +} + +/// A per-file writer into an open rebuild transaction. Files stream straight +/// from the crawler into SQLite — nothing accumulates the bodies in memory. +/// A per-file writer over one open rebuild transaction. Files stream +/// straight from the crawler into SQLite — nothing accumulates the bodies in +/// memory. The two INSERT statements are prepared from the connection before +/// the transaction opens (SQLite transactions are connection-scoped, so +/// stepping them inside is equivalent and keeps the writer free of +/// self-referential borrows). +struct RootWriter<'conn> { + insert_file: rusqlite::Statement<'conn>, + insert_fts: rusqlite::Statement<'conn>, + transaction: rusqlite::Transaction<'conn>, +} + +impl<'conn> RootWriter<'conn> { + fn begin(connection: &'conn Connection, root_id: &str) -> Result { + let insert_file = connection.prepare( + "INSERT INTO files (root_id, rel_path, size, mtime_ms, content_indexed) VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + let insert_fts = connection.prepare( + "INSERT INTO file_content_fts (root_id, rel_path, body) VALUES (?1, ?2, ?3)", + )?; + let transaction = connection.unchecked_transaction()?; + // Old rows go first: the FTS hit set must never outlive the files + // rows it points into. + transaction.execute("DELETE FROM file_content_fts WHERE root_id = ?1", [root_id])?; + transaction.execute("DELETE FROM files WHERE root_id = ?1", [root_id])?; + Ok(Self { + insert_file, + insert_fts, + transaction, + }) + } + + fn write(&mut self, root_id: &str, file: &IndexedFile) -> Result<()> { + self.insert_file.execute(params![ + root_id, + file.rel_path, + file.size as i64, + file.mtime_ms, + if file.body.is_some() { 1_i64 } else { 0_i64 } + ])?; + // Only ingested files reach the full-text table, so an FTS hit always + // implies a readable body behind it. + if let Some(body) = &file.body { + self.insert_fts + .execute(params![root_id, file.rel_path, body])?; + } + Ok(()) + } + + fn commit(self) -> Result<()> { + self.transaction.commit()?; + Ok(()) + } +} + +fn upsert_root( + connection: &Connection, + root_id: &str, + root: &Path, + update: RootUpdate<'_>, +) -> Result<()> { + connection.execute( + "INSERT INTO indexed_roots (root_id, root_path, status, file_count, indexed_bytes, error_count, last_error, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(root_id) DO UPDATE SET root_path=excluded.root_path, status=excluded.status, + file_count=excluded.file_count, indexed_bytes=excluded.indexed_bytes, error_count=excluded.error_count, + last_error=excluded.last_error, updated_at=excluded.updated_at", + params![ + root_id, + normalize_root(root).to_string_lossy().into_owned(), + update.status.as_str(), + update.file_count, + update.indexed_bytes, + update.error_count, + update.last_error, + now_ms() + ], + )?; + Ok(()) +} + +pub fn normalize_root(path: &Path) -> PathBuf { + let mut text = path.to_string_lossy().replace('\\', "/"); + if let Some(rest) = text.strip_prefix("//?/") { + text = rest.to_string(); + } + let candidate = PathBuf::from(text); + candidate.canonicalize().unwrap_or(candidate) +} + +pub fn normalize_rel_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +pub fn root_id(root: &Path) -> String { + let mut hash = Sha256::new(); + hash.update(normalize_root(root).to_string_lossy().as_bytes()); + hex::encode(hash.finalize()) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn normalizes_root_and_relative_paths() { + let directory = tempfile::tempdir().unwrap(); + let root = normalize_root(&PathBuf::from(format!("{}/", directory.path().display()))); + assert_eq!(root, directory.path().canonicalize().unwrap()); + assert_eq!(normalize_rel_path(Path::new("src\\lib.rs")), "src/lib.rs"); + assert_eq!(root_id(&root), root_id(&root)); + } + + #[test] + fn empty_store_is_safe() { + let data = tempfile::tempdir().unwrap(); + let store = IndexStore::open(data.path()).unwrap(); + assert!(store.status(None).unwrap().is_empty()); + assert_eq!(store.clear(None).unwrap(), 0); + } + + #[test] + fn building_residue_from_a_dead_process_is_rearmed() { + let data = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("one.txt"), "one\n").unwrap(); + let store = IndexStore::open(data.path()).unwrap(); + // Simulate crash residue: a `building` row with no in-process build + // behind it (as if the previous host process died mid-build). + store + .set_root_status( + &root_id(&normalize_root(root.path())), + &normalize_root(root.path()), + RootUpdate { + status: IndexStatus::Building, + file_count: 0, + indexed_bytes: 0, + error_count: 0, + last_error: None, + }, + ) + .unwrap(); + assert!(matches!( + store.ensure_index(root.path()).unwrap(), + EnsureOutcome::Triggered, + )); + + // The same row while a build IS running in this process must still + // answer InProgress. + let rebuilding = IndexStore::open(data.path()).unwrap(); + rebuilding + .set_root_status( + &root_id(&normalize_root(root.path())), + &normalize_root(root.path()), + RootUpdate { + status: IndexStatus::Building, + file_count: 0, + indexed_bytes: 0, + error_count: 0, + last_error: None, + }, + ) + .unwrap(); + rebuilding.building_roots.lock().unwrap().insert( + root_id(&normalize_root(root.path())), + Arc::new(BuildProgress::default()), + ); + assert!(matches!( + rebuilding.ensure_index(root.path()).unwrap(), + EnsureOutcome::InProgress, + )); + } + + #[test] + fn disabled_store_answers_every_read_as_unavailable() { + let store = IndexStore::disabled(); + assert!(store.status(None).is_err()); + assert!(store.ensure_index(Path::new("/nonexistent")).is_err()); + } + + #[test] + fn rebuild_indexes_text_and_ignores_binary_and_vendor_dirs() { + let data = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + fs::create_dir_all(root.path().join("node_modules/pkg")).unwrap(); + fs::create_dir_all(root.path().join(".git")).unwrap(); + fs::write(root.path().join(".pi-desktopignore"), "private.txt\n").unwrap(); + fs::write(root.path().join("README.md"), "hello index\n").unwrap(); + fs::write(root.path().join("notes.md"), "indexed too\n").unwrap(); + fs::write(root.path().join("private.txt"), "private\n").unwrap(); + fs::write(root.path().join("node_modules/pkg/ignored.js"), "ignored\n").unwrap(); + fs::write(root.path().join("image.png"), [0_u8, 1, 2, 3]).unwrap(); + let store = IndexStore::open(data.path()).unwrap(); + let status = store.rebuild(root.path(), IndexLimits::default()).unwrap(); + assert_eq!(status.status, "fresh"); + assert_eq!(status.file_count, 2); + assert!(status.indexed_bytes > 0); + // The binary file is visible but not ingested. Both halves matter: it + // must be recorded (so the fast path still searches it) yet must not + // count as indexed content. + assert_eq!( + store.indexed_rel_paths(root.path()).unwrap(), + vec![ + "README.md".to_string(), + "image.png".to_string(), + "notes.md".to_string() + ] + ); + } + + #[test] + fn budget_marks_root_skipped_without_serving_partial_index() { + let data = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("one.txt"), "one\n").unwrap(); + fs::write(root.path().join("two.txt"), "two\n").unwrap(); + let store = IndexStore::open(data.path()).unwrap(); + let status = store + .rebuild( + root.path(), + IndexLimits { + max_files: 1, + ..IndexLimits::default() + }, + ) + .unwrap(); + assert_eq!(status.status, "skipped_over_limit"); + } + + #[test] + fn corrupt_store_is_quarantined_and_recreated() { + let data = tempfile::tempdir().unwrap(); + let path = data.path().join("index/index.db"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"not sqlite").unwrap(); + let store = IndexStore::open(data.path()).unwrap(); + assert!(store.status(None).unwrap().is_empty()); + assert!(fs::read_dir(path.parent().unwrap()).unwrap().count() >= 2); + } + + #[test] + fn multiple_roots_are_namespaced_and_clear_is_scoped() { + let data = tempfile::tempdir().unwrap(); + let root_a = tempfile::tempdir().unwrap(); + let root_b = tempfile::tempdir().unwrap(); + fs::write(root_a.path().join("a.txt"), "alpha\n").unwrap(); + fs::write(root_b.path().join("b.txt"), "bravo\n").unwrap(); + let store = IndexStore::open(data.path()).unwrap(); + store + .rebuild(root_a.path(), IndexLimits::default()) + .unwrap(); + store + .rebuild(root_b.path(), IndexLimits::default()) + .unwrap(); + assert_eq!(store.status(None).unwrap().len(), 2); + assert_eq!(store.clear(Some(root_a.path())).unwrap(), 1); + let remaining = store.status(None).unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!( + remaining[0].root_path, + normalize_root(root_b.path()).to_string_lossy() + ); + + // The FTS side of the cleared root must go too: the virtual table has + // no foreign keys, so clear deletes its rows explicitly. A leftover + // row would silently keep workspace content readable after "clear". + let connection = fts::open(&store.path).unwrap(); + let orphaned: i64 = connection + .query_row("SELECT COUNT(*) FROM file_content_fts", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(orphaned, 1); + let files_left: i64 = connection + .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0)) + .unwrap(); + assert_eq!(files_left, 1); + } + + #[test] + fn status_reports_progress_only_while_building() { + let data = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("a.txt"), "alpha\n").unwrap(); + let store = IndexStore::open(data.path()).unwrap(); + + // An unindexed root marked building (as the auto-index path does) + // surfaces the progress block; a never-built root seeds the total at 0. + assert_eq!( + store.ensure_index(root.path()).unwrap(), + crate::index::EnsureOutcome::Triggered + ); + let building = store + .status(Some(root.path())) + .unwrap() + .into_iter() + .next() + .unwrap(); + assert_eq!(building.status, "building"); + let progress = building.progress.expect("building exposes progress"); + assert_eq!(progress.files_done, 0); + + // A fresh root carries no progress field. + let fresh = store.rebuild(root.path(), IndexLimits::default()).unwrap(); + assert_eq!(fresh.status, "fresh"); + assert!(fresh.progress.is_none()); + + // Marking the freshly built root stale makes the next ensure_index mark + // it building again; the progress denominator is seeded from the + // previous visible set (1 file). + fts::open(&store.path) + .unwrap() + .execute("UPDATE indexed_roots SET status = 'stale'", []) + .unwrap(); + assert_eq!( + store.ensure_index(root.path()).unwrap(), + crate::index::EnsureOutcome::Triggered + ); + let rebuilding = store + .status(Some(root.path())) + .unwrap() + .into_iter() + .next() + .unwrap(); + let progress = rebuilding.progress.expect("building exposes progress"); + assert_eq!(progress.files_total, 1); + } + + #[test] + fn progress_never_reports_total_below_done() { + let progress = BuildProgress::default(); + progress.reset(1); + progress.inc_done(); + progress.inc_done(); + progress.inc_done(); + assert_eq!(progress.snapshot(), (3, 3)); + } +} diff --git a/crates/host-core/src/index/fts.rs b/crates/host-core/src/index/fts.rs new file mode 100644 index 0000000000..ce48810a32 --- /dev/null +++ b/crates/host-core/src/index/fts.rs @@ -0,0 +1,81 @@ +//! SQLite storage for the workspace content index: the schema, the +//! connection opener, and the pragmas every writer relies on. + +use anyhow::{Context, Result}; +use rusqlite::Connection; +use std::path::Path; + +/// Binary/archival extensions the index never ingests. This is a *content* +/// filter, not a visibility rule: such files stay visible to Grep and are +/// simply not worth indexing. Vendor/ignore visibility lives in +/// `crate::tools::ignore_rules`. +pub(crate) fn is_binary_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "7z" | "a" + | "bmp" + | "class" + | "dll" + | "dmg" + | "exe" + | "gif" + | "ico" + | "jar" + | "jpeg" + | "jpg" + | "mov" + | "mp3" + | "mp4" + | "o" + | "obj" + | "pdf" + | "png" + | "so" + | "tar" + | "wasm" + | "webp" + | "woff" + | "woff2" + | "zip" + ) + }) +} + +pub const SCHEMA: &str = r#" + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS indexed_roots ( + root_id TEXT PRIMARY KEY, + root_path TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('fresh','building','stale','failed','partial','disabled','skipped_over_limit')), + file_count INTEGER NOT NULL DEFAULT 0, + indexed_bytes INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS files ( + root_id TEXT NOT NULL REFERENCES indexed_roots(root_id) ON DELETE CASCADE, + rel_path TEXT NOT NULL, + size INTEGER NOT NULL, + mtime_ms INTEGER NOT NULL, + content_indexed INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (root_id, rel_path) + ); + CREATE VIRTUAL TABLE IF NOT EXISTS file_content_fts USING fts5( + root_id UNINDEXED, + rel_path UNINDEXED, + body, + tokenize = 'trigram' + ); +"#; + +pub fn open(path: &Path) -> Result { + let connection = Connection::open(path).context("open index database")?; + connection.busy_timeout(std::time::Duration::from_secs(5))?; + connection.execute_batch("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;")?; + Ok(connection) +} diff --git a/crates/host-core/src/main.rs b/crates/host-core/src/main.rs index bc94a224c1..5f3f9b757d 100644 --- a/crates/host-core/src/main.rs +++ b/crates/host-core/src/main.rs @@ -4,6 +4,7 @@ mod artifacts; mod audit; mod config_sync; mod db; +mod index; mod keyboard; mod mcp_servers; mod network_policy; diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs index 16d6c71491..555b4be6be 100644 --- a/crates/host-core/src/rpc/mod.rs +++ b/crates/host-core/src/rpc/mod.rs @@ -491,6 +491,24 @@ fn rpc_err(code: i64, message: impl Into, error_code: &str) -> JsonRpcEr } } +fn checked_index_root( + requested: Option, + current: PathBuf, +) -> Result { + let current = crate::index::normalize_root(¤t); + let requested = requested + .map(|root| crate::index::normalize_root(&root)) + .unwrap_or_else(|| current.clone()); + if requested != current { + return Err(rpc_err( + 1002, + "rootPath must match the active workspace", + "INDEX_ROOT_OUTSIDE_WORKSPACE", + )); + } + Ok(current) +} + fn config_sync_rpc_err(error: impl ToString) -> JsonRpcError { let message = error.to_string(); let error_code = message @@ -811,6 +829,16 @@ fn effective_command_shell_id(settings: Option<&Value>) -> Option { .map(|shell| shell.id) } +/// Whether the workspace index switch is on. Absent or `false` leaves the +/// index alone: nothing builds one and the index RPCs still answer status, +/// so the switch is the single owner of when a workspace gets indexed. +fn index_grep_boost_enabled(settings: Option<&Value>) -> bool { + settings + .and_then(|value| value.get("indexGrepBoost")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + fn validate_settings_value(value: &Value) -> Result<(), JsonRpcError> { let Some(object) = value.as_object() else { return Ok(()); @@ -936,6 +964,17 @@ fn validate_settings_value(value: &Value) -> Result<(), JsonRpcError> { if let Err(message) = crate::network_proxy::validate_network_proxy(value) { return Err(rpc_err(1002, message, "INVALID_PARAMS")); } + // The index switch is opt-in. Reject non-booleans so a malformed patch + // cannot switch it on through JSON truthiness. + if let Some(flag) = object.get("indexGrepBoost") { + if !flag.is_boolean() { + return Err(rpc_err( + 1002, + "indexGrepBoost must be a boolean", + "INVALID_PARAMS", + )); + } + } let Some(shell_value) = object.get("defaultCommandShell") else { return Ok(()); }; @@ -1681,6 +1720,129 @@ async fn handle_request( .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; Ok(json!({ "projects": projects })) } + "index.status" => { + let started = std::time::Instant::now(); + let requested_root = params + .get("rootPath") + .and_then(Value::as_str) + .map(PathBuf::from); + let (index, current_root) = { + let st = state.lock().await; + ( + st.index.clone(), + st.workspace + .get() + .map(|workspace| PathBuf::from(workspace.path)), + ) + }; + let Some(current_root) = current_root else { + tracing::info!( + method = "index.status", + duration_ms = started.elapsed().as_millis() as u64, + "index rpc served" + ); + return Ok(json!({ "roots": [] })); + }; + let root = checked_index_root(requested_root, current_root)?; + let roots = tokio::task::spawn_blocking(move || index.status(Some(&root))) + .await + .map_err(|e| rpc_err(1000, e.to_string(), "INDEX_UNAVAILABLE"))? + .map_err(|e| rpc_err(1000, e.to_string(), "INDEX_UNAVAILABLE"))?; + tracing::info!( + method = "index.status", + roots = roots.len(), + duration_ms = started.elapsed().as_millis() as u64, + "index rpc served" + ); + Ok(json!({ "roots": roots })) + } + "index.rebuild" => { + let started = std::time::Instant::now(); + let requested_root = params + .get("rootPath") + .and_then(Value::as_str) + .map(PathBuf::from); + let (index, current_root) = { + let st = state.lock().await; + ( + st.index.clone(), + st.workspace + .get() + .map(|workspace| PathBuf::from(workspace.path)), + ) + }; + let current_root = current_root + .ok_or_else(|| rpc_err(1002, "active workspace required", "INVALID_PARAMS"))?; + let root = checked_index_root(requested_root, current_root)?; + let audit_root = root.to_string_lossy().into_owned(); + let status = tokio::task::spawn_blocking(move || { + index.rebuild(&root, crate::index::IndexLimits::default()) + }) + .await + .map_err(|e| rpc_err(1000, e.to_string(), "INDEX_REBUILD_FAILED"))? + .map_err(|e| rpc_err(1000, e.to_string(), "INDEX_REBUILD_FAILED"))?; + // Destructive lifecycle operation: record who rebuilt which root. + // Only the redacted summary fields go to the audit log — never any + // file content. + let st = state.lock().await; + let _ = audit::append( + &st.db, + "index_rebuild", + None, + json!({ + "rootPath": audit_root, + "status": status.status, + "fileCount": status.file_count, + "errorCount": status.error_count, + }), + ); + tracing::info!( + method = "index.rebuild", + status = %status.status, + file_count = status.file_count, + duration_ms = started.elapsed().as_millis() as u64, + "index rpc served" + ); + Ok(json!({ "root": status })) + } + "index.clear" => { + let started = std::time::Instant::now(); + let requested_root = params + .get("rootPath") + .and_then(Value::as_str) + .map(PathBuf::from); + let (index, current_root) = { + let st = state.lock().await; + ( + st.index.clone(), + st.workspace + .get() + .map(|workspace| PathBuf::from(workspace.path)), + ) + }; + let current_root = current_root + .ok_or_else(|| rpc_err(1002, "active workspace required", "INVALID_PARAMS"))?; + let root = checked_index_root(requested_root, current_root)?; + let audit_root = root.to_string_lossy().into_owned(); + let cleared = tokio::task::spawn_blocking(move || index.clear(Some(&root))) + .await + .map_err(|e| rpc_err(1000, e.to_string(), "INDEX_UNAVAILABLE"))? + .map_err(|e| rpc_err(1000, e.to_string(), "INDEX_UNAVAILABLE"))?; + let st = state.lock().await; + let _ = audit::append( + &st.db, + "index_clear", + None, + json!({ "rootPath": audit_root, "cleared": cleared }), + ); + tracing::info!( + method = "index.clear", + cleared, + duration_ms = started.elapsed().as_millis() as u64, + "index rpc served" + ); + Ok(json!({ "ok": true, "cleared": cleared })) + } "project.groups.list" => { let st = state.lock().await; let groups = st @@ -1947,6 +2109,7 @@ async fn handle_request( .and_then(|v| v.as_str()) .ok_or_else(|| rpc_err(1002, "path required", "INVALID_PARAMS"))?; let mut st = state.lock().await; + let previous = st.workspace.get().map(|workspace| workspace.path); st.hashline.drop_all(); let ws = st.workspace.set(PathBuf::from(path)); let pid = st @@ -1956,6 +2119,48 @@ async fn handle_request( st.db .kv_set("app", "currentProjectId", &json!(pid)) .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; + // Auto-index the workspace while the index switch is on: one + // switch owns the index's whole lifecycle. A changed path always + // (re)indexes; the scan runs on the blocking pool, so workspace.set + // stays fast and `index.status` reports `building` until it lands. + let settings = st.db.get_setting("app").ok().flatten(); + let changed = previous.as_deref() != Some(ws.path.as_str()); + let index = st.index.clone(); + let root = PathBuf::from(ws.path.clone()); + let boost = index_grep_boost_enabled(settings.as_ref()); + // The index store owns its connection, so its calls below do not + // need the app state lock; drop the lock before doing them so + // concurrent RPCs are not serialized behind this one. + drop(st); + if boost && changed { + match index.ensure_index(&root) { + Ok(crate::index::EnsureOutcome::Triggered) => { + let build_index = index.clone(); + let build_root = root.clone(); + let handle = tokio::task::spawn_blocking(move || { + if let Err(error) = build_index + .rebuild(&build_root, crate::index::IndexLimits::default()) + { + tracing::warn!(error = %error, "background index rebuild failed"); + } + }); + // Keep the join handle reachable so tests (and any + // future caller) can await the build instead of + // polling the store for a status flip. + let mut st = state.lock().await; + st.index_builds.insert( + crate::index::normalize_root(&root) + .to_string_lossy() + .into_owned(), + handle, + ); + } + Ok(_) => {} + Err(error) => { + tracing::warn!(error = %error, "auto index ensure failed"); + } + } + } Ok(json!({ "workspace": ws })) } "workspace.clear" => { @@ -2053,6 +2258,10 @@ async fn handle_request( { gate_default_command_shell_setting(&st)?; } + // Captured before `stored` is consumed by the merge below: the + // index-switch check compares the incoming switch against the + // stored one. + let boost_was_on = index_grep_boost_enabled(stored.as_ref()); let mut settings = normalize_settings_value(merge_settings_value(stored, params)); prune_unresolvable_image_bindings(&st.db, &mut settings) .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; @@ -2076,6 +2285,44 @@ async fn handle_request( } crate::network_proxy::apply_from_settings(Some(&settings)); crate::network_policy::apply_from_settings(Some(&settings)); + // Turning the index switch on must arm the index for the + // workspace the user is looking at; otherwise the index only + // exists after the next workspace switch, and the switch's + // lifetime is disjoint from the page that reports on it. + if index_grep_boost_enabled(Some(&settings)) && !boost_was_on { + if let Some(workspace) = st.workspace.get() { + let index = st.index.clone(); + let root = PathBuf::from(workspace.path); + drop(st); + match index.ensure_index(&root) { + Ok(crate::index::EnsureOutcome::Triggered) => { + let build_index = index.clone(); + let build_root = root.clone(); + let handle = tokio::task::spawn_blocking(move || { + if let Err(error) = build_index + .rebuild(&build_root, crate::index::IndexLimits::default()) + { + tracing::warn!( + error = %error, + "boost-enable index build failed" + ); + } + }); + let mut st = state.lock().await; + st.index_builds.insert( + crate::index::normalize_root(&root) + .to_string_lossy() + .into_owned(), + handle, + ); + } + Ok(_) => {} + Err(error) => { + tracing::warn!(error = %error, "boost-enable ensure failed"); + } + } + } + } Ok(json!({ "ok": true })) } @@ -4818,9 +5065,10 @@ mod tests { use tokio::sync::{mpsc, Mutex}; use super::{ - capability_err, handle_request, parse_capability_query, parse_capability_target, - peek_jsonrpc_id, provider_rpc_err, resolve_plan_workspace, resolve_tool_workspace, - resolve_tool_workspace_for_call, scope_err, skill_err, + capability_err, handle_request, index_grep_boost_enabled, parse_capability_query, + parse_capability_target, peek_jsonrpc_id, provider_rpc_err, resolve_plan_workspace, + resolve_tool_workspace, resolve_tool_workspace_for_call, scope_err, skill_err, + validate_settings_value, }; use crate::agent_capabilities::CapabilityLevel; use crate::plans::{PlanResolveParams, PlanSubmitParams}; @@ -7031,6 +7279,128 @@ mod tests { .unwrap(); } + #[test] + fn index_grep_boost_defaults_off_and_rejects_non_booleans() { + assert!(!index_grep_boost_enabled(None)); + assert!(!index_grep_boost_enabled(Some(&json!({})))); + assert!(!index_grep_boost_enabled(Some( + &json!({ "indexGrepBoost": false }) + ))); + assert!(index_grep_boost_enabled(Some( + &json!({ "indexGrepBoost": true }) + ))); + + assert!(validate_settings_value(&json!({ "indexGrepBoost": true })).is_ok()); + assert!(validate_settings_value(&json!({ "theme": "light" })).is_ok()); + // A truthy string must not be able to switch the index on. + assert!(validate_settings_value(&json!({ "indexGrepBoost": "true" })).is_err()); + assert!(validate_settings_value(&json!({ "indexGrepBoost": 1 })).is_err()); + } + + #[tokio::test] + async fn workspace_set_auto_indexes_only_while_the_switch_is_on() { + let data_dir = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + std::fs::write(workspace.path().join("auto.txt"), "auto index target\n").unwrap(); + let mut app_state = AppState::open(data_dir.path()).unwrap(); + app_state.handshook = true; + let state = Arc::new(Mutex::new(app_state)); + let (tx, _rx) = mpsc::unbounded_channel(); + + // Switch off (the default): no index rows are created for the root. + handle_request( + state.clone(), + "workspace.set", + json!({ "path": workspace.path().display().to_string() }), + tx.clone(), + ) + .await + .unwrap(); + let off = handle_request(state.clone(), "index.status", json!({}), tx.clone()) + .await + .unwrap(); + assert_eq!(off["roots"].as_array().unwrap().len(), 0); + + // Turn the switch on, then switch to a different workspace: the + // background build must land at a fresh root. The build's join handle + // is awaited directly — no wall-clock polling — so the assertion reads + // the settled status the moment the build completes. + handle_request( + state.clone(), + "settings.set", + json!({ "indexGrepBoost": true }), + tx.clone(), + ) + .await + .unwrap(); + let other = tempfile::tempdir().unwrap(); + std::fs::write(other.path().join("other.txt"), "other workspace\n").unwrap(); + handle_request( + state.clone(), + "workspace.set", + json!({ "path": other.path().display().to_string() }), + tx.clone(), + ) + .await + .unwrap(); + let build = { + let mut st = state.lock().await; + st.index_builds.remove( + &crate::index::normalize_root(other.path()) + .to_string_lossy() + .into_owned(), + ) + }; + let build = build.expect("workspace.set registered the background build"); + build.await.expect("background build task panicked"); + let status = handle_request(state.clone(), "index.status", json!({}), tx.clone()) + .await + .unwrap(); + let roots = status["roots"].as_array().unwrap(); + let root = roots.first().expect("indexed root present"); + assert_eq!(root["status"], "fresh"); + assert_eq!(root["fileCount"], 1); + } + + #[tokio::test] + async fn index_rebuild_and_clear_are_audited() { + let data_dir = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + std::fs::write(workspace.path().join("audit.txt"), "audit target\n").unwrap(); + let mut app_state = AppState::open(data_dir.path()).unwrap(); + app_state.handshook = true; + let state = Arc::new(Mutex::new(app_state)); + let (tx, _rx) = mpsc::unbounded_channel(); + + handle_request( + state.clone(), + "workspace.set", + json!({ "path": workspace.path().display().to_string() }), + tx.clone(), + ) + .await + .unwrap(); + handle_request(state.clone(), "index.rebuild", json!({}), tx.clone()) + .await + .unwrap(); + handle_request(state.clone(), "index.clear", json!({}), tx.clone()) + .await + .unwrap(); + + let st = state.lock().await; + let mut kinds: Vec = st + .db + .conn() + .prepare("SELECT kind FROM audit_log WHERE kind LIKE 'index_%' ORDER BY id") + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap(); + kinds.dedup(); + assert_eq!(kinds, vec!["index_rebuild", "index_clear"]); + } + #[tokio::test] async fn bash_rejects_a_changed_shell_before_running_the_command() { let Some(current_shell_id) = available_test_shell_id() else { diff --git a/crates/host-core/src/state.rs b/crates/host-core/src/state.rs index 76de5500e4..5f5ce027d3 100644 --- a/crates/host-core/src/state.rs +++ b/crates/host-core/src/state.rs @@ -5,6 +5,7 @@ use std::time::{Duration, Instant}; use anyhow::Result; use crate::db::Database; +use crate::index::IndexStore; use crate::mcp_servers::McpServerRegistry; use crate::permissions::PermissionManager; use crate::plans::PlanManager; @@ -27,6 +28,14 @@ const PLUGIN_DELETE_RATE_LIMIT: usize = 20; pub struct AppState { pub data_dir: std::path::PathBuf, pub db: Database, + /// Workspace content index (FTS5 store under `/index/index.db`). + /// Lifecycle only — status/rebuild/clear; it does not participate in any + /// tool execution. + pub index: IndexStore, + /// normalized root → join handle of the background build that + /// `workspace.set`/`settings.set` spawned, so callers (and tests) can + /// await a build's completion instead of polling the wall clock. + pub index_builds: HashMap>, pub secrets: SecretStore, pub workspace: WorkspaceState, pub permissions: PermissionManager, @@ -102,6 +111,17 @@ impl AppState { Err(error) => tracing::warn!(%error, "in-flight reply sweep failed"), } let secrets = SecretStore::open(data_dir)?; + // The index is an optimization layer: if its store cannot be opened + // even after the quarantine-and-retry, degrade to a disabled store + // (the status RPC reports unavailable) instead of costing the host + // its boot. + let index = match IndexStore::open(data_dir) { + Ok(index) => index, + Err(error) => { + tracing::warn!(%error, "index store unavailable; indexing stays disabled"); + IndexStore::disabled() + } + }; // The marketplace channel is read before the manager builds its first // catalog, so a source configured for networks without GitHub access // applies on launch instead of only after a manual refresh. @@ -127,6 +147,8 @@ impl AppState { Ok(Self { data_dir: data_dir.to_path_buf(), db, + index, + index_builds: HashMap::new(), secrets, workspace: WorkspaceState::default(), permissions: PermissionManager::default(), diff --git a/crates/host-core/src/tools/ignore_rules.rs b/crates/host-core/src/tools/ignore_rules.rs index 09b549c7b0..3e57c613cd 100644 --- a/crates/host-core/src/tools/ignore_rules.rs +++ b/crates/host-core/src/tools/ignore_rules.rs @@ -209,6 +209,47 @@ pub fn rg_args(ignore_root: &Path, scoped: bool) -> Vec { args } +// ---- P2-B workspace visible-set additions -------------------------------- +// +// The index crawler reuses this module so the index visible set and the Grep +// candidate walk stay structurally identical: both are configured by +// [`configure_walker`], whose layers pair with [`rg_args`] on the rg side. + +/// Directory names that are tooling or scan metadata rather than workspace +/// content. The index crawler prunes these before ingest in addition to the +/// ignore layers above, so their bytes never reach the FTS store. +pub const VENDOR_COMPONENTS: &[&str] = &[".git", ".pi-desktopignore", "node_modules", "target"]; + +/// Build the shared visible-set walker for `root`. +/// +/// `scoped` mirrors Grep's scoped search: when the caller names a path +/// explicitly, parent ignore files are dropped so an explicitly named +/// directory stays reachable. The index crawler always walks the whole root +/// (`scoped == false`). +pub fn visible_walker(root: &Path, scoped: bool) -> WalkBuilder { + let mut walker = WalkBuilder::new(root); + walker.hidden(false).git_ignore(true); + if scoped { + // Same as rg's --no-ignore-parent for a scoped search: an explicitly + // named directory stays reachable even when a parent directory + // ignores it. + walker.parents(false); + } + configure_walker(&mut walker, root, scoped); + walker +} + +/// Whether a walked path sits under a [`VENDOR_COMPONENTS`] entry. Only +/// applied to a whole-workspace (unscoped) search. +pub fn is_vendor_path(root: &Path, path: &Path) -> bool { + let relative = path.strip_prefix(root).unwrap_or(path); + relative.components().any(|component| { + VENDOR_COMPONENTS + .iter() + .any(|vendor| component.as_os_str() == *vendor) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -260,3 +301,84 @@ mod tests { assert!(unscoped.contains(&"!*.log".to_string())); } } + +#[cfg(test)] +mod vendor_visibility_tests { + use super::*; + use std::fs; + + fn visible_from_walker(root: &Path, scoped: bool) -> Vec { + let mut paths: Vec = visible_walker(root, scoped) + .build() + .flatten() + .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file())) + .map(|entry| entry.path().to_path_buf()) + .filter(|path| scoped || !is_vendor_path(root, path)) + .map(|path| { + path.strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + paths.sort(); + paths + } + + #[test] + fn vendor_and_custom_ignore_drop_from_whole_workspace_visibility() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir_all(root.path().join("node_modules/pkg")).unwrap(); + fs::create_dir_all(root.path().join(".git")).unwrap(); + fs::write(root.path().join(".pi-desktopignore"), "private.txt\n").unwrap(); + fs::write(root.path().join("keep.txt"), "keep\n").unwrap(); + fs::write(root.path().join("private.txt"), "secret\n").unwrap(); + fs::write(root.path().join("node_modules/pkg/a.js"), "x\n").unwrap(); + fs::write(root.path().join(".git/config"), "[core]\n").unwrap(); + + assert_eq!(visible_from_walker(root.path(), false), vec!["keep.txt"]); + } + + #[test] + fn explicitly_named_vendor_directory_stays_reachable() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir_all(root.path().join("node_modules/pkg")).unwrap(); + fs::write(root.path().join("node_modules/pkg/index.js"), "x\n").unwrap(); + + // Scoped: no vendor prune, so the named directory is visible. + assert_eq!( + visible_from_walker(&root.path().join("node_modules/pkg"), true), + vec!["index.js"] + ); + } + + #[test] + fn scoped_walk_drops_parent_ignore_files_like_the_rg_side() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join(".ignore"), "node_modules\n").unwrap(); + fs::create_dir_all(root.path().join("node_modules/pkg")).unwrap(); + fs::write(root.path().join("node_modules/pkg/index.js"), "x\n").unwrap(); + + // A scoped walk rooted below the ignore file must not honor it — + // the same contract rg gets via --no-ignore-parent — while the + // whole-workspace walk keeps filtering (only the ignore file itself + // stays visible; `node_modules/` is dropped). + assert_eq!( + visible_from_walker(&root.path().join("node_modules/pkg"), true), + vec!["index.js"] + ); + assert_eq!(visible_from_walker(root.path(), false), vec![".ignore"]); + } + + #[test] + fn root_pi_desktopignore_constrains_the_whole_scan() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir_all(root.path().join("sub")).unwrap(); + fs::write(root.path().join(".pi-desktopignore"), "*.log\n").unwrap(); + fs::write(root.path().join("app.log"), "x\n").unwrap(); + fs::write(root.path().join("sub/deep.log"), "x\n").unwrap(); + fs::write(root.path().join("main.rs"), "x\n").unwrap(); + + assert_eq!(visible_from_walker(root.path(), false), vec!["main.rs"]); + } +} diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 8ac585d15c..97843642b1 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -35,6 +35,7 @@ Principles: | `menu` | Allowlisted application-menu commands and native editing/window actions | | `notification` | Durable inbox list/read/clear and new/activated events | | `stats` | Completed-turn token history (host RPC; dashboard is plugin-owned) | +| `index` | Workspace index lifecycle (host RPC; builds and reports a local cache behind the opt-in `indexGrepBoost` switch) | ## 3. Channel Conventions @@ -1155,6 +1156,20 @@ ISO week year (`%G-W%V`). The result fills empty buckets in range. This channel is not a Settings page; the user-facing dashboard is plugin `pi.token-insights` (D335 / ADR 0173). +### index + +- `pi-desktop/index/status({ rootPath? }) -> { roots: WorkspaceIndexRoot[] }` +- `pi-desktop/index/rebuild({ rootPath? }) -> { root: WorkspaceIndexRoot }` +- `pi-desktop/index/clear({ rootPath? }) -> { ok, cleared }` + +Lifecycle channels for the host-owned workspace index cache. `rootPath` is +optional and must equal the active workspace when present +(`INDEX_ROOT_OUTSIDE_WORKSPACE` otherwise). `rebuild` scans the workspace into +`/index/index.db` under fixed file-count and byte budgets and returns +the resulting root status. `status` returns lifecycle metadata only and never +file contents. The cache is only built while the `indexGrepBoost` switch is on; +no tool reads it. + ## 8. Settings / Secrets API ### settings diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index 85caae73f3..72ec24d642 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -70,6 +70,9 @@ to an absolute path before it reaches host-core as a child-process variable. ├── plugins/ # code + data + registry.json (unchanged, spec 07-11) ├── logs/ # NDJSON app/, host/, agent/ logs ├── cache/ # disposable caches + ├── index/ # rebuildable workspace-search cache (host-core only) + │ ├── index.db # root metadata + file metadata + FTS5 text + │ └── index.db.corrupt- # quarantined cache after integrity/schema failure ├── crash-dumps/ # local Crashpad minidumps (never uploaded; D602) ├── crash-dumps.json # last-reported dump mtime (best-effort marker) ├── review-changes/// @@ -90,6 +93,16 @@ content lives in `sessions/`, attachments and tool outputs beyond the limits of [16-tool-result-limits](16-tool-result-limits.md) live on disk, referenced by path/hash. +`index/index.db` is deliberately separate from `pi.sqlite`. It is a disposable, +rebuildable optimization cache and never filesystem truth. It stores normalized +root and relative paths, file size and modification time, lifecycle/error +metadata, and indexed text in FTS5. It stores no credentials, message history, +project entity records, or file hashes. It is excluded from application backup, +export, and sync surfaces; corruption or an unsupported index schema quarantines +the old cache and creates a new empty index without touching `pi.sqlite`. +The index RPCs are lifecycle-only (`status`/`rebuild`/`clear`); no tool reads +this cache. + ### 1.3 Portable configuration sync Host-core stores sync configuration in the `configSync` key-value namespace. diff --git a/docs/spec/03-runtime/06-host-rpc-protocol.md b/docs/spec/03-runtime/06-host-rpc-protocol.md index 5c91866d4c..dd3758f94e 100644 --- a/docs/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/spec/03-runtime/06-host-rpc-protocol.md @@ -203,6 +203,24 @@ type ToolBudgetHealth = { - `workspace.set` - `workspace.clear` +### Workspace index +- `index.status({rootPath?})` returns lifecycle status for the host-owned, + disposable workspace index. It never exposes file contents. +- `index.rebuild({rootPath?})` scans the selected workspace into the isolated + `/index/index.db` cache. When `rootPath` is omitted, the current + workspace is used. The operation enforces fixed file-count and byte budgets. +- `workspace.set` triggers a background `ensure_index` + rebuild for a changed +workspace while `indexGrepBoost` is on; with it off, switching workspaces +never touches the index. `index.clear({rootPath?})` removes the active + workspace root namespace. When `rootPath` is present it must equal the active + workspace; omission selects that same workspace. + +The index database is a rebuildable optimization cache, not filesystem truth. +No tool reads it: `tools.execute` keeps its exact behavior whether or not an +index exists, and the index RPCs are the cache's only consumer. Roots report +`fresh`, `building`, `stale`, `failed`, `partial`, `disabled`, or +`skipped_over_limit`. + ### Review snapshots (ADR 0043) - `review.rollback({sessionId, snapshotId})` — verify the current post-tool hash, restore the session-owned previous bytes, and return one of diff --git a/docs/spec/03-runtime/08-error-codes.md b/docs/spec/03-runtime/08-error-codes.md index aacc769191..32b77b40f2 100644 --- a/docs/spec/03-runtime/08-error-codes.md +++ b/docs/spec/03-runtime/08-error-codes.md @@ -117,6 +117,9 @@ does not turn temporary thread pressure into a host process exit. |---|---|---| | `WORKSPACE_REQUIRED` | no | no workspace bound | | `PATH_OUTSIDE_WORKSPACE` | no | path escapes sandbox before an explicit outside-path permission decision, or a prompt attachment is outside its session scratch/project/attachment roots | +| `INDEX_UNAVAILABLE` | no | the index store could not be opened or the rebuild worker failed | +| `INDEX_ROOT_OUTSIDE_WORKSPACE` | no | index.status/rebuild/clear was called with a root that is not the active workspace root | +| `INDEX_REBUILD_FAILED` | no | a background index rebuild failed partway; the root is marked failed and can be rebuilt again | | `WORKSPACE_PATH_DENIED` | no | an explicit `Read`/`Write`/`Edit` path hit the always-on security denylist (private keys, `.env` files, credential bundles, `.git/objects`); an outside-path grant does not lift it (spec 15 §3) | | `READ_PATH_IS_DIRECTORY` | no | `Read` was given a directory; the result carries a `Glob` suggestion | | `TOOL_BINARY_CONTENT` | no | `Read` refused to dump a binary file into the model context | diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index fb76f9d1ce..aeade0251c 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -55,17 +55,18 @@ Settings is a **full-window page** that replaces the app sidebar + main chrome ( 8. **Subagents / 子智能体** — Lucide `Bot` (built-in and personal parallel agents) 9. **Import / 导入** — Lucide `Download` (bring sessions and model configuration in from other tools) 10. **Projects / 项目** — Lucide `Archive` (durable project index) - 11. **Cloud sync / 云同步** — Lucide `CloudDownload` (encrypted portable configuration backup and bidirectional sync; developer mode only) - 12. **Remote Hosts / 远程主机** — Lucide `Globe` (SSH bootstrap and pairing inventory; developer mode only) - 13. **Info / 信息** — Lucide `Info` (versions, logs, updates, developer) + 11. **Index / 索引** — Lucide `Database` (workspace index health and lifecycle) + 12. **Cloud sync / 云同步** — Lucide `CloudDownload` (encrypted portable configuration backup and bidirectional sync; developer mode only) + 13. **Remote Hosts / 远程主机** — Lucide `Globe` (SSH bootstrap and pairing inventory; developer mode only) + 14. **Info / 信息** — Lucide `Info` (versions, logs, updates, developer) Icons are decorative (`aria-hidden` via the SVG default) and stay monochrome with the rail label; do not reuse refresh/rotate glyphs here. - The directory remains a flat searchable list in the same exact order. For scanability, the destinations are shown in four titled visual clusters: `Preferences` / `偏好` (General, AI, Shortcuts), `Agent` / `智能体` (Instructions, Models, Skills, MCP, Subagents), `Workspace` / `工作区` - (Import, Projects), and `System` / `系统` (Cloud sync, Remote Hosts, Info; - Cloud sync and Remote Hosts are developer-only). Headings are + (Import, Projects, Index), and `System` / `系统` (Cloud sync, Remote Hosts, + Info; Cloud sync and Remote Hosts are developer-only). Headings are muted, non-interactive labels and use whitespace for separation; no divider lines are rendered. These are visual landmarks only, not a second navigation level. @@ -724,6 +725,29 @@ system while preserving their different data ownership: - Activating a project or project session returns to chat; archive and close actions keep Project archive open even when the active workspace changes +### Index library (`index` tab, `Workspace` group) +- One health card for the host-owned workspace index cache: status, indexed + file count, indexed size, unreadable-file count, and last-update time +- A standalone `Codebase` section below the health card carries the single + opt-in toggle, `Workspace indexing` (`indexGrepBoost`, default off). When + on, opening a different workspace marks its index `building` and rebuilds + it on the blocking pool, so `workspace.set` stays fast and the health card + polls `index.status` once a second while building, pausing when the window + is hidden. No tool reads the index: it is reported on, rebuilt, and cleared + from this page alone. A second "index new folders" toggle would either + duplicate this switch or build an index that nothing uses, so the section + carries exactly one +- Actions are Rebuild index and Clear index; both call the host lifecycle + RPCs and refresh the card from the returned status. Rebuild is offered only + while the opt-in toggle is on, because an index the switch never feeds is + just a scan and some disk; Clear stays available so a leftover index can + still be removed +- The copy states that the index is a rebuildable local cache whose data + never leaves the machine +- Empty state: no root yet for the active workspace, with Build index as the + single action, gated the same way. Load failure shows a retry instead of a + blank card + ### Info - app/host/protocol versions + open logs - **Report a problem** row: one action opens the GitHub bug issue form in @@ -776,11 +800,11 @@ system while preserving their different data ownership: 2. Rail shows the search pill at the top, the back-to-app action pinned at the foot on the main sidebar's footer icon line, and exactly General / 常规, AI, Shortcuts / 快捷键, Instructions / 指令, Models / 模型, Skills / 技能, MCP, - Subagents / 子智能体, Import / 导入, Projects / 项目, Cloud sync / 云同步, - Remote Hosts / 远程主机, and Info / 信息 in that order. Cloud sync / 云同步 - and Remote Hosts / 远程主机 appear only while developer mode is on. The rows are grouped under Preferences / 偏好, - Agent / 智能体, Workspace / 工作区, and System / 系统. There is no - Usage / 用量 destination. + Subagents / 子智能体, Import / 导入, Projects / 项目, Index / 索引, Cloud sync / + 云同步, and Info / 信息 in that order. Cloud sync / 云同步 + and Remote Hosts / 远程主机 appear only while developer mode is on. The rows are grouped + under Preferences / 偏好, Agent / 智能体, Workspace / 工作区, and + System / 系统. There is no Usage / 用量 destination. 3. Appearance is part of General and has no standalone rail destination 4. Providers is part of Agent and has no standalone rail destination 5. Plugins has no Settings destination; the app-shell Plugins page supports diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 87f50bd4f7..5b0de256f4 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -1958,6 +1958,66 @@ identify the platform validation still needed. - **Milestone**: M2 - **Status**: Draft +### Workspace Index + +#### E2E-INDEX-status-rebuild-clear: isolated index lifecycle stays workspace-scoped + +- **Preconditions**: A debug host-core binary is available; the harness can create + temporary data, workspace, and outside directories. +- **Steps**: 1) Start host-core with an isolated `PI_DESKTOP_DATA_DIR`. 2) Set the + temporary workspace. 3) Confirm `index.status` is empty. 4) Call + `index.rebuild` and confirm one fixture file is indexed. 5) Attempt to rebuild + an outside directory. 6) Clear the workspace root and read status again. +- **Expected**: Rebuild produces one `fresh` root; status exposes metadata but no + file content; the outside root fails with `INDEX_ROOT_OUTSIDE_WORKSPACE`; + clear removes exactly the selected root and leaves status empty. All index + files live under the temporary data directory and are removed by harness + cleanup. Grep results stay identical whether or not the cache exists. +- **Specs linked**: `03-runtime/04-data-storage.md`, + `03-runtime/06-host-rpc-protocol.md` +- **Acceptance**: C (workspace tools and boundaries), Quality (data safety) +- **Milestone**: M6+ +- **Status**: Automated by `pnpm test:e2e:index` + +#### E2E-INDEX-settings-health-card: index health card reflects host lifecycle + +- **Preconditions**: The app is running with an active workspace. The Index + destination is available under the Workspace settings group. +- **Steps**: 1) Open Settings → Index. 2) Observe the empty state for a + workspace without an index. 3) Activate Build index and wait for the card to + refresh. 4) Activate Clear index. 5) Optionally point the window at a + workspace whose index is over budget or partial. +- **Expected**: The card shows the host-reported status, indexed file count, + indexed size, unreadable-file count when non-zero, and last-update time. + Build and Clear call the host lifecycle RPCs and refresh from the returned + status. The copy describes the index as a rebuildable local cache and never + claims Grep reads it. Load failure shows a retry action instead of a blank + card. Grep results stay unchanged throughout. +- **Specs linked**: `04-ux/06-settings-ia.md`, + `03-runtime/06-host-rpc-protocol.md`, `03-runtime/04-data-storage.md` +- **Acceptance**: D (workspace), Quality (local data safety) +- **Milestone**: M6+ +- **Status**: Documented; the RPC lifecycle is covered by + `pnpm test:e2e:index`, UI automation is pending + +#### E2E-INDEX-auto-index: switching workspaces builds the index only when switched on + +- **Preconditions**: Host RPC available; two temporary workspaces; the switch + at its default. +- **Steps**: 1) `workspace.set` to workspace A with the switch off and read + `index.status`. 2) Turn `indexGrepBoost` on. 3) `workspace.set` to + workspace B and poll `index.status`. +- **Expected**: With the switch off, status stays empty. With it on, + the changed workspace is marked `building` immediately and a background + rebuild lands it at `fresh` with the fixture file counted; `workspace.set` + returns promptly without waiting for the scan. +- **Specs linked**: `03-runtime/06-host-rpc-protocol.md`, + `04-ux/06-settings-ia.md` +- **Acceptance**: D (workspace), Quality (responsiveness) +- **Milestone**: M6+ +- **Status**: Automated at the RPC boundary in host-core + (`workspace_set_auto_indexes_only_while_the_switch_is_on`) + ### Workspace Open #### E2E-012: Open a project directory @@ -8518,7 +8578,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | B — Model config | E2E-005, E2E-006, E2E-007, E2E-038, E2E-050, E2E-052, E2E-055, E2E-066, E2E-080, E2E-082, E2E-102c, E2E-102d, E2E-102e, E2E-151, E2E-154, E2E-163, E2E-166, E2E-172, E2E-174, E2E-197, E2E-005G, E2E-005J, E2E-199, E2E-201, E2E-202, E2E-203, E2E-205, E2E-206, E2E-209 | | C — Conversation & stream | E2E-CHAT-running-status-survives-output-pauses, E2E-008, E2E-008d, E2E-008e, E2E-008a, E2E-009, E2E-010, E2E-011, E2E-011a, E2E-011b, E2E-011d, E2E-011e, E2E-011g, E2E-031, E2E-040, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-052, E2E-053, E2E-054, E2E-055, E2E-059, E2E-059a, E2E-060c, E2E-060d, E2E-061, E2E-061a, E2E-062, E2E-064, E2E-065, E2E-068, E2E-071, E2E-073, E2E-074, E2E-075, E2E-081, E2E-083, E2E-084, E2E-086, E2E-087, E2E-088, E2E-088b, E2E-089, E2E-090, E2E-COMPOSER-narrow-controls, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102g, E2E-106, E2E-109, E2E-111, E2E-114, E2E-116, E2E-117, E2E-118, E2E-119, E2E-120, E2E-121, E2E-218, E2E-259, E2E-219, E2E-AGENTS-001, E2E-142, E2E-144, E2E-145, E2E-146, E2E-146a, E2E-147, E2E-151, E2E-154, E2E-155, E2E-158, E2E-159, E2E-161, E2E-162, E2E-166, E2E-172, E2E-173, E2E-174, E2E-177, E2E-178, E2E-179, E2E-180, E2E-182, E2E-183, E2E-187, E2E-198, E2E-199, E2E-202, E2E-203, E2E-207, E2E-208, E2E-CHAT-content-width-handles, E2E-250, E2E-102i, E2E-PLUGIN-session-orchestrator-real-workers, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-SUBAGENT-resume-a-settled-delegation | | C — Conversation & stream (composer drafts) | E2E-011c, E2E-011c-1 | -| D — Workspace | E2E-012, E2E-013, E2E-022B, E2E-024I, E2E-047, E2E-049, E2E-057, E2E-058, E2E-060, E2E-068, E2E-075, E2E-078, E2E-153, E2E-158, E2E-182, E2E-187, E2E-252 | +| D — Workspace | E2E-INDEX-status-rebuild-clear, E2E-INDEX-settings-health-card, E2E-012, E2E-013, E2E-022B, E2E-024I, E2E-047, E2E-049, E2E-057, E2E-058, E2E-060, E2E-068, E2E-075, E2E-078, E2E-153, E2E-158, E2E-182, E2E-187, E2E-252 | | D — Workspace (project ordering) | E2E-253 | | E — Tools & permissions | E2E-008a, E2E-014, E2E-015, E2E-016, E2E-017, E2E-018, E2E-019, E2E-024I, E2E-024K, E2E-040, E2E-049, E2E-074, E2E-093, E2E-097, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102d, E2E-102e, E2E-102g, E2E-103, E2E-105, E2E-106, E2E-107, E2E-111, E2E-112, E2E-113, E2E-114, E2E-115, E2E-116, E2E-119, E2E-121, E2E-122, E2E-142, E2E-145, E2E-147, E2E-155, E2E-158, E2E-166, E2E-181, E2E-PLUGIN-imported-pi-package-skills | | F — Persistence | E2E-020, E2E-021, E2E-021a, E2E-036, E2E-037, E2E-038, E2E-040, E2E-042, E2E-047, E2E-048, E2E-051, E2E-054, E2E-056, E2E-061, E2E-062, E2E-064, E2E-066, E2E-068, E2E-071, E2E-072, E2E-073, E2E-082, E2E-084, E2E-096, E2E-098, E2E-102, E2E-102b, E2E-102c, E2E-102d, E2E-102g, E2E-102i, E2E-103, E2E-AGENTS-001, E2E-061a, E2E-073a, E2E-104, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-112, E2E-118, E2E-119, E2E-120, E2E-121, E2E-123, E2E-142, E2E-146, E2E-146a, E2E-148, E2E-151, E2E-158, E2E-160, E2E-168, E2E-171, E2E-177, E2E-178, E2E-183, E2E-186, E2E-005J, E2E-PLUGIN-session-orchestrator-real-workers | @@ -8526,7 +8586,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | G — Plugins | E2E-022, E2E-022A, E2E-022B, E2E-022C, E2E-023, E2E-024, E2E-024B, E2E-024C, E2E-024D, E2E-024AA, E2E-024E, E2E-024W, E2E-024F, E2E-024G, E2E-024H, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M, E2E-024N, E2E-024O, E2E-024P, E2E-025, E2E-026, E2E-105, E2E-117, E2E-120, E2E-122, E2E-123, E2E-024Q, E2E-148, E2E-152, E2E-153, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-imported-pi-package-wrapper, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-global-shortcut-owns-only-its-own-command, E2E-PLUGIN-permission-gate-for-real-time-capabilities, E2E-PLUGIN-background-audio-and-realtime-connection, E2E-PLUGIN-fs-root-follows-the-calling-session | | H — Diagnostics | E2E-027, E2E-031, E2E-034, E2E-042, E2E-096, E2E-098, E2E-104, E2E-107, E2E-108, E2E-109, E2E-110, E2E-113, E2E-115, E2E-116, E2E-118, E2E-121, E2E-146, E2E-146a, E2E-155, E2E-159, E2E-176, E2E-194, E2E-195 | | Security | E2E-028, E2E-029, E2E-030, E2E-024J, E2E-024K, E2E-024M, E2E-049, E2E-068, E2E-086, E2E-102c, E2E-102d, E2E-102e, E2E-105, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-112, E2E-113, E2E-115, E2E-116, E2E-117, E2E-119, E2E-121, E2E-122, E2E-123, E2E-142, E2E-148, E2E-151, E2E-153, E2E-158, E2E-187, E2E-196c, E2E-196b, E2E-196, E2E-PLUGIN-fs-root-follows-the-calling-session | -| Quality | E2E-CHAT-running-status-survives-output-pauses, E2E-032, E2E-033, E2E-039, E2E-043, E2E-044, E2E-045, E2E-046, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-050, E2E-053, E2E-055, E2E-056, E2E-057, E2E-058, E2E-059, E2E-060, E2E-061, E2E-062, E2E-063, E2E-064, E2E-065, E2E-066, E2E-067, E2E-068, E2E-069, E2E-070, E2E-071, E2E-072, E2E-073, E2E-074, E2E-075, E2E-076, E2E-077, E2E-078, E2E-079, E2E-080, E2E-081, E2E-082, E2E-083, E2E-084, E2E-085, E2E-086, E2E-092, E2E-093, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102e, E2E-103, E2E-AGENTS-001, E2E-021a, E2E-024N, E2E-059a, E2E-060b, E2E-060c, E2E-061a, E2E-073a, E2E-111, E2E-114, E2E-117, E2E-118, E2E-119, E2E-120, E2E-122, E2E-123, E2E-142, E2E-143, E2E-144, E2E-145, E2E-146, E2E-147, E2E-148, E2E-150, E2E-151, E2E-153, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-168, E2E-172, E2E-173, E2E-174, E2E-011g, E2E-176, E2E-177, E2E-178, E2E-179, E2E-180, E2E-181, E2E-182, E2E-183, E2E-186, E2E-187, E2E-194, E2E-195, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-196, E2E-201, E2E-204, E2E-202, E2E-203, E2E-205, E2E-206, E2E-207, E2E-208, E2E-209, E2E-210, E2E-218, E2E-259, E2E-219, E2E-250, E2E-252, E2E-102i, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-SUBAGENT-resume-a-settled-delegation | +| Quality | E2E-INDEX-status-rebuild-clear, E2E-INDEX-settings-health-card, E2E-CHAT-running-status-survives-output-pauses, E2E-032, E2E-033, E2E-039, E2E-043, E2E-044, E2E-045, E2E-046, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-050, E2E-053, E2E-055, E2E-056, E2E-057, E2E-058, E2E-059, E2E-060, E2E-061, E2E-062, E2E-063, E2E-064, E2E-065, E2E-066, E2E-067, E2E-068, E2E-069, E2E-070, E2E-071, E2E-072, E2E-073, E2E-074, E2E-075, E2E-076, E2E-077, E2E-078, E2E-079, E2E-080, E2E-081, E2E-082, E2E-083, E2E-084, E2E-085, E2E-086, E2E-092, E2E-093, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102e, E2E-103, E2E-AGENTS-001, E2E-021a, E2E-024N, E2E-059a, E2E-060b, E2E-060c, E2E-061a, E2E-073a, E2E-111, E2E-114, E2E-117, E2E-118, E2E-119, E2E-120, E2E-122, E2E-123, E2E-142, E2E-143, E2E-144, E2E-145, E2E-146, E2E-147, E2E-148, E2E-150, E2E-151, E2E-153, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-168, E2E-172, E2E-173, E2E-174, E2E-011g, E2E-176, E2E-177, E2E-178, E2E-179, E2E-180, E2E-181, E2E-182, E2E-183, E2E-186, E2E-187, E2E-194, E2E-195, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-196, E2E-201, E2E-204, E2E-202, E2E-203, E2E-205, E2E-206, E2E-207, E2E-208, E2E-209, E2E-210, E2E-218, E2E-259, E2E-219, E2E-250, E2E-252, E2E-102i, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-SUBAGENT-resume-a-settled-delegation | | Quality (project ordering) | E2E-253 | | C — Conversation & stream (IME slash alias) | E2E-255 | | E — Tools & permissions (Skill residency) | E2E-254 | diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index 87d891a6aa..18a0475fce 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -38,6 +38,7 @@ | `menu` | 列入许可名单的应用程序菜单命令和本机 editing/window 操作 | | `notification` | 持久收件箱 list/read/clear 和 new/activated 事件 | | `stats` | 已完成回合的 token 历史(host RPC;仪表板由插件拥有) | +| `index` | 工作区索引生命周期(host RPC;在 `indexGrepBoost` 开关开启时构建并报告本地缓存) | ## 3. 通道约定 @@ -931,6 +932,18 @@ sidecar 用于显示每秒输出令牌的流时间。 `ToolTokenUsage` (`%G-W%V`)。结果会填充范围内的空桶。此通道不是设置页面;面向用户的仪表板 是插件 `pi.token-insights`(D335 / ADR 0173)。 +### index + +- `pi-desktop/index/status({ rootPath? }) -> { roots: WorkspaceIndexRoot[] }` +- `pi-desktop/index/rebuild({ rootPath? }) -> { root: WorkspaceIndexRoot }` +- `pi-desktop/index/clear({ rootPath? }) -> { ok, cleared }` + +host 所有的工作区索引缓存生命周期通道。`rootPath` 可选;提供时必须等于当前工作区 +(否则返回 `INDEX_ROOT_OUTSIDE_WORKSPACE`)。`rebuild` 在固定的文件数与字节预算下 +把工作区扫描进 `/index/index.db` 并返回 root 状态。`status` 只返回生命周期 +元数据,绝不返回文件内容。该缓存仅在 `indexGrepBoost` 开关开启时才会构建; +任何工具都不读取它。 + ## 8. 设置/秘密 API ### settings diff --git a/docs/zh-CN/spec/03-runtime/04-data-storage.md b/docs/zh-CN/spec/03-runtime/04-data-storage.md index fd210ac79c..773c37b2e7 100644 --- a/docs/zh-CN/spec/03-runtime/04-data-storage.md +++ b/docs/zh-CN/spec/03-runtime/04-data-storage.md @@ -59,6 +59,9 @@ host-core 之前被解析为绝对路径。 ├── plugins/ # code + data + registry.json (unchanged, spec 07-11) ├── logs/ # NDJSON app/, host/, agent/ logs ├── cache/ # disposable caches + ├── index/ # 可重建的工作区搜索缓存(仅 host-core) + │ ├── index.db # root 元数据、文件元数据与 FTS5 文本 + │ └── index.db.corrupt- # 完整性/架构失败后隔离的缓存 ├── crash-dumps/ # local Crashpad minidumps (never uploaded; D602) ├── crash-dumps.json # last-reported dump mtime (best-effort marker) ├── review-changes/// @@ -78,6 +81,13 @@ host-core 之前被解析为绝对路径。 [16-tool-result-limits](/zh-CN/spec/03-runtime/16-tool-result-limits) 存在于磁盘上,已引用 由 path/hash 提供。 +`index/index.db` 刻意与 `pi.sqlite` 分离。它是可丢弃、可重建的优化缓存, +永远不是文件系统事实来源。它只保存规范化 root/相对路径、文件大小与修改时间、 +生命周期/错误元数据,以及 FTS5 中的索引文本;不保存凭据、消息历史、项目实体记录或文件 hash。 +它被排除在应用备份、导出与同步之外;完整性检查失败或索引架构版本不受支持时, +旧缓存会被隔离并创建新的空索引,且不会触碰 `pi.sqlite`。索引 RPC 只涉及生命周期 +(`status`/`rebuild`/`clear`);任何工具都不读取该缓存。 + ### 2.0 消息拥有的评论快照 (ADR 0043) 成功的工作区 `PRAGMA user_version`/Plan/Goal 工具结果携带有界 diff --git a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md index c61da3f3b0..af1e6d4b7b 100644 --- a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md @@ -185,6 +185,19 @@ type ToolBudgetHealth = { - `workspace.set` - `workspace.clear` +### 工作区索引 +- `index.status({rootPath?})` 返回 host 所有、可丢弃的工作区索引生命周期状态,不暴露文件内容。 +- `index.rebuild({rootPath?})` 将所选工作区扫描进独立的 + `/index/index.db` 缓存。省略 `rootPath` 时使用当前工作区;操作受固定文件数和字节预算限制。 +- `indexGrepBoost` 开启时,`workspace.set` 会对变更的工作区触发后台 +`ensure_index` + 重建;关闭时,切换工作区绝不触碰索引。 +`index.clear({rootPath?})` 删除当前工作区的 root namespace;提供 `rootPath` 时必须与当前工作区一致,省略时也选择当前工作区。 + +索引数据库是可重建的优化缓存,不是文件系统事实来源。任何工具都不读取它: +无论索引是否存在,`tools.execute` 的行为完全一致,索引 RPC 是该缓存唯一的消费方。 +root 状态包括 `fresh`、`building`、`stale`、`failed`、`partial`、 +`disabled`、`skipped_over_limit`。 + ### 查看快照 (ADR 0043) - `review.rollback({sessionId, snapshotId})` — 验证当前的后期工具 hash,恢复会话拥有的先前字节,并返回其中之一 diff --git a/docs/zh-CN/spec/03-runtime/08-error-codes.md b/docs/zh-CN/spec/03-runtime/08-error-codes.md index 407ca05161..e16ca8affe 100644 --- a/docs/zh-CN/spec/03-runtime/08-error-codes.md +++ b/docs/zh-CN/spec/03-runtime/08-error-codes.md @@ -119,6 +119,9 @@ stdio 与 Tokio 的动态阻塞池隔离,因此后一种情况 |---|---|---| | `WORKSPACE_REQUIRED` | 不 | 无工作空间限制 | | `PATH_OUTSIDE_WORKSPACE` | 不 | 在显式外部路径权限决策之前路径逃逸沙箱,或提示词附件位于其会话 scratch/project/attachment 根目录之外 | +| `INDEX_UNAVAILABLE` | 不 | 索引存储无法打开,或重建工作线程失败 | +| `INDEX_ROOT_OUTSIDE_WORKSPACE` | 不 | index.status/rebuild/clear 传入了与当前活动工作区根不一致的 root | +| `INDEX_REBUILD_FAILED` | 不 | 后台索引重建中途失败;该 root 标记为 failed,可再次重建 | | `WORKSPACE_PATH_DENIED` | 不 | 显式的 `Read`/`Write`/`Edit` 路径命中了始终开启的安全拒绝名单(私钥、`.env` 文件、凭证包、`.git/objects`);外部路径授权不会解除它(规格 15 §3) | | `READ_PATH_IS_DIRECTORY` | 不 | `Read` 拿到的是目录;结果附带一条 `Glob` 建议 | | `TOOL_BINARY_CONTENT` | 不 | `Read` 拒绝把二进制文件倾倒进模型上下文 | diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md index bfbccfdf73..a66a5f4608 100644 --- a/docs/zh-CN/spec/04-ux/06-settings-ia.md +++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md @@ -35,14 +35,15 @@ 8. **子智能体** — Lucide `Bot`(内置与自建的并行工作智能体) 9. **导入** — Lucide `Download`(从其他工具引入会话和模型配置) 10. **项目** — Lucide `Archive`(持久项目索引) - 11. **云同步** — Lucide `CloudDownload`(加密的可移植配置备份与双向同步;仅开发者模式) - 12. **远程主机** — Lucide `Globe`(SSH 引导与配对清单;仅开发者模式) - 13. **信息** — Lucide `Info`(版本、日志、更新、开发人员) + 11. **索引** — Lucide `Database`(索引库:本地索引健康与生命周期) + 12. **云同步** — Lucide `CloudDownload`(加密的可移植配置备份与双向同步;仅开发者模式) + 13. **远程主机** — Lucide `Globe`(SSH 引导与配对清单;仅开发者模式) + 14. **信息** — Lucide `Info`(版本、日志、更新、开发人员) 图标具有装饰性(通过 SVG 默认设置为 `aria-hidden`)并保持单色 带有导轨标签;不要在此处重复使用 refresh/rotate 字形。 - 目录仍是保持相同顺序的可搜索扁平列表。为便于扫描,目的地分为四个带标题 的视觉分组:“偏好”(常规、AI、快捷键)、“智能体”(指令、模型、技能、 - MCP、子智能体)、“工作区”(导入、项目)和“系统”(云同步、远程主机、信息;前两项仅开发者模式可见)。标题使用柔和 + MCP、子智能体)、“工作区”(导入、项目、索引)和“系统”(云同步、远程主机、信息;前两项仅开发者模式可见)。标题使用柔和 的非交互文字,分组之间只使用留白,不绘制分割线;搜索过滤后,空分组及其 标题一并隐藏。 - **云同步**是仅开发者可用的实验性目的地:其导轨行、页面和设置搜索命中仅在 @@ -308,6 +309,22 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历 打开该项目后按最新活动列出匹配会话,以 8 条一批显示,而不是截断历史 - 激活项目或项目会话返回聊天;归档和关闭仍使项目归档页保持打开 +### 索引库(`index` 选项卡,`工作区` 分组) +- 单张健康卡展示 host 所有的工作区索引缓存:状态、已索引文件数、已索引体积、 + 无法读取的文件数、最近更新时间 +- 健康卡下方有一个独立的「代码库」小节,承载唯一的开关「工作区索引」 + (`indexGrepBoost`,默认关)。开启后,切换到不同工作区会把其索引标记为 `building` + 并在阻塞线程池中重建,因此 `workspace.set` 保持快速;健康卡在构建期间每秒轮询 + 一次 `index.status`,窗口隐藏时暂停。任何工具都不读取该索引:它只在本页被查询、 + 重建与清理。再加一个「索引新文件夹」开关要么与它重复,要么建出无人使用的索引, + 因此该小节只保留一个 +- 操作为「重建索引」与「清理索引」。两者都调用 host 生命周期 RPC 并用返回状态刷新 + 卡片。重建只在开关打开时提供——开关不喂它的话,索引就只是白扫一遍盘、白占一份盘; + 清理始终可用,好让遗留索引仍能被删掉 +- 文案说明索引是可重建的本地缓存,数据不会离开本机 +- 空态:当前工作区还没有 root,只提供「建立索引」动作,并受同一条开关门控。 + 加载失败显示重试而不是空白卡片 + ### 云同步 - **连接**:提供 WebDAV URL、用户名、应用密码、远程目录、设备标签、独立的备份/vault 密码和服务器兼容模式。默认使用严格 CAS;能力测试只使用临时远端对象。选择追加式兼容模式时显示持续风险提示,并在保存前要求确认。 @@ -365,7 +382,7 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历 1.打开设置隐藏编码应用侧边栏(全页接管) 2. 导轨顶部显示搜索药丸,底部固定返回应用程序操作并与主侧边栏底部图标行同一条线, 并精确显示常规、AI、快捷键、指令、模型、技能、MCP、 - 子智能体、导入、项目和信息(开发者模式开启时,云同步与远程主机依次位于项目与信息之间), + 子智能体、导入、项目、索引和信息(开发者模式开启时,云同步与远程主机依次位于索引与信息之间), 并按偏好、智能体、工作区、系统分组。没有 用量设置目的地。 3.外观是常规的一部分,没有独立的导轨目的地 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 31fe676c59..f561052aac 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -857,6 +857,49 @@ task-candidate E2E 从请求工作树运行,但使用主工作区已经准备 - **里程碑**:M2 - **状态**:草案 +### 工作区索引 + +#### E2E-INDEX-status-rebuild-clear:隔离索引生命周期仅限当前工作区 + +- **前置条件**:debug host-core 可用;测试框架可创建临时 data、workspace 和 outside 目录。 +- **步骤**:1)以隔离的 `PI_DESKTOP_DATA_DIR` 启动 host-core。2)设置临时工作区。 + 3)确认 `index.status` 为空。4)调用 `index.rebuild`,确认索引一个 fixture 文件。 + 5)尝试重建工作区外目录。6)清理工作区 root,再次读取状态。 +- **预期**:重建产生一个 `fresh` root;状态仅暴露元数据、不暴露文件内容; + 工作区外 root 返回 `INDEX_ROOT_OUTSIDE_WORKSPACE`;clear 只删除所选 root,状态随后为空。 + 所有索引文件都位于临时 data 目录并由测试框架清理。 +- **关联规格**:`03-runtime/04-data-storage.md`、`03-runtime/06-host-rpc-protocol.md` +- **验收**:C(工作区工具与边界)、质量(数据安全) +- **里程碑**:M6+ +- **状态**:由 `pnpm test:e2e:index` 自动化 + +#### E2E-INDEX-settings-health-card:索引健康卡反映 host 生命周期 + +- **前置条件**:应用正在运行且存在活动工作区;设置的工作区分组下有「索引」目的地。 +- **步骤**:1)打开 设置 → 索引。2)观察无索引工作区的空态。3)点击「建立索引」 + 并等待卡片刷新。4)点击「清理索引」。5)可选:把窗口指向超预算或部分失败的索引工作区。 +- **预期**:卡片显示 host 返回的状态、已索引文件数、已索引体积、非零时的无法读取文件数 + 与最近更新时间。建立与清理都会调用 host 生命周期 RPC 并用返回状态刷新。文案把索引 + 描述为可重建的本地缓存,绝不声称 Grep 读取它。加载失败显示重试而不是空白卡片。 +- **关联规格**:`04-ux/06-settings-ia.md`、`03-runtime/06-host-rpc-protocol.md`、 + `03-runtime/04-data-storage.md` +- **验收**:D(工作区)、质量(本地数据安全) +- **里程碑**:M6+ +- **状态**:已记录;RPC 生命周期由 `pnpm test:e2e:index` 覆盖,UI 自动化待补 + +#### E2E-INDEX-auto-index:仅在开关开启时随工作区切换建立索引 + +- **前置条件**:host RPC 可用;两个临时工作区;开关为默认值。 +- **步骤**:1)开关关闭时 `workspace.set` 到工作区 A 并读取 `index.status`。 + 2)开启 `indexGrepBoost`。3)`workspace.set` 到工作区 B 并轮询 `index.status`。 +- **预期**:开关关闭时状态保持为空。开关开启后,变更的工作区立即标记 `building`, + 后台重建最终落到 `fresh` 并统计到 fixture 文件;`workspace.set` 立即返回、不等待扫描。 +- **关联规格**:`03-runtime/06-host-rpc-protocol.md`、`04-ux/06-settings-ia.md` +- **验收**:D(工作区)、质量(响应性) +- **里程碑**:M6+ +- **状态**:host-core RPC 边界已自动化 + (`workspace_set_auto_indexes_only_while_the_switch_is_on`) + ### 工作区打开 #### E2E-012:打开项目目录 diff --git a/package.json b/package.json index 53749ac50b..782e12f26e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test": "pnpm build:js && pnpm -r --if-present test && cargo test -p host-core", "test:host": "cargo test -p host-core", "test:e2e": "node scripts/e2e-smoke.mjs", + "test:e2e:index": "node scripts/e2e-index.mjs", "test:e2e:scheduled": "node scripts/e2e-scheduled.mjs", "test:e2e:keep-awake": "node scripts/e2e-keep-awake.mjs", "test:e2e:rpc-unicode": "node scripts/e2e-rpc-unicode.mjs", diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 7cc77819e3..a90574375d 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -705,6 +705,7 @@ sklm: { "subagents": "Subagenten", "import": "Importieren Sie", "projects": "Projekte", + "index": "Index", "sync": "Cloud-Synchronisierung", "remoteHosts": "Remote-Hosts", "info": "Informationen", @@ -1096,6 +1097,7 @@ sklm: { "subagentSaved": "Gespeichert {{name}}", "import": "Importieren", "projectArchive": "Projektarchiv", + "index": "Indexbibliothek", "remoteHosts": { "title": "Remote-Hosts", "listError": "Hosts konnten nicht geladen werden", @@ -2451,7 +2453,53 @@ sklm: { "continue": "Weiter", "dismiss": "Verwerfen" } - } + }, + "index": { + "loading": "Index-Status wird geladen…", + "grepBoost": "Workspace-Indizierung", + "grepBoostDesc": "Solange dieser Schalter aktiviert ist, werden neu geöffnete Workspaces im Hintergrund indexiert, und diese Seite zeigt deren Status. Alle Daten bleiben auf diesem Computer.", + "loadErrorTitle": "Index-Status nicht verfügbar", + "loadErrorDesc": "Der Host hat nicht geantwortet.", + "retry": "Erneut versuchen", + "statusDesc": "Der Index ist ein lokal neu aufbaubarer Zwischenspeicher.", + "indexSubtitle": "Der lokale Index ist ein neu aufbaubarer Zwischenspeicher; diese Seite zeigt Status und Zustand.", + "nudgeText": "Alles läuft lokal und lädt niemals Workspace-Inhalte hoch.", + "nudgeDismiss": "Verstanden", + "sectionCode": "Codebasis", + "progressFiles": "{{done}} / {{total}} Dateien", + "progressFallback": "Der Index wird im Hintergrund aufgebaut; Sie können weiterarbeiten.", + "emptyTitle": "Kein Index für den aktuellen Arbeitsbereich", + "emptyDesc": "Erstellen Sie einen, um Dateianzahl und Größe zu sehen. Der Index bleibt auf diesem Computer.", + "actions": "Index-Aktionen", + "actionsDesc": "Erstellt oder aktualisiert den Index des aktuellen Workspaces. Bei deaktiviertem Schalter ist das nicht möglich, weil nichts ihn verwenden würde.", + "localOnly": "Alles wird lokal gespeichert und nie hochgeladen.", + "rebuilding": "Wird indiziert…", + "clearing": "Wird gelöscht…", + "actionError": "Die Indexaktion ist fehlgeschlagen. Sie können es erneut versuchen.", + "card": { + "health": "Index-Status", + "status": "Status", + "files": "Indizierte Dateien", + "size": "Indexgröße", + "errors": "Unlesbare Dateien", + "updated": "Zuletzt aktualisiert", + }, + "action": { + "rebuild": "Index neu aufbauen", + "build": "Index aufbauen", + "clear": "Index löschen", + }, + "status": { + "fresh": "Bereit", + "building": "Wird aufgebaut", + "stale": "Aktualisiert", + "failed": "Fehlgeschlagen", + "partial": "Teilweise", + "disabled": "Aus", + "skipped_over_limit": "Budget überschritten", + }, + }, } satisfies EnglishCatalog; + export default de; diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index a4956f41a0..1dc46cba18 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -712,6 +712,7 @@ sklm: { subagents: "Subagents", import: "Import", projects: "Projects", + index: "Index", sync: "Cloud sync", remoteHosts: "Remote hosts", info: "Info", @@ -944,6 +945,7 @@ sklm: { subagentSaved: "Saved {{name}}", import: "Import", projectArchive: "Project archive", + index: "Index library", remoteHosts: { title: "Remote hosts", listError: "Failed to load hosts", @@ -2496,6 +2498,51 @@ importConfirm: "Imported extensions run inside the agent process with the same a dismiss: "Dismiss", }, }, + index: { + loading: "Loading index status…", + grepBoost: "Workspace indexing", + grepBoostDesc: "While this switch is on, newly opened workspaces are indexed in the background and this page reports their status. Everything stays on this machine.", + loadErrorTitle: "Index status unavailable", + loadErrorDesc: "The host did not answer.", + retry: "Retry", + statusDesc: "The index is a rebuildable local cache.", + indexSubtitle: "The local index is a rebuildable cache; this page reports its status and health.", + nudgeText: "Everything runs locally and never uploads your workspace content.", + nudgeDismiss: "Dismiss", + sectionCode: "Codebase", + progressFiles: "{{done}} / {{total}} files", + progressFallback: "The index builds in the background; you can keep working.", + emptyTitle: "No index for the current workspace", + emptyDesc: "Build one to see file counts and size. The index stays on this machine.", + actions: "Index actions", + actionsDesc: "Build or rebuild the index for the current workspace. It cannot be built while the switch is off, because nothing would use it.", + localOnly: "Everything is stored locally and never uploaded.", + rebuilding: "Indexing…", + clearing: "Clearing…", + actionError: "The index action failed. You can retry.", + card: { + health: "Index health", + status: "Status", + files: "Indexed files", + size: "Indexed size", + errors: "Unreadable files", + updated: "Last updated", + }, + action: { + rebuild: "Rebuild index", + build: "Build index", + clear: "Clear index", + }, + status: { + fresh: "Ready", + building: "Building", + stale: "Updating", + failed: "Failed", + partial: "Partial", + disabled: "Off", + skipped_over_limit: "Over budget", + }, + }, } as const; type DeepStringify = { diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 2340e037e1..d282b9ce8b 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -705,6 +705,7 @@ sklm: { "subagents": "Subagentes", "import": "Importar", "projects": "Proyectos", + "index": "Índice", "sync": "Sincronización en la nube", "remoteHosts": "Hosts remotos", "info": "Información", @@ -1096,6 +1097,7 @@ sklm: { "subagentSaved": "Guardado {{name}}", "import": "Importar", "projectArchive": "Archivo de proyecto", + "index": "Biblioteca de índices", "remoteHosts": { "title": "Hosts remotos", "listError": "No se pudieron cargar los hosts", @@ -2451,7 +2453,53 @@ sklm: { "continue": "Continuar", "dismiss": "Descartar" } - } + }, + "index": { + "loading": "Cargando estado del índice…", + "grepBoost": "Indexación del espacio de trabajo", + "grepBoostDesc": "Mientras este interruptor esté activado, los espacios de trabajo recién abiertos se indexan en segundo plano y esta página muestra su estado. Todo se queda en este equipo.", + "loadErrorTitle": "Estado del índice no disponible", + "loadErrorDesc": "El host no respondió.", + "retry": "Reintentar", + "statusDesc": "El índice es una caché local reconstruible.", + "indexSubtitle": "El índice local es una caché reconstruible; esta página muestra su estado y salud.", + "nudgeText": "Todo se ejecuta localmente y nunca sube el contenido de tu espacio de trabajo.", + "nudgeDismiss": "Entendido", + "sectionCode": "Base de código", + "progressFiles": "{{done}} / {{total}} archivos", + "progressFallback": "El índice se construye en segundo plano; puede seguir trabajando.", + "emptyTitle": "Sin índice para el espacio actual", + "emptyDesc": "Cree uno para ver el número de archivos y el tamaño. El índice se queda en este equipo.", + "actions": "Acciones del índice", + "actionsDesc": "Crea o reconstruye el índice del espacio de trabajo actual. No se puede crear con el interruptor desactivado, porque nada lo usaría.", + "localOnly": "Todo se guarda localmente y nunca se sube.", + "rebuilding": "Indexando…", + "clearing": "Borrando…", + "actionError": "La acción del índice falló. Puede volver a intentarlo.", + "card": { + "health": "Estado del índice", + "status": "Estado", + "files": "Archivos indexados", + "size": "Tamaño indexado", + "errors": "Archivos ilegibles", + "updated": "Última actualización", + }, + "action": { + "rebuild": "Reconstruir índice", + "build": "Crear índice", + "clear": "Borrar índice", + }, + "status": { + "fresh": "Listo", + "building": "Construyendo", + "stale": "Actualizando", + "failed": "Error", + "partial": "Parcial", + "disabled": "Desactivado", + "skipped_over_limit": "Presupuesto excedido", + }, + }, } satisfies EnglishCatalog; + export default es; diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index f6ed924185..f772f9a301 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -705,6 +705,7 @@ sklm: { "subagents": "Sous-agents", "import": "Importation", "projects": "Projets", + "index": "Index", "sync": "Synchronisation cloud", "remoteHosts": "Hôtes distants", "info": "Informations", @@ -1096,6 +1097,7 @@ sklm: { "subagentSaved": "Enregistré {{name}}", "import": "Importation", "projectArchive": "Archive du projet", + "index": "Bibliothèque d'index", "remoteHosts": { "title": "Hôtes distants", "listError": "Impossible de charger les hôtes", @@ -2451,7 +2453,53 @@ sklm: { "continue": "Continuer", "dismiss": "Ignorer" } - } + }, + "index": { + "loading": "Chargement de l'état de l'index…", + "grepBoost": "Indexation de l'espace de travail", + "grepBoostDesc": "Lorsque cet interrupteur est activé, les espaces de travail récemment ouverts sont indexés en arrière-plan et cette page en affiche l'état. Tout reste sur cette machine.", + "loadErrorTitle": "État de l'index indisponible", + "loadErrorDesc": "L'hôte n'a pas répondu.", + "retry": "Réessayer", + "statusDesc": "L'index est un cache local reconstruisible.", + "indexSubtitle": "L'index local est un cache reconstruisible ; cette page en affiche l'état et la santé.", + "nudgeText": "Tout s'exécute localement et ne téléverse jamais le contenu de votre espace de travail.", + "nudgeDismiss": "Compris", + "sectionCode": "Base de code", + "progressFiles": "{{done}} / {{total}} fichiers", + "progressFallback": "L'index se construit en arrière-plan ; vous pouvez continuer à travailler.", + "emptyTitle": "Aucun index pour l'espace actuel", + "emptyDesc": "Créez-en un pour voir le nombre de fichiers et la taille. L'index reste sur cette machine.", + "actions": "Actions de l'index", + "actionsDesc": "Crée ou reconstruit l'index de l'espace de travail actuel. Impossible de le créer interrupteur désactivé, car rien ne l'utiliserait.", + "localOnly": "Tout est stocké localement et jamais envoyé.", + "rebuilding": "Indexation…", + "clearing": "Effacement…", + "actionError": "L'action d'index a échoué. Vous pouvez réessayer.", + "card": { + "health": "État de l'index", + "status": "État", + "files": "Fichiers indexés", + "size": "Taille indexée", + "errors": "Fichiers illisibles", + "updated": "Dernière mise à jour", + }, + "action": { + "rebuild": "Reconstruire l'index", + "build": "Créer l'index", + "clear": "Effacer l'index", + }, + "status": { + "fresh": "Prêt", + "building": "Construction", + "stale": "Mise à jour", + "failed": "Échec", + "partial": "Partiel", + "disabled": "Désactivé", + "skipped_over_limit": "Budget dépassé", + }, + }, } satisfies EnglishCatalog; + export default fr; diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 285b6e8002..f321f38b87 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -713,6 +713,7 @@ sklm: { subagents: "서브에이전트", import: "가져오기", projects: "프로젝트", + index: "인덱스", sync: "클라우드 동기화", remoteHosts: "원격 호스트", info: "정보", @@ -1104,6 +1105,7 @@ sklm: { subagentSaved: "{{name}} 저장됨", import: "가져오기", projectArchive: "프로젝트 보관함", + index: "인덱스 라이브러리", remoteHosts: { title: "원격 호스트", listError: "호스트를 불러오지 못했습니다", @@ -2491,6 +2493,51 @@ importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이 dismiss: "닫기", }, }, + index: { + loading: "인덱스 상태 불러오는 중…", + "grepBoost": "워크스페이스 색인", + "grepBoostDesc": "이 스위치가 켜져 있으면 새로 여는 워크스페이스가 백그라운드에서 색인되고 이 페이지에 그 상태가 표시됩니다. 모든 데이터는 이 컴퓨터에만 저장됩니다.", + loadErrorTitle: "인덱스 상태를 사용할 수 없음", + loadErrorDesc: "호스트가 응답하지 않습니다.", + retry: "다시 시도", + statusDesc: "색인은 다시 생성할 수 있는 로컬 캐시입니다.", + indexSubtitle: "로컬 색인은 다시 생성할 수 있는 캐시입니다. 이 페이지는 그 상태와 건강도를 표시합니다.", + nudgeText: "모든 처리는 로컬에서 이루어지며 워크스페이스 콘텐츠를 업로드하지 않습니다.", + nudgeDismiss: "확인", + sectionCode: "코드베이스", + progressFiles: "{{done}} / {{total}}개 파일", + progressFallback: "색인은 백그라운드에서 생성되며, 그동안 작업을 계속할 수 있습니다.", + emptyTitle: "현재 워크스페이스에 인덱스가 없습니다", + emptyDesc: "생성하면 파일 수와 크기를 볼 수 있습니다. 색인은 이 컴퓨터에만 저장됩니다.", + actions: "인덱스 작업", + actionsDesc: "현재 워크스페이스의 색인을 생성하거나 다시 생성합니다. 스위치가 꺼져 있으면 사용하는 기능이 없으므로 생성할 수 없습니다.", + localOnly: "모든 데이터는 로컬에 저장되며 업로드되지 않습니다.", + rebuilding: "색인 중…", + clearing: "삭제 중…", + actionError: "색인 작업이 실패했습니다. 다시 시도할 수 있습니다.", + card: { + health: "인덱스 상태", + status: "상태", + files: "색인된 파일", + size: "색인 크기", + errors: "읽을 수 없는 파일", + updated: "마지막 업데이트", + }, + action: { + rebuild: "인덱스 재생성", + build: "인덱스 생성", + clear: "인덱스 삭제", + }, + status: { + fresh: "준비됨", + building: "생성 중", + stale: "업데이트 중", + failed: "실패", + partial: "일부 실패", + disabled: "꺼짐", + skipped_over_limit: "예산 초과", + }, + }, } satisfies EnglishCatalog; export default ko; diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 1d6cfe0dc1..7647bb6ab6 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -690,6 +690,7 @@ export const ptBR = { subagents: "Subagentes", import: "Importar", projects: "Projetos", + index: "Índice", sync: "Sincronização", remoteHosts: "Hosts remotos", info: "Sobre", @@ -916,6 +917,7 @@ export const ptBR = { subagentSaved: "Salvo {{name}}", import: "Importar", projectArchive: "Arquivo de projeto", + index: "Biblioteca de índices", remoteHosts: { title: "Hosts remotos", listError: "Falha ao carregar hosts", @@ -2406,6 +2408,51 @@ export const ptBR = { continue: "Continuar", dismiss: "Dispensar" } + }, + index: { + loading: "Carregando status do índice…", + grepBoost: "Indexação do espaço de trabalho", + grepBoostDesc: "Com esta opção ativada, os espaços de trabalho recém-abertos são indexados em segundo plano e esta página informa o status. Tudo permanece nesta máquina.", + loadErrorTitle: "Status do índice indisponível", + loadErrorDesc: "O host não respondeu.", + retry: "Tentar novamente", + statusDesc: "O índice é um cache local reconstruível.", + indexSubtitle: "O índice local é um cache reconstruível; esta página informa o status e a saúde dele.", + nudgeText: "Tudo é executado localmente e nunca envia o conteúdo do seu espaço de trabalho.", + nudgeDismiss: "Dispensar", + sectionCode: "Base de código", + progressFiles: "{{done}} / {{total}} arquivos", + progressFallback: "O índice é construído em segundo plano; você pode continuar trabalhando.", + emptyTitle: "Nenhum índice para o espaço de trabalho atual", + emptyDesc: "Construa um para ver a contagem de arquivos e o tamanho. O índice permanece nesta máquina.", + actions: "Ações do índice", + actionsDesc: "Construa ou reconstrua o índice do espaço de trabalho atual. Ele não pode ser construído com a opção desativada, pois nada o utilizaria.", + localOnly: "Tudo é armazenado localmente e nunca enviado.", + rebuilding: "Indexando…", + clearing: "Limpando…", + actionError: "A ação do índice falhou. Você pode tentar novamente.", + card: { + health: "Saúde do índice", + status: "Status", + files: "Arquivos indexados", + size: "Tamanho indexado", + errors: "Arquivos ilegíveis", + updated: "Última atualização" + }, + action: { + rebuild: "Reconstruir índice", + build: "Construir índice", + clear: "Limpar índice" + }, + status: { + fresh: "Pronto", + building: "Construindo", + stale: "Atualizando", + failed: "Falhou", + partial: "Parcial", + disabled: "Desativado", + skipped_over_limit: "Acima do orçamento" + } } } satisfies EnglishCatalog; diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 1ef9cb64c0..e96251daee 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -713,6 +713,7 @@ sklm: { subagents: "Alt ajanlar", import: "İçe aktar", projects: "Projeler", + index: "Dizin", sync: "Bulut senkronizasyonu", remoteHosts: "Uzak ana bilgisayarlar", info: "Bilgi", @@ -1094,6 +1095,7 @@ sklm: { subagentSaved: "{{name}} kaydedildi", import: "İçe aktar", projectArchive: "Proje arşivi", + index: "Dizin kitaplığı", remoteHosts: { title: "Uzak ana bilgisayarlar", listError: "Ana bilgisayarlar yüklenemedi", @@ -2481,6 +2483,51 @@ importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araç dismiss: "Kapat", }, }, + index: { + loading: "Dizin durumu yükleniyor…", + "grepBoost": "Çalışma alanı dizinleme", + "grepBoostDesc": "Bu anahtar açıkken yeni açılan çalışma alanları arka planda dizinlenir ve bu sayfa durumlarını gösterir. Her şey bu makinede kalır.", + loadErrorTitle: "Dizin durumu kullanılamıyor", + loadErrorDesc: "Ana makine yanıt vermedi.", + retry: "Yeniden dene", + statusDesc: "Dizin yeniden oluşturulabilen yerel bir önbellektir.", + indexSubtitle: "Yerel dizin yeniden oluşturulabilen bir önbellektir; bu sayfa durumunu ve sağlığını gösterir.", + nudgeText: "Her şey yerel olarak çalışır ve çalışma alanı içeriğinizi asla yüklemez.", + nudgeDismiss: "Anladım", + sectionCode: "Kod tabanı", + progressFiles: "{{done}} / {{total}} dosya", + progressFallback: "Dizin arka planda oluşturulur; çalışmaya devam edebilirsiniz.", + emptyTitle: "Geçerli çalışma alanı için dizin yok", + emptyDesc: "Dosya sayısını ve boyutu görmek için bir tane oluşturun. Dizin bu makinede kalır.", + actions: "Dizin işlemleri", + actionsDesc: "Geçerli çalışma alanının dizinini oluşturur veya yeniden oluşturur. Anahtar kapalıyken oluşturulamaz, çünkü hiçbir şey onu kullanmazdı.", + localOnly: "Her şey yerel saklanır, asla yüklenmez.", + rebuilding: "Dizinleniyor…", + clearing: "Temizleniyor…", + actionError: "Dizin eylemi başarısız oldu. Yeniden deneyebilirsiniz.", + card: { + health: "Dizin durumu", + status: "Durum", + files: "Dizinlenen dosyalar", + size: "Dizin boyutu", + errors: "Okunamayan dosyalar", + updated: "Son güncelleme", + }, + action: { + rebuild: "Dizini yeniden oluştur", + build: "Dizin oluştur", + clear: "Dizini temizle", + }, + status: { + fresh: "Hazır", + building: "Oluşturuluyor", + stale: "Güncelleniyor", + failed: "Başarısız", + partial: "Kısmi", + disabled: "Kapalı", + skipped_over_limit: "Bütçe aşıldı", + }, + }, } satisfies EnglishCatalog; export default tr; diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index db3685e064..b007fb0a13 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -703,6 +703,7 @@ sklm: { subagents: "子智能体", import: "导入", projects: "项目", + index: "索引", sync: "云同步", remoteHosts: "远程主机", info: "信息", @@ -1080,6 +1081,7 @@ sklm: { subagentSaved: "已保存 {{name}}", import: "导入", projectArchive: "项目归档", + index: "索引库", remoteHosts: { title: "远程主机", listError: "加载失败", @@ -2443,6 +2445,51 @@ sklm: { dismiss: "关闭", }, }, + index: { + loading: "正在读取索引状态…", + "grepBoost": "工作区索引", + "grepBoostDesc": "开关打开时,新打开的工作区会在后台建立索引,本页显示其状态。所有数据均保存在本机。", + loadErrorTitle: "索引状态不可用", + loadErrorDesc: "主机未响应。", + retry: "重试", + statusDesc: "索引是可重建的本地缓存。", + indexSubtitle: "本地索引是可重建的缓存;本页显示其状态与健康度。", + nudgeText: "所有处理均在本地完成,不会上传工作区内容。", + nudgeDismiss: "知道了", + sectionCode: "代码库", + progressFiles: "{{done}} / {{total}} 个文件", + progressFallback: "索引在后台构建,期间可继续工作。", + emptyTitle: "当前工作区尚未建立索引", + emptyDesc: "建立索引后可查看文件数与体积。索引仅保存在本机。", + actions: "索引操作", + actionsDesc: "为当前工作区建立或重建索引。开关关闭时无法建立,因为没有功能会使用它。", + localOnly: "所有数据均存储在本地,不会上传。", + rebuilding: "正在索引…", + clearing: "正在清理…", + actionError: "索引操作失败。可以重试。", + card: { + health: "索引健康", + status: "状态", + files: "已索引文件", + size: "已索引体积", + errors: "无法读取的文件", + updated: "最近更新", + }, + action: { + rebuild: "重建索引", + build: "建立索引", + clear: "清理索引", + }, + status: { + fresh: "就绪", + building: "构建中", + stale: "更新中", + failed: "失败", + partial: "部分失败", + disabled: "已停用", + skipped_over_limit: "超出预算", + }, + }, } satisfies EnglishCatalog; export default zhCN; diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index b0719de566..22ec75635a 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -703,6 +703,7 @@ sklm: { subagents: "子智慧體", import: "匯入", projects: "專案", + index: "索引", sync: "雲端同步", remoteHosts: "遠端主機", info: "資訊", @@ -1080,6 +1081,7 @@ sklm: { subagentSaved: "已儲存 {{name}}", import: "匯入", projectArchive: "專案歸檔", + index: "索引庫", remoteHosts: { title: "遠端主機", listError: "載入失敗", @@ -2441,6 +2443,51 @@ sklm: { dismiss: "關閉", }, }, + index: { + loading: "正在讀取索引狀態…", + "grepBoost": "工作區索引", + "grepBoostDesc": "開關開啟時,新開啟的工作區會在背景建立索引,本頁顯示其狀態。所有資料均儲存在本機。", + loadErrorTitle: "索引狀態不可用", + loadErrorDesc: "主機未回應。", + retry: "重試", + statusDesc: "索引是可重建的本機快取。", + indexSubtitle: "本機索引是可重建的快取;本頁顯示其狀態與健康度。", + nudgeText: "所有處理均在本機完成,不會上傳工作區內容。", + nudgeDismiss: "知道了", + sectionCode: "程式碼庫", + progressFiles: "{{done}} / {{total}} 個檔案", + progressFallback: "索引在背景建置,期間可繼續工作。", + emptyTitle: "目前工作區尚未建立索引", + emptyDesc: "建立索引後可查看檔案數與大小。索引僅儲存在本機。", + actions: "索引操作", + actionsDesc: "為目前工作區建立或重建索引。開關關閉時無法建立,因為沒有功能會使用它。", + localOnly: "所有資料均儲存在本機,不會上傳。", + rebuilding: "正在索引…", + clearing: "正在清除…", + actionError: "索引操作失敗。可以重試。", + card: { + health: "索引健康", + status: "狀態", + files: "已索引檔案", + size: "已索引大小", + errors: "無法讀取的檔案", + updated: "最近更新", + }, + action: { + rebuild: "重建索引", + build: "建立索引", + clear: "清除索引", + }, + status: { + fresh: "就緒", + building: "建置中", + stale: "更新中", + failed: "失敗", + partial: "部分失敗", + disabled: "已停用", + skipped_over_limit: "超出預算", + }, + }, } satisfies EnglishCatalog; export default zhTW; diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index 02a097b3ff..01d244b387 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -92,6 +92,12 @@ export const ErrorCodes = { * delegate's model, or how much it reads at once has to change. */ SUBAGENT_CONTEXT_OVERFLOW: "SUBAGENT_CONTEXT_OVERFLOW", + /** index.status/rebuild/clear could not reach the index store. */ + INDEX_UNAVAILABLE: "INDEX_UNAVAILABLE", + /** The requested index root does not match the active workspace root. */ + INDEX_ROOT_OUTSIDE_WORKSPACE: "INDEX_ROOT_OUTSIDE_WORKSPACE", + /** A background index rebuild failed; Grep keeps working via rg. */ + INDEX_REBUILD_FAILED: "INDEX_REBUILD_FAILED", WORKSPACE_REQUIRED: "WORKSPACE_REQUIRED", PATH_OUTSIDE_WORKSPACE: "PATH_OUTSIDE_WORKSPACE", TOOL_NOT_FOUND: "TOOL_NOT_FOUND", diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 7313e9f1b9..e6b433ced3 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -332,6 +332,9 @@ export const IPC = { fsRead: "pi-desktop/fs/read", fsReadImageDataUrl: "pi-desktop/fs/readImageDataUrl", statsGetTokenUsageHistory: "pi-desktop/stats/getTokenUsageHistory", + indexStatus: "pi-desktop/index/status", + indexRebuild: "pi-desktop/index/rebuild", + indexClear: "pi-desktop/index/clear", fsReveal: "pi-desktop/fs/reveal", fsOpen: "pi-desktop/fs/open", fsIndex: "pi-desktop/fs/index", diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 402047cd70..75484edf48 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -9,6 +9,7 @@ export * from "./types/models.js"; export * from "./types/permissions.js"; export * from "./types/messages.js"; export * from "./types/sessions.js"; +export * from "./types/workspace-index.js"; export * from "./types/agent.js"; export * from "./types/workspace.js"; export * from "./types/providers.js"; diff --git a/packages/shared/src/types/settings.ts b/packages/shared/src/types/settings.ts index 3e84f8c970..267d857561 100644 --- a/packages/shared/src/types/settings.ts +++ b/packages/shared/src/types/settings.ts @@ -23,6 +23,12 @@ export type ThemePreference = "system" | "light" | "dark" | `plugin:${string}`; export type CloseBehavior = "ask" | "tray" | "quit"; export type AppSettings = { + /** + * Workspace group. One switch, default off: it owns the whole lifecycle of + * the workspace index — on, newly opened workspaces get indexed in the + * background so the Index page can report status; off, nothing builds one. + */ + indexGrepBoost: boolean; imageGeneration?: import("../image-generation.js").ImageGenerationBinding | null; /** All models marked for image generation; absent falls back to imageGeneration. */ imageGenerationModels?: import("../image-generation.js").ImageGenerationBinding[] | null; diff --git a/packages/shared/src/types/workspace-index.ts b/packages/shared/src/types/workspace-index.ts new file mode 100644 index 0000000000..771625d156 --- /dev/null +++ b/packages/shared/src/types/workspace-index.ts @@ -0,0 +1,26 @@ +/** + * Workspace index status types (`index.status`). These describe the index — + * a rebuildable local cache. + */ + +export type WorkspaceIndexRootStatus = + | "fresh" + | "building" + | "stale" + | "failed" + | "partial" + | "disabled" + | "skipped_over_limit"; + +export type WorkspaceIndexRoot = { + rootId: string; + rootPath: string; + status: WorkspaceIndexRootStatus; + fileCount: number; + indexedBytes: number; + errorCount: number; + lastError: string | null; + updatedAt: number; + /** Present while status is "building"; absent otherwise. */ + progress?: { filesDone: number; filesTotal: number }; +}; diff --git a/scripts/README.md b/scripts/README.md index 33aaa6005c..2336efaeb8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -50,6 +50,7 @@ they cover are specified in | Script | Alias | Purpose | |---|---|---| | `e2e-smoke.mjs` | `pnpm test:e2e` | Protocol-level E2E against host-core, plus an optional live model | +| `e2e-index.mjs` | `pnpm test:e2e:index` | Isolated workspace-index status, rebuild, boundary rejection, and clear lifecycle | | `e2e-plan.mjs` | `pnpm test:e2e:plan` | Plan state, checkpoint artifact, and approval transitions | | `e2e-plan-ui.mjs` | `pnpm test:e2e:plan-ui` | Plan approval through the rendered UI | | `e2e-electron-boot.mjs` | `pnpm test:e2e:boot` | Electron boot probe | diff --git a/scripts/e2e-index.mjs b/scripts/e2e-index.mjs new file mode 100644 index 0000000000..dff4e01946 --- /dev/null +++ b/scripts/e2e-index.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const protocolVersion = 11; +const binary = resolveHostBinary(); +const scenarioRoot = await mkdtemp(join(tmpdir(), "pi-index-e2e-")); +const dataDir = join(scenarioRoot, "data"); +const workspace = join(scenarioRoot, "workspace"); +const outside = join(scenarioRoot, "outside"); +await mkdir(dataDir, { recursive: true }); +await mkdir(workspace, { recursive: true }); +await mkdir(outside, { recursive: true }); +await writeFile(join(workspace, "README.md"), "workspace index fixture\n", "utf8"); + +let child; +let lines; +const pending = new Map(); +let stderr = ""; + +try { + child = spawn(binary, [], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, PI_DESKTOP_DATA_DIR: dataDir }, + windowsHide: true, + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + lines = createInterface({ input: child.stdout }); + lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.id === undefined || message.id === null) return; + const entry = pending.get(String(message.id)); + if (!entry) return; + pending.delete(String(message.id)); + clearTimeout(entry.timer); + if (message.error) { + const error = new Error(message.error.message); + error.errorCode = message.error.data?.errorCode; + entry.reject(error); + } else { + entry.resolve(message.result); + } + }); + + await call("app.handshake", { protocolVersion }); + await call("workspace.set", { path: workspace }); + + const before = await call("index.status"); + assert(Array.isArray(before.roots) && before.roots.length === 0, "new index must be empty"); + + const rebuilt = await call("index.rebuild"); + assert(rebuilt.root.status === "fresh", `expected fresh, got ${rebuilt.root.status}`); + assert(rebuilt.root.fileCount === 1, `expected one file, got ${rebuilt.root.fileCount}`); + + const status = await call("index.status", { rootPath: workspace }); + assert(status.roots.length === 1, "rebuilt root must be visible"); + assert(status.roots[0].indexedBytes > 0, "indexed byte count must be positive"); + + let outsideRejected = false; + try { + await call("index.rebuild", { rootPath: outside }); + } catch (error) { + outsideRejected = error.errorCode === "INDEX_ROOT_OUTSIDE_WORKSPACE"; + } + assert(outsideRejected, "index RPC must reject a root outside the active workspace"); + + const cleared = await call("index.clear", { rootPath: workspace }); + assert(cleared.ok && cleared.cleared === 1, "clear must remove exactly one root"); + const after = await call("index.status"); + assert(after.roots.length === 0, "cleared index must be empty"); + + console.log("PASS E2E-INDEX-status-rebuild-clear - isolated index lifecycle RPCs"); +} catch (error) { + console.error(`FAIL E2E-INDEX-status-rebuild-clear - ${error.stack || error}`); + if (stderr.trim()) console.error(stderr.trim().slice(-2000)); + process.exitCode = 1; +} finally { + for (const entry of pending.values()) { + clearTimeout(entry.timer); + entry.reject(new Error("host stopped")); + } + pending.clear(); + lines?.close(); + if (child && child.exitCode === null) child.kill(); + await rm(scenarioRoot, { recursive: true, force: true }); +} + +function call(method, params = {}, timeoutMs = 30_000) { + const id = randomUUID(); + return new Promise((resolveResult, rejectResult) => { + const timer = setTimeout(() => { + pending.delete(id); + rejectResult(new Error(`timeout ${method}`)); + }, timeoutMs); + pending.set(id, { resolve: resolveResult, reject: rejectResult, timer }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + }); +} + +function resolveHostBinary() { + const name = process.platform === "win32" ? "pi-desktop-host-core.exe" : "pi-desktop-host-core"; + const candidates = [ + process.env.PI_DESKTOP_HOST_BIN && resolve(process.env.PI_DESKTOP_HOST_BIN), + join(root, "target", "debug", name), + join(root, "..", "..", "..", "target", "debug", name), + ].filter(Boolean); + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) throw new Error(`host binary missing; tried ${candidates.join(", ")}`); + return found; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +}