diff --git a/CLAUDE.md b/CLAUDE.md index 4b738aa..d8290ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,7 @@ Key files: - **`indexing` means "no answer yet", not "working".** A source with a snapshot that is being re-read stays `status: "ready"` and raises the additive `indexing.refreshing` flag, so a background refresh never flips a usable source (or the console's spinner) back to unready. `awaitIndexes` (`?wait=`) waits on the running pass, not on `status`. A pass dirtied mid-flight owes exactly one follow-up, and that follow-up waits out a quiet period as long as the last pass took (1–15s) — without it, sustained editing chained a full re-walk per edit forever. - **Poll `/api/status`, not `/api/graph`.** `/api/status` is O(sources) — index progress and per-source state, no resolve, no tokenize — and carries a `generation` counter that moves whenever the *content* of the graph payload would differ. Progress-only fields (`indexing.elapsedMs`, `passes`) are deliberately outside it — a counter that moved every millisecond through an index would defeat the poll. `generation` moving is a necessary condition for a refetch, not a sufficient one; the console additionally compares a per-source content signature. `/api/graph`'s expensive half (concept rows + `resolvedTokens`) is memoized on the identity of the snapshots it read, and per-concept token counts come from the index rather than a per-request BPE encode; that cache is keyed by live state on every request rather than cleared by an invalidation event, precisely so there is no trigger to forget. Never reintroduce a per-request `countTokens` over the corpus — it cost 14.5s per call on a 139MB vault the console was polling every 900ms. - **The indexing limits are user settings, not env-only** (`settings.mjs`, `GET`/`PATCH /api/settings`, Settings → Indexing in the console). Precedence is manifest > env > default — the manifest has to win or the settings UI would silently do nothing. Env vars (`CONTEXTCAKE_MAX_DOC_FILES`, `CONTEXTCAKE_MAX_SCAN_ENTRIES`, `CONTEXTCAKE_SOURCE_BUDGET_MS`) remain the headless/CI fallback. +- **`PATCH /api/sources` can repoint a folder-backed source** (`path`, for the `local`/`files` kinds) — the same cheap `probeFolder` the add path uses, so don't put a full walk on it either. It is refused for `github`/`github-rest`/`mcp` and for a clone-backed layer, whose folder belongs to Sync (`gitCloneOrPull` writes `CACHE_DIR/`, never `layer.path`). A new folder is a new *content identity*, so `adoptIndexes` finds nothing to carry and the source re-indexes from zero — measured on a 3,000-note vault: `status: "indexing"`, `conceptCount: 0`, ~16s, and the old folder's concepts stop resolving immediately. That is correct, not a gap in adoption: the snapshot it would have carried indexes a folder this source no longer reads. The response says `reindexing: true` so the client can name the cause before the row goes blue. - **Add-source validation is deliberately cheap**: only "folder is missing" and "that's a file" fail the form. A too-big folder is a normal thing to add — it becomes a visible source error after indexing, with a pointer at Settings. Don't reintroduce a full walk on the add path. MCP sources still probe (`tools/list`) at add time, which is bounded and catches a wrong command. - **Retrieval is measured, not asserted.** `npm test` runs the eval; a ranking change that loses recall fails the build with `RETRIEVAL REGRESSED`. If a change is a deliberate trade, re-record with `--record --label ""` — the superseded numbers stay in `baseline.json`'s history so the trade is visible later. Do not tune the stemmer or the field boosts against the golden questions: the set is small enough to overfit in an afternoon, and a scorer fitted to its own eval measures nothing. Add questions first, then tune. -- **Layer file APIs live in the engine, not the playground** (`layer-files.mjs`), so the desktop app can browse and edit context files. They cover `files`-kind layers too — the playground's old copy only mapped `okf-local` roots, which made markdown folders invisible in the editor. +- **Layer file APIs live in the engine, not the playground** (`layer-files.mjs`), so the desktop app can browse and edit context files. They cover `files`-kind layers too — the playground's old copy only mapped `okf-local` roots, which made markdown folders invisible in the editor. The console's Web Demo browses the same tree read-only: `apps/console/scripts/build-demo-data.mjs` calls `listFilesApi`/`readFileApi` over `apps/playground/demo-layers/` at build time. Like the resolved concepts beside it, that fixture is generated from real engine output — never hand-authored — and it captures only the two GET answers, so the demo has no write path to fake. diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index 1445689..4610844 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -34,7 +34,8 @@ release builds and deploys the matching public Web Demo from the same commit. The gates are `npm run typecheck` (strict, `noUnusedLocals`/`noUnusedParameters`) and `npm test`; CI runs both. dev/build/typecheck/test all regenerate -`src/generated/demo-cascade.json` (gitignored) via their pre-hooks. +`src/generated/demo-cascade.json` + `src/generated/demo-files.json` (gitignored) +via their pre-hooks. ## Architecture @@ -47,12 +48,24 @@ and `npm test`; CI runs both. dev/build/typecheck/test all regenerate - **Views** — `src/views/` (Canvas, Overview, Sources, Triage, Conflicts, Concepts, Files). `App.tsx` is the shell: topbar + subbar + routed view, plus the Triage S/R/D keyboard handler. The canvas view stays full-height inside - the chrome. Files is live-mode only: it browses and edits the real files - behind each layer through the engine's `/api/files` + `/api/file`, with a - rendered/raw toggle for Markdown. Sources manages the layers themselves — - rename + re-level (PATCH `/api/sources`), remove with confirm (DELETE), - Sync-now for github kinds (POST `/api/sources/sync`) — read-only in demo - mode; `live: true` layers get a capture warning on rename/remove. + the chrome. Files browses and edits the real files behind each layer through + the engine's `/api/files` + `/api/file`, with a rendered/raw toggle for + Markdown. It renders in demo mode too, read-only, over the generated + `demo-files.json` snapshot (see **Data**): same tree, same documents, same + cross-links, no Save — `canEdit = live && file.editable` gates the save + button, the ⌘S binding and the textarea, and the raw-preview fetch is skipped + because a snapshot carries text, not bytes. Sources manages the layers themselves — + rename + re-level + repoint a folder-backed source (PATCH `/api/sources`), + remove with confirm (DELETE), Sync-now for github kinds (POST + `/api/sources/sync`) — read-only in demo mode; `live: true` layers get a + capture warning on rename/remove. +- **Files ⇄ Concepts** — the two ends of one thing, and walkable both ways. An + open document names the concept it resolves to (`conceptForFile`: the file's + `rel` minus its document extension, matched against a loaded concept id — + verified 3,000/3,000 against a real `files` layer); each contributor in + `ConceptDetail` gets an "Open file" link, but only where `/api/files` lists a + file for that (source, concept id) pair, so an MCP or REST-read contributor + gets no affordance rather than one that opens on an error. - **Setup wizard** — `src/components/SetupWizard.tsx` has two shapes from one component: the first-run guided narrative (personal → optional team → optional company MCP → review) and a one-step add-a-source mode (four-kind @@ -71,7 +84,11 @@ and `npm test`; CI runs both. dev/build/typecheck/test all regenerate - **Data** — `src/api.ts` is the single seam: demo mode imports a bundle generated at build time by shelling out to the real `packages/core/src/resolver.mjs` (`scripts/build-demo-data.mjs`), live mode fetches the same-origin playground - API (`/api/status`, `/api/graph`, `/api/resolve-all`). Adapters map wire types (`types.ts`) + API (`/api/status`, `/api/graph`, `/api/resolve-all`). `src/layer-files.ts` is + the same seam for files: the demo half of `demo-files.json` is one + `listFilesApi` listing plus a `readFileApi` answer per path, produced by + calling the engine's own file APIs — never hand-authored, and read-only + because only the two GET answers are captured. Adapters map wire types (`types.ts`) onto the view model in `src/data.ts`, deriving provenance from contributor levels. `src/data.ts` keeps only lane semantics and the demo-only triage/activity fixtures. Live errors are typed (`LiveDataError`) and @@ -122,6 +139,12 @@ Key files: `src/store.tsx` (state), `src/theme.ts` (`css()` + tokens), `synced` — "synced · 0 concepts" over a still-reading vault is the exact lie this pass exists to remove. `indexing.refreshing` is the opposite case: serving good data while re-reading, so it gets a note, never a spinner. +- **The file listing revalidates on `filesRevalidation()`, never on + `sources.length`.** Three views read `/api/files` (Sources, Files, + ConceptDetail) and all three must key the refetch the same way. A rename and a + repoint both leave the source count untouched, so a count-keyed effect went on + answering for the old layer name and the old root until something remounted — + a renamed 3,000-file source rendering "None on this machine". - **`warnings` is the true count; `warningMessages` is capped at 10.** Render the count from `warnings`. - **`src/markdown.ts` parses to typed data and has no dependencies.** It never diff --git a/apps/console/README.md b/apps/console/README.md index 115f9d4..f879fe4 100644 --- a/apps/console/README.md +++ b/apps/console/README.md @@ -3,7 +3,8 @@ The React front end for inspecting and resolving a ContextCake cascade. It runs in three environments from the same codebase: -- **demo** — bundled sample data for the public site; +- **demo** — bundled sample data for the public site, including a snapshot of + the files behind each layer so the navigator works read-only; - **live browser** — reads the local engine through `/api/status` (the cheap poll), `/api/graph`, `/api/resolve*`, `/api/conflict-resolutions`, and the source-management endpoints; @@ -52,7 +53,16 @@ playground/service command documented in the repository instructions. `.contextcake/conflict-resolutions.ndjson` beside the manifest. History can be reopened to choose a different saved answer later. The service refuses the whole change if a source is remote, missing, or changed since review. -- **Concepts** shows the effective concept with per-section provenance. +- **Concepts** shows the effective concept with per-section provenance, and each + contributor links to the file it came from. +- **Files** is the source navigator: a keyboard tree per source, scoping to one + source, deep links (`#/files//`), a rendered/raw view of each + document, and editing with re-resolve on save. Demo mode renders the same + navigator read-only. Sources whose content is remote — a GitHub repository + read over the API, an MCP graph — keep no files here and say so. +- **Sources** manages the layers themselves: rename, re-level, repoint a + folder-backed source, remove, and sync. Read-only in demo mode, where the way + into the navigator is still offered. - **Ask ContextCake** uses the resolved cascade when a compatible `window.claude.complete` harness bridge is present. Otherwise it returns a visibly labeled sample answer; Electron does not currently provide that @@ -86,6 +96,7 @@ metadata; each Mac requires its own local setup. ```text src/ api.ts demo/live adapters and authenticated desktop fetch + layer-files.ts the same seam for /api/files and /api/file store.tsx application state and live reload/actions theme.ts CSS-variable references and style helpers theme-mode.tsx local theme plus optional desktop sync @@ -95,6 +106,7 @@ src/ SettingsView.tsx full-window General and Account settings AccountPanel.tsx desktop auth and settings-sync controls SetupWizard.tsx first-run source configuration + FileTree.tsx windowed ARIA tree behind the Files navigator ConnectAgentDialog.tsx ChatPanel.tsx views/ @@ -103,6 +115,8 @@ src/ Triage.tsx Conflicts.tsx Concepts.tsx + Files.tsx + Sources.tsx styles.css ``` diff --git a/apps/console/scripts/build-demo-data.mjs b/apps/console/scripts/build-demo-data.mjs index e5a3a6b..8c47674 100644 --- a/apps/console/scripts/build-demo-data.mjs +++ b/apps/console/scripts/build-demo-data.mjs @@ -3,22 +3,30 @@ // // Enumerate every concept in the demo bundle and resolve it through the REAL // engine, then assemble a graph summary shaped exactly like the playground -// server's GET /api/graph. Emits one JSON file the console imports at build time: +// server's GET /api/graph. Emits two JSON files the console imports at build time: // // apps/console/src/generated/demo-cascade.json → { graph, concepts } +// apps/console/src/generated/demo-files.json → { layers, files } // -// so DemoSource and LiveSource return identical shapes (types.ts). The directory -// is gitignored: generated, never committed, never hand-edited. Wired as the -// console `predev` / `prebuild` / `pretypecheck` npm script. +// so DemoSource and LiveSource return identical shapes (types.ts), and the Files +// navigator has the same tree in the public Web Demo that it has over a real +// folder. The directory is gitignored: generated, never committed, never +// hand-edited. Wired as the console `predev` / `prebuild` / `pretypecheck` npm +// script. // -// Engine use is READ-ONLY — we shell out to `resolver.mjs` exactly as the docs -// show (`node resolver.mjs --manifest … --concept …`). No engine file is -// imported or modified; this can never affect `npm test`. +// Engine use is READ-ONLY, and both halves come from the engine itself: the +// cascade by shelling out to `resolver.mjs` exactly as the docs show +// (`node resolver.mjs --manifest … --concept …`), the file tree by calling the +// very functions service.mjs mounts at GET /api/files and GET /api/file. Those +// have no CLI, and reimplementing the walk here would mean the demo tree could +// drift from the live one without anything failing. Nothing is written back and +// no engine file is modified, so this can never affect `npm test`. import { execFileSync } from 'node:child_process' import { readdirSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { layerRootMap, listFilesApi, readFileApi } from '../../../packages/core/src/layer-files.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) // apps/console/scripts const consoleRoot = resolve(scriptDir, '..') // apps/console/ @@ -28,6 +36,7 @@ const manifestDir = dirname(manifestPath) const resolverPath = join(repoRoot, 'packages', 'core', 'src', 'resolver.mjs') const outDir = join(consoleRoot, 'src', 'generated') const outFile = join(outDir, 'demo-cascade.json') +const filesOutFile = join(outDir, 'demo-files.json') /** Recursively collect every `*.md` under `dir` (Node ≥ 18, no deps). */ function walkMarkdown(dir) { @@ -114,16 +123,37 @@ const sources = layers.map((l) => ({ })) const graph = { - manifest: { path: manifestPath }, + // Repo-relative for the same reason `layer.root` is below: this bundle is + // inlined into the public Web Demo's JS, and the absolute path is the build + // machine's — a developer's home directory and repo layout, shipped. + manifest: { path: relative(repoRoot, manifestPath) }, tokenizer: 'demo', totals: { sourceTokens: 0, resolvedTokens: 0, concepts: concepts.length, sources: sources.length }, sources, concepts: graphConcepts, } +// The file tree behind those layers, from the engine's own file APIs. The demo +// serves it read-only: the listing and every document's text, no write route. +const roots = layerRootMap(manifest, manifestDir) +const listing = await listFilesApi(roots) +const files = {} +for (const layer of listing.layers) { + for (const entry of layer.files) files[entry.path] = await readFileApi(entry.path, roots) + // The absolute root is the build machine's, and this bundle ships to the + // public Web Demo. The repo-relative path is the same folder said honestly, + // without a stranger's home directory in it. + layer.root = relative(repoRoot, layer.root) +} + mkdirSync(outDir, { recursive: true }) writeFileSync(outFile, JSON.stringify({ graph, concepts }, null, 2) + '\n') +const filesJson = JSON.stringify({ layers: listing.layers, files }, null, 2) + '\n' +writeFileSync(filesOutFile, filesJson) console.log( `[console build-demo-data] wrote ${concepts.length} concept(s), ${sources.length} source(s) → ${relative(repoRoot, outFile)}`, ) +console.log( + `[console build-demo-data] wrote ${Object.keys(files).length} file(s), ${Math.round(filesJson.length / 1024)} KB → ${relative(repoRoot, filesOutFile)}`, +) diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 39a0fa6..2106dae 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -66,7 +66,7 @@ function ErrorState({ kind, message, reload }: { kind: LiveErrorKind; message: s } export function App() { - const { view, setView, chatOpen, openChat, closeChat, route, loading, load, error, reload, retryNow, mode, sources, loadErrors } = useStore() + const { view, setView, chatOpen, openChat, closeChat, route, loading, load, error, reload, retryNow, mode, sources, loadErrors, openFilesScope } = useStore() // Undefined = not yet decided by the auto-trigger effect below; true/false // once the user (or the trigger) has taken an explicit stance. Kept separate // from `needsSetup` so the wizard's own Success step stays visible even @@ -171,16 +171,25 @@ export function App() { { id: 'home', label: 'Go to Home', keywords: 'overview', shortcut: '⌘1', run: () => setView('overview') }, { id: 'cascade', label: 'Go to Cascade', keywords: 'canvas graph', shortcut: '⌘2', run: () => setView('canvas') }, { id: 'concepts', label: 'Go to Knowledge: Concepts', keywords: 'browse', run: () => setView('concepts') }, - { id: 'files', label: 'Go to Knowledge: Files', keywords: 'markdown documents', run: () => setView('files') }, + { id: 'files', label: 'Go to Knowledge: Files', keywords: 'markdown documents', shortcut: '⇧⌘F', run: () => setView('files') }, { id: 'sources', label: 'Go to Sources', shortcut: '⌘4', run: () => setView('sources') }, { id: 'queue', label: 'Go to Review: Queue', keywords: 'triage', run: () => setView('triage') }, { id: 'conflicts', label: 'Go to Review: Conflicts', keywords: 'resolve', run: () => setView('conflicts') }, + // One per source: the palette is the keyboard route into the navigator, + // matching the Sources panel's "Browse files" button — including in the + // demo, where that button is offered too. Browsing is a read. + ...sources.filter((source) => !source.quarantined).map((source) => ({ + id: `files:${source.name}`, + label: `Browse files in ${source.name}`, + keywords: 'navigator folder tree source files', + run: () => openFilesScope(source.name), + })), ...(mode === 'live' ? [{ id: 'add-source', label: 'Add Source', keywords: 'folder repository', run: reopenWizard }] : []), ...(isDesktop ? [{ id: 'connect-agent', label: 'Connect Agent', keywords: 'cli mcp', run: openConnect }] : []), { id: 'ask', label: 'Ask ContextCake', shortcut: '⇧⌘A', run: openAskFromPalette }, { id: 'settings', label: 'Open Settings', shortcut: '⌘,', run: openSettings }, { id: 'sidebar', label: 'Toggle Sidebar', run: toggleSidebar }, - ], [isDesktop, mode, openAskFromPalette, setView, sources.length, sourceSetupComplete]) + ], [isDesktop, mode, openAskFromPalette, openFilesScope, setView, sources, sourceSetupComplete]) const closeSettings = () => { const opener = settingsOpener.current setSettingsOpen(false) @@ -277,7 +286,12 @@ export function App() { } else if (command && e.shiftKey && e.key.toLowerCase() === 'a') { e.preventDefault() if (!showWizard && !connectOpen && !settingsOpen) openAsk() - } else if (command && e.key.toLowerCase() === 'f' && SEARCHABLE_VIEWS.has(view)) { + } else if (command && e.shiftKey && e.key.toLowerCase() === 'f') { + // ⇧⌘F is the navigator, ⌘F is search-this-view — the desktop View menu + // carries the same pair, so the two surfaces agree. + e.preventDefault() + if (!showWizard && !connectOpen && !settingsOpen) setView('files') + } else if (command && !e.shiftKey && e.key.toLowerCase() === 'f' && SEARCHABLE_VIEWS.has(view)) { e.preventDefault() window.dispatchEvent(new Event('contextcake:focus-search')) } else if (command && !editing && !e.shiftKey && /^[1-5]$/.test(e.key)) { @@ -305,6 +319,7 @@ export function App() { const editing = active instanceof Element && active.matches('input, textarea, select, [contenteditable="true"]') const modalOpen = showWizard || connectOpen || settingsOpen if (command === 'command-palette' && !modalOpen) openPalette() + else if (command === 'view:files' && !modalOpen) setView('files') else if (command === 'search' && SEARCHABLE_VIEWS.has(view) && !modalOpen) window.dispatchEvent(new Event('contextcake:focus-search')) else if (command === 'ask' && !modalOpen) openAsk() else if (command === 'settings' && !showWizard && !connectOpen) openSettings() diff --git a/apps/console/src/api.ts b/apps/console/src/api.ts index 9b078fe..bd7d67b 100644 --- a/apps/console/src/api.ts +++ b/apps/console/src/api.ts @@ -403,6 +403,7 @@ function adaptSection(s: ResolvedSection, levels: Map): ConceptS const winner = layerOf(s.sourceLayer, levels.get(s.sourceLayer) ?? 0) const dissents: Dissent[] = (s.conflicts ?? []).map((c) => ({ layer: layerOf(c.layer, levels.get(c.layer) ?? 0), + sourceLayer: c.layer, value: c.content, updated: c.updated, })) @@ -410,6 +411,7 @@ function adaptSection(s: ResolvedSection, levels: Map): ConceptS name: sectionName(s), key: s.key, winner, + sourceLayer: s.sourceLayer, value: s.content, updated: s.sourceUpdated, suppressed: s.suppressed === true, diff --git a/apps/console/src/components/ConceptDetail.tsx b/apps/console/src/components/ConceptDetail.tsx index 2374fab..c0351bd 100644 --- a/apps/console/src/components/ConceptDetail.tsx +++ b/apps/console/src/components/ConceptDetail.tsx @@ -1,11 +1,66 @@ +import { useMemo } from 'react' import { C, css, lc, MONO, conceptTypeStyle } from '../theme' import { layerName } from '../data' import type { Concept } from '../data' +import { filesRevalidation, useLayerFiles } from '../layer-files' +import { useStore } from '../store' import { LayerChip } from './LayerChip' +/** Which document extension wins when one concept id has several files behind it. */ +const DOC_EXT = ['.md', '.markdown', '.mdx', '.txt'] + +/** JSON, not a joined string: a source name may contain spaces, and + * "a b" + "c" must never collide with "a" + "b c". */ +const contributorKey = (layer: string, conceptId: string) => JSON.stringify([layer, conceptId]) + +/** + * (source name, concept id) → the engine file path behind it. + * + * Built from the real `/api/files` listing rather than guessed as + * `.md`, so the link is only ever offered for a file that exists — a + * `files`-kind layer may hold the concept as `.mdx` or `.txt`, and a + * contributor read over MCP or the GitHub API keeps no file here at all and is + * therefore absent from the listing. That absence is the gate: no entry, no + * link, and so no affordance that opens on an error. + */ +function useFileByContributor(): Map { + const { mode, sources, reloadKey } = useStore() + const { layers } = useLayerFiles(mode, filesRevalidation(sources, reloadKey)) + return useMemo(() => { + const best = new Map() + for (const entry of layers ?? []) { + for (const file of entry.files) { + const rank = DOC_EXT.indexOf(file.ext) + if (rank === -1) continue + const key = contributorKey(entry.layer, file.rel.slice(0, -file.ext.length)) + const current = best.get(key) + if (!current || rank < current.rank) best.set(key, { path: file.path, rank }) + } + } + return new Map([...best].map(([key, value]) => [key, value.path])) + }, [layers]) +} + +/** "Open file" for one contributor, or nothing when that layer keeps no file here. */ +function OpenFile({ layer, path, conceptId }: { layer: string; path: string | undefined; conceptId: string }) { + const { openFilesScope } = useStore() + if (!path) return null + return ( + + ) +} + /** The resolved read of a concept — provenance chips per section + inline dissent. * Shared by the Concepts view and the Canvas node slide-over. */ export function ConceptDetail({ concept }: { concept: Concept }) { + const fileByContributor = useFileByContributor() + const fileFor = (sourceLayer: string) => fileByContributor.get(contributorKey(sourceLayer, concept.id)) return ( <>
@@ -32,6 +87,7 @@ export function ConceptDetail({ concept }: { concept: Concept }) {
{s.suppressed ? ( @@ -54,6 +110,7 @@ export function ConceptDetail({ concept }: { concept: Concept }) { {layerName(d.layer)} says "{d.value}" — overridden here. {d.updated && {d.updated}} + ) })} diff --git a/apps/console/src/components/FileTree.test.ts b/apps/console/src/components/FileTree.test.ts new file mode 100644 index 0000000..a02399c --- /dev/null +++ b/apps/console/src/components/FileTree.test.ts @@ -0,0 +1,62 @@ +// buildTree's identity contract. +// +// A row's id keys the React child, `indexById`, and the registered DOM node, so +// two rows sharing one id is not a cosmetic problem: the pre-order walk follows +// `children.get(node.id)`, and a collision makes it descend into somebody +// else's subtree. Both shapes below were real with `/` ids. +import { describe, expect, it } from 'vitest' +import { ancestorsOfId, buildTree } from './FileTree' +import type { LayerFile, LayerFiles } from '../types' + +function file(layer: string, rel: string): LayerFile { + const name = rel.slice(rel.lastIndexOf('/') + 1) + const dot = name.lastIndexOf('.') + return { + path: `${layer}/${rel}`, name, rel, + ext: dot > 0 ? name.slice(dot) : '', + kind: 'text', markdown: rel.endsWith('.md'), + } +} + +function layer(name: string, rels: string[]): LayerFiles { + return { + layer: name, kind: 'files', root: `/${name}`, fileCount: rels.length, truncated: false, + files: rels.map((rel) => file(name, rel)), + } +} + +const filePaths = (entries: ReturnType) => + entries.filter((entry) => entry.kind === 'file').map((entry) => entry.path) + +describe('buildTree ids', () => { + it('keeps a file and the sibling folder of the same name apart', () => { + // `notes` the file and `notes/` the folder both hashed to "vault/notes", + // so the walk descended into the folder's children a second time: five + // file rows — two of them duplicate React keys — for three files. + const entries = buildTree([layer('vault', ['notes', 'notes/a.md', 'notes/b.md'])]) + + expect(filePaths(entries).sort()).toEqual(['vault/notes', 'vault/notes/a.md', 'vault/notes/b.md']) + expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length) + }) + + it('keeps a layer named like a folder path out of that folder', () => { + // validateContextManifest accepts a layer literally named `a/b`, and + // /api/files then lists it. Its root id was "a/b" — the same string as the + // `b/` folder inside the layer named `a`, whose two files then vanished. + const entries = buildTree([layer('a', ['b/c.md', 'b/d.md']), layer('a/b', ['e.md'])]) + + expect(filePaths(entries).sort()).toEqual(['a/b/c.md', 'a/b/d.md', 'a/b/e.md']) + expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length) + // Two roots, and the folder is still inside the layer that owns it. + expect(entries.filter((entry) => entry.depth === 0).map((entry) => entry.name)).toEqual(['a', 'a/b']) + }) + + it('walks a row back up to its layer root', () => { + const entries = buildTree([layer('vault', ['Projects/deep/beta.md'])]) + const beta = entries.find((entry) => entry.path === 'vault/Projects/deep/beta.md')! + const chain = ancestorsOfId(beta.id) + + expect(chain).toEqual(entries.filter((entry) => entry.kind === 'dir').map((entry) => entry.id)) + expect(chain[0]).toBe(entries.find((entry) => entry.depth === 0)!.id) + }) +}) diff --git a/apps/console/src/components/FileTree.tsx b/apps/console/src/components/FileTree.tsx new file mode 100644 index 0000000..440d8eb --- /dev/null +++ b/apps/console/src/components/FileTree.tsx @@ -0,0 +1,537 @@ +// The source navigator: a real tree widget over a layer's files, windowed so a +// 3,000-note vault costs the same as a 30-note one. +// +// Two things here are load-bearing and easy to get subtly wrong. +// +// 1. **Flat DOM, real tree semantics.** Windowing means the rows that exist are +// a moving slice of the tree, so `role="group"` wrappers cannot be nested +// around subtrees the way a static tree nests them. Every row instead +// carries `aria-level`/`aria-setsize`/`aria-posinset` — the flattened form +// the ARIA tree pattern defines for exactly this case. Directories carry +// `aria-expanded`; the selected file carries `aria-selected`. And rows are +// emitted in VISUAL order even though they are absolutely positioned: browse +// mode reads a flattened tree in DOM order, so the two must not diverge. +// +// 2. **Focus survives the window.** A focused row that scrolls out of the +// rendered slice would be unmounted, and focus would fall to — the +// keyboard would go dead mid-navigation. Two rules prevent that: keyboard +// movement scrolls the target into view *before* focusing it, and the active +// row is rendered unconditionally even when it lies outside the slice. So +// however the row went out of *view* (wheel, trackpad), it is still in the +// DOM and still has focus. A row that leaves the tree ENTIRELY — its folder +// collapsed, a filter dropped it, the listing refetched without it — cannot +// be kept, so focus follows the tab stop to the row that inherits it, but +// only when focus was already inside the tree. +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { lc, type LayerId } from '../theme' +import type { LayerFile, LayerFiles } from '../types' + +/** Fixed row height. Windowing math is index × this — keep it in sync with CSS. */ +export const ROW_HEIGHT = 28 +/** Rows rendered beyond each edge, so a fast scroll doesn't show gaps. */ +const OVERSCAN = 8 +/** Used until the scroll container has been measured (and in jsdom, where it never is). */ +const FALLBACK_VIEWPORT = 640 +/** Left inset per level, and the depth past which it stops growing. */ +const INDENT_STEP = 13 +const INDENT_BASE = 8 +/** + * Depth is unbounded — `walkAll` in the engine caps files, not nesting — but the + * navigator column is not: `minmax(220px, 300px)`, with `overflow-x: hidden`. + * Left unclamped, indent ate the name outright, and a file 20 folders down + * rendered as a blank row that was still clickable and still focusable. Ten + * levels is the deepest inset that still leaves room for a name at the column's + * *narrowest*, so past it rows share an inset. Nesting is then carried by + * `aria-level` and the row's `title`, which stay truthful at any depth — a + * flatter picture of a deep tree beats an invisible one. + */ +const MAX_INDENT_DEPTH = 10 +const rowIndent = (depth: number) => INDENT_BASE + Math.min(depth, MAX_INDENT_DEPTH) * INDENT_STEP + +/** + * One row of the tree. Shaped after `apps/site/src/lib/pack-explorer.ts` — + * the site's pack explorer already proved this flattening — plus what a + * windowed, layer-aware navigator additionally needs: sibling position for + * ARIA, a subtree file count, and the layer each row belongs to. + */ +export interface TreeEntry { + /** + * This row's identity: the React key, the `indexById` key, and the key the + * DOM node is registered under. Two rows sharing one is not cosmetic — the + * tree duplicates some entries and loses others outright — so an id is NOT a + * path. `/` collides two ways, and both are reachable: a file + * beside a directory of the same name, and a layer literally named `a/b` + * beside a layer `a` that contains a `b/` folder (the manifest validator + * accepts such a name, and `/api/files` then lists it). An id therefore + * carries the layer's POSITION rather than its name, plus whether the row is + * a directory or a file. See `dirId`/`fileId`. + */ + id: string + kind: 'dir' | 'file' + name: string + /** `/` for files — the exact path `/api/file` takes. */ + path: string + /** Depth from the layer root; the layer row itself is 0. */ + depth: number + parent: string + layer: string + /** 1-based position among siblings, and how many siblings there are. */ + pos: number + size: number + /** Files in this subtree (directories and layer roots only). */ + count: number + file?: LayerFile +} + +const byName = (a: TreeEntry, b: TreeEntry) => + (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1) + +/** A directory's id: its layer's index, then the folder path inside it ('' = the layer root). */ +const dirId = (layerIndex: number, rel: string) => `d${layerIndex}:${rel}` +/** A file's id. The `f` is what keeps a file from sharing an id with a sibling folder of the same name. */ +const fileId = (layerIndex: number, rel: string) => `f${layerIndex}:${rel}` + +/** + * The directory ids that hold a row, outermost first — the folders a deep link + * has to open to show its file. Derived from the id rather than the path, so a + * layer whose name contains a slash cannot be mis-split. + */ +export function ancestorsOfId(id: string): string[] { + const cut = id.indexOf(':') + if (cut === -1) return [] + const layerIndex = id.slice(1, cut) + const parts = id.slice(cut + 1).split('/') + const out = [`d${layerIndex}:`] + for (let i = 1; i < parts.length; i += 1) out.push(`d${layerIndex}:${parts.slice(0, i).join('/')}`) + return out +} + +/** + * Layer listings → one pre-ordered entry list. Each layer contributes a root + * row, and every `rel` with a slash in it becomes the directories it implies. + */ +export function buildTree(layers: LayerFiles[]): TreeEntry[] { + const out: TreeEntry[] = [] + const roots: TreeEntry[] = [] + const children = new Map() + + layers.forEach((layer, layerIndex) => { + const rootId = dirId(layerIndex, '') + const root: TreeEntry = { + id: rootId, kind: 'dir', name: layer.layer, path: layer.layer, depth: 0, + parent: '', layer: layer.layer, pos: 0, size: 0, count: 0, + } + roots.push(root) + children.set(rootId, []) + const dirs = new Map([[rootId, root]]) + + for (const file of layer.files) { + const parts = file.rel.split('/') + let parentId = rootId + let folder = '' + root.count += 1 + for (let i = 0; i < parts.length - 1; i += 1) { + folder = folder ? `${folder}/${parts[i]}` : parts[i] + const id = dirId(layerIndex, folder) + let dir = dirs.get(id) + if (!dir) { + dir = { + id, kind: 'dir', name: parts[i], path: `${layer.layer}/${folder}`, depth: i + 1, + parent: parentId, layer: layer.layer, pos: 0, size: 0, count: 0, + } + dirs.set(id, dir) + children.set(id, []) + children.get(parentId)!.push(dir) + } + dir.count += 1 + parentId = id + } + children.get(parentId)!.push({ + id: fileId(layerIndex, file.rel), kind: 'file', name: file.name, path: file.path, + depth: parts.length, parent: parentId, layer: layer.layer, + pos: 0, size: 0, count: 0, file, + }) + } + }) + + // Pre-order emit. An explicit stack rather than recursion: a vault nests as + // deep as the user's folders do, and this runs on every filter keystroke. + roots.sort(byName) + roots.forEach((root, index) => { root.pos = index + 1; root.size = roots.length }) + const stack = [...roots].reverse() + while (stack.length > 0) { + const node = stack.pop()! + out.push(node) + const kids = children.get(node.id) + if (!kids || kids.length === 0) continue + kids.sort(byName) + for (let i = kids.length - 1; i >= 0; i -= 1) { + kids[i].pos = i + 1 + kids[i].size = kids.length + stack.push(kids[i]) + } + } + return out +} + +/** Pre-ordered entries → the rows a given expansion state actually shows. */ +export function flattenTree(entries: TreeEntry[], isExpanded: (id: string) => boolean): TreeEntry[] { + const out: TreeEntry[] = [] + let collapsedAt = -1 + for (const entry of entries) { + if (collapsedAt >= 0) { + if (entry.depth > collapsedAt) continue + collapsedAt = -1 + } + out.push(entry) + if (entry.kind === 'dir' && !isExpanded(entry.id)) collapsedAt = entry.depth + } + return out +} + +function Chevron() { + return ( + + ) +} + +interface RowProps { + entry: TreeEntry + index: number + active: boolean + selected: boolean + expanded: boolean + layerId: LayerId | null + onOpen: (entry: TreeEntry) => void + register: (id: string, node: HTMLDivElement | null) => void +} + +/** + * Memoized because a filter keystroke re-renders the tree and every prop here + * is either a primitive or a stable identity — nothing rebuilds unless the row + * itself changed. + */ +const Row = memo(function Row({ entry, index, active, selected, expanded, layerId, onOpen, register }: RowProps) { + const dir = entry.kind === 'dir' + const root = entry.depth === 0 + const color = root && layerId ? lc(layerId) : null + return ( +
register(entry.id, node)} + role="treeitem" + aria-level={entry.depth + 1} + aria-posinset={entry.pos} + aria-setsize={entry.size} + aria-expanded={dir ? expanded : undefined} + aria-selected={dir ? undefined : selected} + tabIndex={active ? 0 : -1} + data-kind={entry.kind} + data-root={root ? 'true' : undefined} + title={entry.path} + className="cc-tree-row" + style={{ top: index * ROW_HEIGHT, paddingLeft: rowIndent(entry.depth) }} + onClick={() => onOpen(entry)} + > + {dir ? :
+ ) +}) + +export interface FileTreeProps { + entries: TreeEntry[] + /** Every directory starts open — what a text filter wants, since every row matched. */ + expandAll: boolean + selected: string | null + /** + * A file whose folders should be opened to show it. Distinct from `selected` + * on purpose: a selection the view made for itself must not reorganize the + * tree, only one the user asked for. + */ + reveal: string | null + onSelect: (path: string) => void + layerIds: Map + label: string +} + +export function FileTree({ entries, expandAll, selected, reveal, onSelect, layerIds, label }: FileTreeProps) { + const scrollRef = useRef(null) + const rows = useRef(new Map()) + const [scrollTop, setScrollTop] = useState(0) + const [viewport, setViewport] = useState(0) + const [collapsed, setCollapsed] = useState>(() => new Set()) + const [opened, setOpened] = useState>(() => new Set()) + const [activeId, setActiveId] = useState(null) + // Set only by the movement handlers: a re-render caused by data arriving must + // never yank focus away from wherever the user actually is. + const wantFocus = useRef(false) + // Whether focus is inside the tree, tracked as it moves rather than read off + // document.activeElement when it matters: removing a focused node moves focus + // to without firing anything, so by then the answer is always "no". + const hasFocus = useRef(false) + const activeRef = useRef(null) + activeRef.current = activeId + + const rootIds = useMemo( + () => new Set(entries.filter((entry) => entry.depth === 0).map((entry) => entry.id)), + [entries], + ) + + // Directories are closed until asked for. The layer roots are the exception — + // a source with nothing open under it still has to show that it is there — as + // are the ancestors of `reveal`, since a deep link must show its own file. + // + // `expandAll` (a search is running: every remaining row matched, so a closed + // tree hides the answer) is a DEFAULT, not an override. Checked first, it made + // every folder permanently open for as long as the filter lasted: ArrowLeft + // always took the collapse branch and never walked to the parent, and clicking + // a folder was a visible no-op that silently dropped its stored state — during + // the flow the tree is used in most. + const isExpanded = useCallback((id: string) => { + if (collapsed.has(id)) return false + if (expandAll) return true + return rootIds.has(id) || opened.has(id) + }, [collapsed, expandAll, opened, rootIds]) + + // `reveal` names a file by its engine path; the folders to open are keyed by + // id. One pass to translate, so the effect below re-runs on the id and not on + // every rebuild of an identical tree. + const revealId = useMemo(() => { + if (!reveal) return null + for (const entry of entries) if (entry.kind === 'file' && entry.path === reveal) return entry.id + return null + }, [entries, reveal]) + + useEffect(() => { + if (!revealId) return + const chain = ancestorsOfId(revealId) + setOpened((prev) => { + if (chain.every((id) => prev.has(id))) return prev + const next = new Set(prev) + for (const id of chain) next.add(id) + return next + }) + setCollapsed((prev) => { + if (!chain.some((id) => prev.has(id))) return prev + const next = new Set(prev) + for (const id of chain) next.delete(id) + return next + }) + }, [revealId]) + + const visible = useMemo(() => flattenTree(entries, isExpanded), [entries, isExpanded]) + const indexById = useMemo(() => { + const map = new Map() + visible.forEach((entry, index) => map.set(entry.id, index)) + return map + }, [visible]) + + // How tall the window is. Measured, never assumed — but the measurement is + // deliberately re-taken on scroll as well, because a first measurement taken + // before the pane has been laid out would otherwise stick: the tree would + // render a two-row window over a full-height column and only ever show the + // top of the list, with nothing to correct it. + const measure = useCallback(() => { + const node = scrollRef.current + if (node) setViewport((prev) => (prev === node.clientHeight ? prev : node.clientHeight)) + }, []) + + useEffect(() => { + const node = scrollRef.current + if (!node) return + measure() + const frame = requestAnimationFrame(measure) + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure) + return () => { cancelAnimationFrame(frame); window.removeEventListener('resize', measure) } + } + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => { cancelAnimationFrame(frame); observer.disconnect() } + }, [measure]) + + const register = useCallback((id: string, node: HTMLDivElement | null) => { + if (node) rows.current.set(id, node) + else rows.current.delete(id) + }, []) + + /** Put a row inside the window before anything tries to focus it. */ + const ensureVisible = useCallback((index: number) => { + const node = scrollRef.current + if (!node) return + const height = node.clientHeight || FALLBACK_VIEWPORT + const top = index * ROW_HEIGHT + const next = top < node.scrollTop + ? top + : top + ROW_HEIGHT > node.scrollTop + height + ? top + ROW_HEIGHT - height + : null + if (next === null) return + node.scrollTop = next + // Committed to state as well as the DOM: jsdom never fires `scroll`, and a + // real browser fires it a frame late, which would render the old window. + setScrollTop(next) + }, []) + + const focusRow = useCallback((index: number) => { + const entry = visible[index] + if (!entry) return + ensureVisible(index) + // Re-focusing the row that is already active would set a flag no re-render + // ever clears (React bails on an identical state value), and the next + // unrelated render would then steal focus. Focus it directly instead. + if (entry.id === activeRef.current) { + rows.current.get(entry.id)?.focus({ preventScroll: true }) + return + } + wantFocus.current = true + setActiveId(entry.id) + }, [ensureVisible, visible]) + + useLayoutEffect(() => { + if (!wantFocus.current) return + wantFocus.current = false + if (activeId) rows.current.get(activeId)?.focus({ preventScroll: true }) + }, [activeId, scrollTop, visible]) + + // There is always exactly one tab stop. When the active row disappears — its + // folder collapsed, or a filter dropped it — the first row inherits it. + // + // If the vanished row also held focus, focus is on by the time this + // runs and the keyboard is dead: hand it to the row that inherited the tab + // stop, and scroll that row into view. Only when focus was already inside the + // tree, though. The usual way to make a row vanish is to type in the search + // box, and stealing focus out of the box mid-word would be worse than the bug. + useEffect(() => { + if (activeId && indexById.has(activeId)) return + if (!visible[0]) { setActiveId(null); return } + if (hasFocus.current) focusRow(0) + else setActiveId(visible[0].id) + }, [activeId, focusRow, indexById, visible]) + + // Every close is RECORDED, not inferred from the absence of an open marker. + // Two things default to open — layer roots, and everything while a search is + // running — and for those the absence of a marker means open, so a close that + // wrote nothing down was a close that never happened. + const setExpanded = useCallback((id: string, open: boolean) => { + setOpened((prev) => { + const next = new Set(prev) + if (open) next.add(id); else next.delete(id) + return next + }) + setCollapsed((prev) => { + const next = new Set(prev) + if (open) next.delete(id); else next.add(id) + return next + }) + }, []) + + const open = useCallback((entry: TreeEntry) => { + const index = indexById.get(entry.id) + if (index !== undefined) focusRow(index) + if (entry.kind === 'dir') setExpanded(entry.id, !isExpanded(entry.id)) + else onSelect(entry.path) + }, [focusRow, indexById, isExpanded, onSelect, setExpanded]) + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return + const index = activeId ? indexById.get(activeId) ?? -1 : -1 + if (index < 0) return + const entry = visible[index] + const expanded = entry.kind === 'dir' && isExpanded(entry.id) + + switch (event.key) { + case 'ArrowDown': event.preventDefault(); focusRow(Math.min(visible.length - 1, index + 1)); return + case 'ArrowUp': event.preventDefault(); focusRow(Math.max(0, index - 1)); return + case 'Home': event.preventDefault(); focusRow(0); return + case 'End': event.preventDefault(); focusRow(visible.length - 1); return + case 'ArrowRight': + event.preventDefault() + if (entry.kind === 'dir' && !expanded) setExpanded(entry.id, true) + else if (expanded) focusRow(index + 1) + return + case 'ArrowLeft': { + event.preventDefault() + if (expanded) { setExpanded(entry.id, false); return } + const parent = indexById.get(entry.parent) + if (parent !== undefined) focusRow(parent) + return + } + case 'Enter': + case ' ': + event.preventDefault() + open(entry) + return + default: + } + } + + const total = visible.length + const height = viewport || FALLBACK_VIEWPORT + const first = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN) + const last = Math.min(total, Math.ceil((scrollTop + height) / ROW_HEIGHT) + OVERSCAN) + const activeIndex = activeId ? indexById.get(activeId) ?? -1 : -1 + + const renderRow = (index: number) => { + const entry = visible[index] + return ( + + ) + } + + // The active row is rendered even when it lies outside the window — that is + // what keeps focus alive when the wheel carries the window past the row the + // keyboard is on — but it is SPLICED IN AT ITS OWN INDEX, never appended. + // Rows are absolutely positioned, so appending looked right and read wrong: a + // screen reader's browse mode walks a flattened role="tree" in DOM order, and + // the focused row was emitted last, out of sequence with the aria-posinset it + // claims. + // + // Splicing is safe for focus precisely because every row is keyed by its id + // and emitted in ascending index order. React only re-inserts a keyed child + // that has to move BACKWARDS past one of its siblings; scrolling, expanding + // and filtering all add and remove at the edges and leave the survivors in + // the same relative order, so the focused node is never detached and + // re-attached. (An earlier fixed trailing slot bought the same guarantee by + // giving up DOM order — it did not need to.) + const rendered: React.ReactNode[] = [] + if (activeIndex >= 0 && activeIndex < first) rendered.push(renderRow(activeIndex)) + for (let i = first; i < last; i += 1) rendered.push(renderRow(i)) + if (activeIndex >= last) rendered.push(renderRow(activeIndex)) + + return ( +
{ setScrollTop(event.currentTarget.scrollTop); measure() }} + > +
{ hasFocus.current = true }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) hasFocus.current = false + }} + > + {rendered} +
+
+ ) +} diff --git a/apps/console/src/data.ts b/apps/console/src/data.ts index 684a7e2..c38293b 100644 --- a/apps/console/src/data.ts +++ b/apps/console/src/data.ts @@ -68,9 +68,16 @@ export interface Conflict { history: ConflictResolutionRecord[] } -export interface Dissent { layer: LayerId; value: string; updated?: string | null } +/** `sourceLayer` is the source's real name; `layer` is the lane it renders in. */ +export interface Dissent { layer: LayerId; sourceLayer: string; value: string; updated?: string | null } export interface ConceptSection { name: string; winner: LayerId; value: string + /** + * The name of the source that won this section — the manifest's own layer + * name, not the three-lane `winner`. Two sources can share a lane, and only + * this string identifies the one holding the file behind the value. + */ + sourceLayer: string key?: string; updated?: string | null; suppressed?: boolean /** All dissenting layers (surfaced, not hidden). */ dissents?: Dissent[] diff --git a/apps/console/src/desktop.d.ts b/apps/console/src/desktop.d.ts index 43b0aa9..150aacc 100644 --- a/apps/console/src/desktop.d.ts +++ b/apps/console/src/desktop.d.ts @@ -96,7 +96,7 @@ declare global { set(patch: Partial): Promise } commands?: { - onInvoke(cb: (command: 'command-palette' | 'search' | 'ask' | 'settings' | 'toggle-sidebar' | `destination:${1 | 2 | 3 | 4 | 5}`) => void): () => void + onInvoke(cb: (command: 'command-palette' | 'search' | 'ask' | 'settings' | 'toggle-sidebar' | 'view:files' | `destination:${1 | 2 | 3 | 4 | 5}`) => void): () => void } windows?: { openSettings(pane?: DeviceUiState['settingsPane']): Promise<{ opened: boolean; existing: boolean }> @@ -108,6 +108,13 @@ declare global { } /** Open the native macOS directory picker. Null means the user canceled. */ chooseFolder?: () => Promise + /** + * Show a file in Finder. Takes a source name and a path INSIDE that + * source — never an absolute path. The main process resolves it against + * the manifest and refuses anything that escapes the source's folder, + * answering `{ ok: false, error }` rather than throwing. + */ + revealFile?: (layer: string, rel: string) => Promise<{ ok: boolean; error?: string }> /** Fixed native operations for ContextCake's own command-line tool. */ cli: { getStatus: () => Promise diff --git a/apps/console/src/layer-files.ts b/apps/console/src/layer-files.ts new file mode 100644 index 0000000..f32569a --- /dev/null +++ b/apps/console/src/layer-files.ts @@ -0,0 +1,111 @@ +// The `/api/files` listing and the `/api/file` read, shared by every view that +// needs them. +// +// Sources reads the listing for a source's file count and root path; Files reads +// it to build the navigator tree; the concept detail reads it to find the file +// behind each contributor. One module so they all ask the same question the +// same way — and so a source that owns no local files (a remote graph, a +// REST-read repo) is absent from the payload in exactly one place, which is +// what lets every consumer explain that state instead of rendering an empty +// list or an "open file" link that opens nothing. +// +// This is also the mode seam for files, mirroring `api.ts`: +// +// demo mode — a build-time snapshot of the engine's own file APIs over the +// demo bundle (scripts/build-demo-data.mjs). Never hand-authored, +// and read-only: it holds the two GET answers and nothing else, +// so nothing in the demo can pretend a write happened. +// live mode — the same-origin engine routes. +// +// Deliberately uncached in live mode. The walk is bounded and cheap — 20–40ms +// and 440KB on a 3,030-file vault — so a per-mount fetch costs less than the +// staleness a shared cache would introduce (a note added in Finder has to show +// up the next time you look). +import { useEffect, useState } from 'react' +import { apiFetch, type Mode } from './api' +import demoFilesRaw from './generated/demo-files.json' +import type { DemoFiles, FileContent, LayerFiles } from './types' + +const demoFiles = demoFilesRaw as unknown as DemoFiles + +export async function fetchLayerFiles(mode: Mode): Promise { + if (mode === 'demo') return demoFiles.layers + const res = await apiFetch('/api/files', { headers: { accept: 'application/json' } }) + const data = await res.json().catch(() => ({}) as { error?: string; layers?: LayerFiles[] }) + if (!res.ok) throw new Error((data as { error?: string }).error ?? `Server returned ${res.status}`) + return (data as { layers?: LayerFiles[] }).layers ?? [] +} + +/** One file's content and metadata — the demo answers from the snapshot. */ +export async function readLayerFile(mode: Mode, path: string): Promise { + if (mode === 'demo') { + const file = demoFiles.files[path] + // Same words the engine uses for a path it cannot resolve, so a caller that + // renders the message reads the same in both modes. + if (!file) throw new Error(`Not found: ${path}`) + return file + } + const res = await apiFetch(`/api/file?path=${encodeURIComponent(path)}`, { headers: { accept: 'application/json' } }) + const data = await res.json().catch(() => ({}) as { error?: string }) + if (!res.ok) throw new Error((data as { error?: string }).error ?? `Server returned ${res.status}`) + return data as FileContent +} + +/** + * The `revalidate` value every caller passes — one function so all three ask + * the same question, because getting it wrong is invisible until you rename + * something. + * + * The source *count* is not it. A rename and a repoint both leave it identical, + * so the listing kept answering for the old layer name and the old root until + * something remounted the view: a renamed folder read "None on this machine" + * with its Browse button gone, and a repointed one went on quoting the folder + * it no longer reads. The names cover add/remove/rename — including one made + * outside this app, which arrives through the poll rather than through a write + * — and `reloadKey` covers a repoint, where the name is the one thing that did + * not change. NUL-joined so "a b" + "c" can never collide with "a" + "b c". + */ +export function filesRevalidation(sources: readonly { name: string }[], reloadKey: number): string { + return [String(reloadKey), ...sources.map((source) => source.name)].join('\u0000') +} + +export interface LayerFilesState { + /** Null until the first answer lands — "unknown", never "empty". */ + layers: LayerFiles[] | null + error: string | null +} + +/** + * `revalidate` re-runs the walk when it changes; pass `filesRevalidation(…)` + * rather than something hand-rolled. The listing is cheap even mid-index, so + * this deliberately does not wait on the cascade. The demo snapshot is already + * in the bundle, so it is the initial state rather than something to wait a + * frame for — the tree must not flash its loading skeleton over data the page + * shipped with. + */ +export function useLayerFiles(mode: Mode, revalidate: unknown): LayerFilesState { + const [state, setState] = useState( + () => ({ layers: mode === 'demo' ? demoFiles.layers : null, error: null }), + ) + + useEffect(() => { + if (mode === 'demo') { + // Same array identity as the initial state, so React bails out instead of + // re-rendering the tree every time `revalidate` moves. + setState((current) => (current.layers === demoFiles.layers ? current : { layers: demoFiles.layers, error: null })) + return + } + let cancelled = false + void (async () => { + try { + const layers = await fetchLayerFiles(mode) + if (!cancelled) setState({ layers, error: null }) + } catch (e) { + if (!cancelled) setState({ layers: null, error: e instanceof Error ? e.message : String(e) }) + } + })() + return () => { cancelled = true } + }, [mode, revalidate]) + + return state +} diff --git a/apps/console/src/reveal.ts b/apps/console/src/reveal.ts new file mode 100644 index 0000000..a40b161 --- /dev/null +++ b/apps/console/src/reveal.ts @@ -0,0 +1,40 @@ +// "Reveal in Finder", the console half. +// +// Desktop only, and ABSENT rather than disabled on the web: a control that can +// never do anything says less than no control at all. `available` is what the +// views gate on, so nothing about Finder ships into the browser build's UI. +// +// The bridge takes a source name and a path inside it — never an absolute +// path. The main process resolves it against the manifest and refuses anything +// that escapes the source's folder (apps/desktop/src/main/reveal.mjs), so the +// error this hook surfaces can be a refusal as well as a missing file. +import { useCallback, useState } from 'react' + +export interface RevealState { + /** True only inside the desktop app. Hide the control entirely when false. */ + available: boolean + /** The last refusal, verbatim from the main process. */ + error: string | null + reveal: (layer: string, rel: string) => Promise + clearError: () => void +} + +export function useReveal(): RevealState { + const [error, setError] = useState(null) + const available = typeof window.__CC_DESKTOP?.revealFile === 'function' + + const reveal = useCallback(async (layer: string, rel: string) => { + const bridge = window.__CC_DESKTOP?.revealFile + if (!bridge) return + setError(null) + try { + const result = await bridge(layer, rel) + if (!result?.ok) setError(result?.error ?? 'That file could not be revealed.') + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + const clearError = useCallback(() => setError(null), []) + return { available, error, reveal, clearError } +} diff --git a/apps/console/src/shell-navigation.test.ts b/apps/console/src/shell-navigation.test.ts index f878871..bc89fde 100644 --- a/apps/console/src/shell-navigation.test.ts +++ b/apps/console/src/shell-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { destinationForView, parseHash, viewForDestination } from './shell-navigation' +import { destinationForView, filesHash, parseHash, viewForDestination } from './shell-navigation' describe('shell navigation contract', () => { it('maps every stable ViewId into one of five destinations', () => { @@ -25,3 +25,40 @@ describe('shell navigation contract', () => { expect(parseHash('')).toEqual({}) }) }) + +describe('files deep links', () => { + it('round-trips scope and file through the hash', () => { + const cases: [string | null, string | null][] = [ + [null, null], + ['vault', null], + ['vault', 'vault/Daily Notes/2024-01-05.md'], + ['my vault', 'my vault/a/b/c/deep note.md'], + // A file from another source is not addressable while unscoped, so the + // hash carries the scope alone — and parsing it back agrees. + ['vault', 'other/x.md'], + ] + for (const [layer, file] of cases) { + const hash = filesHash(layer, file) + const parsed = parseHash(hash) + expect(filesHash(parsed.layer ?? null, parsed.file ?? null)).toBe(hash) + expect(parsed.layer ?? null).toBe(layer) + } + }) + + it('serializes the two shapes the navigator can be in', () => { + expect(filesHash(null)).toBe('#/files') + expect(filesHash('vault')).toBe('#/files/vault') + expect(filesHash('vault', 'vault/notes/a.md')).toBe('#/files/vault/notes%2Fa.md') + // A file outside the scope cannot be named by this route; the scope wins. + expect(filesHash('vault', 'other/a.md')).toBe('#/files/vault') + }) + + it('parses a files link into the state the view restores', () => { + expect(parseHash('#/files')).toEqual({ view: 'files' }) + expect(parseHash('#/files/vault')).toEqual({ view: 'files', layer: 'vault' }) + expect(parseHash('#/files/my%20vault/Daily%20Notes%2F2024-01-05.md')) + .toEqual({ view: 'files', layer: 'my vault', file: 'my vault/Daily Notes/2024-01-05.md' }) + // A malformed escape must not throw the whole shell into the error state. + expect(parseHash('#/files/%E0%A4%A')).toEqual({ view: 'files' }) + }) +}) diff --git a/apps/console/src/shell-navigation.ts b/apps/console/src/shell-navigation.ts index b2f66db..0427853 100644 --- a/apps/console/src/shell-navigation.ts +++ b/apps/console/src/shell-navigation.ts @@ -37,7 +37,20 @@ export function viewForDestination( return reviewView } -export function parseHash(hash: string): { view?: ViewId; concept?: string } { +/** + * What a location hash says. `layer`/`file` belong to the Files route: + * `layer` is the source the navigator is scoped to and `file` is the engine's + * own `/` path — the same string `/api/file?path=` takes, so a + * deep link needs no translation on either side. + */ +export interface ParsedHash { + view?: ViewId + concept?: string + layer?: string + file?: string +} + +export function parseHash(hash: string): ParsedHash { const value = hash.replace(/^#\/?/, '') if (!value) return {} const slash = value.indexOf('/') @@ -48,9 +61,36 @@ export function parseHash(hash: string): { view?: ViewId; concept?: string } { try { return { view: candidate, concept: decodeURIComponent(rest) } } catch { return { view: candidate } } } + if (candidate === 'files' && rest) { + // Two segments, each percent-encoded on its own, so a layer name with a + // space and a note nested six folders deep both survive the round trip. + const cut = rest.indexOf('/') + try { + const layer = decodeURIComponent(cut === -1 ? rest : rest.slice(0, cut)) + if (!layer) return { view: candidate } + const rel = cut === -1 ? '' : decodeURIComponent(rest.slice(cut + 1)) + return rel ? { view: candidate, layer, file: `${layer}/${rel}` } : { view: candidate, layer } + } catch { return { view: candidate } } + } return { view: candidate } } +/** + * The Files route as a hash. The scope is the addressable part: a file is + * carried only while the navigator is scoped to the layer that holds it, so + * `parseHash(filesHash(scope, file))` restores exactly the state it left. + * Unscoped browsing is `#/files` — a stable URL, not an alias for whichever + * file happened to be open (the same bare/deep split Concepts already makes). + */ +export function filesHash(layer: string | null, file?: string | null): string { + if (!layer) return '#/files' + const prefix = `${layer}/` + const rel = file && file.startsWith(prefix) ? file.slice(prefix.length) : '' + return rel + ? `#/files/${encodeURIComponent(layer)}/${encodeURIComponent(rel)}` + : `#/files/${encodeURIComponent(layer)}` +} + export function dispatchNavigationGuard(): boolean { return window.dispatchEvent(new Event('contextcake:before-navigate', { cancelable: true })) } diff --git a/apps/console/src/store.test.tsx b/apps/console/src/store.test.tsx index 96bf6d3..2fbb79f 100644 --- a/apps/console/src/store.test.tsx +++ b/apps/console/src/store.test.tsx @@ -33,7 +33,7 @@ let root: Root /** Renders the store's load state so assertions read it from the DOM. */ function Probe() { - const { load, concepts, sources, reload, retryNow, view, selConcept } = useStore() + const { load, concepts, sources, reload, retryNow, view, selConcept, filesScope, filesPath, openFilesScope, setView, setFilesScope, setFilesPath } = useStore() return (
`${t.name}:${t.phase}:${t.loaded}/${t.total ?? '?'}${t.refreshing ? ':refreshing' : ''}`).join(',')} > + + + +
) } @@ -401,4 +407,134 @@ describe('store load state', () => { expect(probe().dataset.selection).toBe('interfaces/auth') expect(window.location.hash).toBe('#/concepts') }) + it('lands the Files view scoped to a source, and puts the scope in the URL', async () => { + mocks.graph.mockResolvedValue(graphPayload([])) + mocks.resolveAll.mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false }) + await act(async () => root.render()) + + await act(async () => (Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'browse team-docs') as HTMLButtonElement).click()) + expect(probe().dataset.view).toBe('files') + expect(probe().dataset.filesScope).toBe('team-docs') + expect(window.location.hash).toBe('#/files/team-docs') + + await act(async () => (Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'open a.md') as HTMLButtonElement).click()) + expect(window.location.hash).toBe('#/files/team-docs/notes%2Fa.md') + + // Clearing the scope keeps the open file — it just widens the navigator. + await act(async () => (Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'clear scope') as HTMLButtonElement).click()) + expect(probe().dataset.filesScope).toBe('') + expect(probe().dataset.filesPath).toBe('team-docs/notes/a.md') + expect(window.location.hash).toBe('#/files') + }) + + it('restores scope and file from a deep link, and from Back', async () => { + window.history.replaceState(null, '', '/#/files/team-docs/notes%2Fa.md') + mocks.graph.mockResolvedValue(graphPayload([])) + mocks.resolveAll.mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false }) + await act(async () => root.render()) + + expect(probe().dataset.view).toBe('files') + expect(probe().dataset.filesScope).toBe('team-docs') + expect(probe().dataset.filesPath).toBe('team-docs/notes/a.md') + + window.history.pushState(null, '', '#/files') + await act(async () => window.dispatchEvent(new PopStateEvent('popstate'))) + expect(probe().dataset.filesScope).toBe('') + expect(probe().dataset.filesPath).toBe('') + }) +}) + +// ---- Back/Forward vs. an unsaved file -------------------------------------- +// +// Real session-history traversal, not a synthesized PopStateEvent: what these +// cover is *which entry* a refused navigation writes to, and a hand-fired event +// leaves the history stack untouched, so it cannot see the difference. + +const FILE_HASH = '#/files/team-docs/notes%2Fa.md' + +function click(label: string) { + const match = Array.from(container.querySelectorAll('button')).find((b) => b.textContent === label) + if (!match) throw new Error(`Button not found: ${label}`) + return act(async () => (match as HTMLButtonElement).click()) +} + +/** A real Back, awaited: jsdom traverses the stack on a task, like a browser. */ +async function goBack() { + window.history.back() + await act(async () => { await vi.advanceTimersByTimeAsync(20) }) +} + +describe('navigating away from an unsaved file', () => { + /** Stands in for the editor: it only listens while there is a draft to lose. */ + let prompts: string[] + let guard: (event: Event) => void + + beforeEach(() => { + prompts = [] + guard = (event: Event) => { prompts.push(window.location.hash); event.preventDefault() } + mocks.graph.mockResolvedValue(graphPayload([])) + mocks.resolveAll.mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false }) + }) + + afterEach(() => window.removeEventListener('contextcake:before-navigate', guard)) + + it('asks before Back moves between two Files URLs, not only between views', async () => { + // Two adjacent Files entries. Same view, different document — the shape the + // view-only guard walked straight through, taking the draft with it. + window.history.replaceState(null, '', '/#/files') + window.history.pushState(null, '', FILE_HASH) + await act(async () => root.render()) + expect(probe().dataset.filesPath).toBe('team-docs/notes/a.md') + + window.addEventListener('contextcake:before-navigate', guard) + await goBack() + + // Asked once, and the answer was honoured: the document is still open and + // the URL still names it. + expect(prompts).toHaveLength(1) + expect(probe().dataset.filesPath).toBe('team-docs/notes/a.md') + expect(window.location.hash).toBe(FILE_HASH) + + // Saved (nothing left to lose) — the same Back now goes through. + window.removeEventListener('contextcake:before-navigate', guard) + await goBack() + expect(probe().dataset.filesPath).toBe('') + expect(window.location.hash).toBe('#/files') + }) + + it('restores the whole Files URL on cancel, and leaves the entry behind it alone', async () => { + // The 3-step path: Sources → a file → Back (cancelled) → Back again. The + // first refusal used to rewrite the *Sources* entry to a bare `#/files`, + // which both mis-described the screen and turned the next Back into a + // same-view move the guard did not cover. + await act(async () => root.render()) + await click('to sources') + await click('browse team-docs') + await click('open a.md') + expect(window.location.hash).toBe(FILE_HASH) + + window.addEventListener('contextcake:before-navigate', guard) + await goBack() + + expect(prompts).toHaveLength(1) + expect(probe().dataset.view).toBe('files') + expect(probe().dataset.filesScope).toBe('team-docs') + expect(probe().dataset.filesPath).toBe('team-docs/notes/a.md') + // The whole URL, scope and file included — not `#/files`. + expect(window.location.hash).toBe(FILE_HASH) + + // Step 3: the entry behind this one is still the Sources view the user + // actually visited, so it still asks, and still keeps the draft. + await goBack() + expect(prompts).toHaveLength(2) + expect(prompts[1]).toBe('#/sources') + expect(probe().dataset.filesPath).toBe('team-docs/notes/a.md') + expect(window.location.hash).toBe(FILE_HASH) + + // And once there is nothing to lose, Back lands where it always should have. + window.removeEventListener('contextcake:before-navigate', guard) + await goBack() + expect(probe().dataset.view).toBe('sources') + expect(window.location.hash).toBe('#/sources') + }) }) diff --git a/apps/console/src/store.tsx b/apps/console/src/store.tsx index b1fdda3..741da37 100644 --- a/apps/console/src/store.tsx +++ b/apps/console/src/store.tsx @@ -11,7 +11,7 @@ import { } from './api' import type { GraphSummary, SourceStatus } from './types' import type { LayerId, RouteId } from './theme' -import { dispatchNavigationGuard, isViewId, parseHash, type ViewId } from './shell-navigation' +import { dispatchNavigationGuard, filesHash, isViewId, parseHash, type ViewId } from './shell-navigation' export type { ViewId } from './shell-navigation' export type TriageTab = 'review' | 'captured' | 'ignored' @@ -20,10 +20,10 @@ const BROWSER_LAST_VIEW_KEY = 'contextcake.lastView' const BROWSER_KNOWLEDGE_VIEW_KEY = 'contextcake.knowledgeView' const BROWSER_REVIEW_VIEW_KEY = 'contextcake.reviewView' -function initialRoute(): { view: ViewId; concept?: string } { +function initialRoute(): { view: ViewId; concept?: string; layer?: string; file?: string } { if (typeof window === 'undefined') return { view: 'overview' } const explicit = parseHash(window.location.hash) - if (explicit.view) return { view: explicit.view, concept: explicit.concept } + if (explicit.view) return { view: explicit.view, concept: explicit.concept, layer: explicit.layer, file: explicit.file } const desktop = window.__CC_DESKTOP?.uiState?.initial.lastView if (isViewId(desktop)) return { view: desktop } try { @@ -167,6 +167,10 @@ export interface Store { selSignal: string | null selConflict: string selConcept: string + /** Files navigator: the one source it is scoped to, or null for every source. */ + filesScope: string | null + /** The open file as the engine names it (`/`), or null. */ + filesPath: string | null query: string chatOpen: boolean chatBusy: boolean @@ -188,6 +192,17 @@ export interface Store { setSelSignal: (id: string | null) => void setSelConflict: (id: string) => void setSelConcept: (id: string) => void + /** Narrow the navigator to one source, or clear it. Leaves the open file alone. */ + setFilesScope: (layer: string | null) => void + setFilesPath: (path: string | null) => void + /** + * Go to Files scoped to a source — the "Browse files" action and its palette + * twin. `file` (an engine `/` path) opens one specific file, for + * the cross-link from a concept's contributor. + */ + openFilesScope: (layer: string | null, file?: string | null) => void + /** Go to Concepts on one concept — the cross-link from the file behind it. */ + openConcept: (id: string) => void setQuery: (q: string) => void openChat: () => void closeChat: () => void @@ -201,6 +216,14 @@ export interface Store { resolveSafeConflicts: () => Promise send: (text?: string) => void reload: () => void + /** + * Bumped by every `reload()`. Exposed because a write can change what a + * secondary read returns without changing anything visible in `sources` — + * repointing a source keeps its name, level and count and moves only the + * folder underneath it. Anything deriving from a separate endpoint keys its + * refetch on this (see `filesRevalidation` in `layer-files.ts`). + */ + reloadKey: number } const StoreContext = createContext(null) @@ -245,6 +268,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { // split without turning itself into a deep link. An explicit row selection // switches the route to the deep-link form. const [conceptRouteMode, setConceptRouteMode] = useState<'bare' | 'deep'>(initial.concept ? 'deep' : 'bare') + const [filesScope, setFilesScopeState] = useState(initial.layer ?? null) + const [filesPath, setFilesPathState] = useState(initial.file ?? null) const setSelConcept = useCallback((id: string) => { setConceptRouteMode('deep') setSelConceptState(id) @@ -524,6 +549,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { const selSignalRef = useRef(selSignal); selSignalRef.current = selSignal const selConflictRef = useRef(selConflict); selConflictRef.current = selConflict const selConceptRef = useRef(selConcept); selConceptRef.current = selConcept + const conceptRouteModeRef = useRef(conceptRouteMode); conceptRouteModeRef.current = conceptRouteMode + const filesScopeRef = useRef(filesScope); filesScopeRef.current = filesScope + const filesPathRef = useRef(filesPath); filesPathRef.current = filesPath const chatBusyRef = useRef(chatBusy); chatBusyRef.current = chatBusy const chatInputRef = useRef(chatInput); chatInputRef.current = chatInput const conceptsRef = useRef(concepts); conceptsRef.current = concepts @@ -540,6 +568,42 @@ export function StoreProvider({ children }: { children: ReactNode }) { setViewState(next) }, [view]) + const setFilesScope = useCallback((layer: string | null) => setFilesScopeState(layer), []) + const setFilesPath = useCallback((path: string | null) => setFilesPathState(path), []) + + /** + * "Browse files in ". Runs the navigation guard itself rather than + * going through setView, which would ask a second time — and drops an open + * file that belongs to a different source, so the navigator and the editor + * never disagree about which source you are looking at. + * + * `file` is the engine's own `/` path, for arriving at one + * specific file (a concept's "open file" link). Passing one from another + * source is a caller bug, so it is ignored rather than silently changing the + * scope out from under the navigator. + */ + const openFilesScope = useCallback((layer: string | null, file?: string | null) => { + if (!dispatchNavigationGuard()) return + setFilesScopeState(layer) + const wanted = file && (!layer || file.startsWith(`${layer}/`)) ? file : null + if (wanted) setFilesPathState(wanted) + else setFilesPathState((current) => (layer && current && !current.startsWith(`${layer}/`) ? null : current)) + setViewState('files') + }, []) + + /** + * "Open the concept behind this file" — the mirror of openFilesScope, and + * guarded once for the same reason: setView would ask about unsaved changes, + * and then setSelConcept would have already moved the selection whether the + * user said yes or not. + */ + const openConcept = useCallback((id: string) => { + if (!dispatchNavigationGuard()) return + setConceptRouteMode('deep') + setSelConceptState(id) + setViewState('concepts') + }, []) + useEffect(() => { window.__CC_DESKTOP?.uiState?.set({ lastView: view, @@ -555,6 +619,20 @@ export function StoreProvider({ children }: { children: ReactNode }) { } }, [view]) + /** + * The hash that describes what is on screen right now — the one place that + * knows how each view addresses itself. Read through refs so the popstate + * handler (which only re-subscribes on `view`) can call it without ever + * restoring a stale URL. + */ + const currentHash = useCallback((): string => { + if (view === 'files') return filesHash(filesScopeRef.current, filesPathRef.current) + if (view === 'concepts' && selConceptRef.current && conceptRouteModeRef.current === 'deep') { + return `#/concepts/${encodeURIComponent(selConceptRef.current)}` + } + return `#/${view}` + }, [view]) + // URL hash ⇄ state: reflect view/selected-concept for deep links, restore on // load (above), and support back/forward. pushState on view change (a real // navigation), replaceState within a view (selection tweak) to avoid spam. @@ -563,24 +641,35 @@ export function StoreProvider({ children }: { children: ReactNode }) { // leave the URL alone — rewriting it here would permanently clobber the // deep link before the data arrives to honor it. if (pendingConceptRef.current) return - const target = view === 'concepts' && selConcept && conceptRouteMode === 'deep' - ? `#/concepts/${encodeURIComponent(selConcept)}` - : `#/${view}` + const target = currentHash() if (window.location.hash === target) { prevViewRef.current = view; return } const viewChanged = prevViewRef.current !== view prevViewRef.current = view if (viewChanged) window.history.pushState(null, '', target) else window.history.replaceState(null, '', target) - }, [conceptRouteMode, view, selConcept]) + }, [conceptRouteMode, currentHash, view, selConcept, filesScope, filesPath]) useEffect(() => { const onPop = () => { const p = parseHash(window.location.hash) - if (p.view && p.view !== view) { + if (!p.view) return + // What the entry would OPEN, not merely which view it names. Two adjacent + // #/files entries share a view and differ in the document, and an unsaved + // draft belongs to the document — gating the guard on the view alone let + // Back walk between two Files URLs and discard typed text with no prompt + // at all. Nothing prompts unless something is dirty: the guard is a + // cancelable event and the editor only listens while it holds edits. + const movesFile = p.view === 'files' && (p.file ?? null) !== filesPathRef.current + if (p.view !== view || movesFile) { if (!dispatchNavigationGuard()) { - const current = view === 'concepts' && selConceptRef.current - ? `#/concepts/${encodeURIComponent(selConceptRef.current)}` : `#/${view}` - window.history.replaceState(null, '', current) + // Put the screen the user is still on back on top of the stack — + // pushState, and the *whole* URL (scope and open file included), not + // `#/${view}`. The popstate has already moved the session onto the + // NEIGHBOURING entry, so replacing "the current entry" would rewrite + // the page they came from: Back would then lead back here instead of + // where they actually were, and a reload would land on a URL that no + // longer describes the screen. + window.history.pushState(null, '', currentHash()) return } setViewState(p.view) @@ -592,10 +681,16 @@ export function StoreProvider({ children }: { children: ReactNode }) { setConceptRouteMode(p.concept ? 'deep' : 'bare') setSelConceptState(p.concept ?? conceptsRef.current[0]?.id ?? '') } + // Back/Forward across the Files route restores exactly what the hash + // says, scope included — a bare #/files really means "every source". + if (p.view === 'files') { + setFilesScopeState(p.layer ?? null) + setFilesPathState(p.file ?? null) + } } window.addEventListener('popstate', onPop) return () => window.removeEventListener('popstate', onPop) - }, [view]) + }, [currentHash, view]) const filtered = useCallback((tab: TriageTab): Signal[] => { const route = TAB_TO_ROUTE[tab] @@ -740,13 +835,14 @@ export function StoreProvider({ children }: { children: ReactNode }) { const value = useMemo(() => ({ mode, loading, load, error, - view, triageTab, selSignal, selConflict, selConcept, query, + view, triageTab, selSignal, selConflict, selConcept, filesScope, filesPath, query, chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, setView, setTriageTab, setSelSignal, setSelConflict, setSelConcept, setQuery, + setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat, setChatInput, - filtered, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, - }), [mode, loading, load, error, view, triageTab, selSignal, selConflict, selConcept, query, chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, filtered, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, setView, setQuery, openChat, closeChat]) + filtered, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, + }), [mode, loading, load, error, view, triageTab, selSignal, selConflict, selConcept, filesScope, filesPath, query, chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, filtered, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, setView, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat]) return {children} } diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css index d3fbbd4..d611bde 100644 --- a/apps/console/src/styles.css +++ b/apps/console/src/styles.css @@ -1226,6 +1226,48 @@ button { font-family: inherit; } .cc-sources-empty h2 { margin: 0 0 7px; font-size: 15px; font-weight: 600; } .cc-sources-empty p { margin: 0 0 14px; color: var(--cc-caption); font-size: 12px; line-height: 1.5; } +/* ---- Source navigator (Files tree) --------------------------------------- + Rows are absolutely positioned by index so the list can be windowed; the + height here and ROW_HEIGHT in components/FileTree.tsx are the same number + and have to stay that way. */ +.cc-tree-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 4px 6px 10px; } +.cc-tree { position: relative; width: 100%; } +.cc-tree-row { + position: absolute; left: 0; right: 0; height: 28px; box-sizing: border-box; + display: flex; align-items: center; gap: 6px; padding-right: 8px; + border-radius: 7px; background: transparent; color: var(--cc-body); + cursor: pointer; font-size: 11.5px; line-height: 1.3; white-space: nowrap; user-select: none; + transition: background 150ms var(--cc-ease-out), color 150ms var(--cc-ease-out); +} +.cc-tree-row:hover { background: var(--cc-neutral-fill); } +.cc-tree-row:active { background: var(--cc-line-soft); } +.cc-tree-row[data-kind="file"] { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px; } +.cc-tree-row[data-kind="dir"] { color: var(--cc-ink); font-weight: 500; } +.cc-tree-row[data-root="true"] { font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; font-size: 10.5px; } +/* Selection carries a fill, a left rule, and aria-selected — never colour alone. */ +.cc-tree-row[aria-selected="true"] { + background: var(--cc-teal-fill); color: var(--cc-teal-text); + box-shadow: inset 2px 0 var(--cc-teal-stroke-e); +} +.cc-tree-twisty { flex: 0 0 auto; color: var(--cc-faint); transition: transform 160ms var(--cc-ease-out); } +.cc-tree-row[aria-expanded="true"] > .cc-tree-twisty { transform: rotate(90deg); } +.cc-tree-leaf { flex: 0 0 auto; width: 12px; height: 12px; } +.cc-tree-dot { flex: 0 0 auto; width: 6px; height: 6px; border-radius: 999px; } +/* The floor that keeps a deep row from shrinking to nothing. Flex may not take + this item below 4ch even when the indent has eaten the row — it overflows + into the clip instead, which shows the start of a name rather than a blank + row you can still click. MAX_INDENT_DEPTH in components/FileTree.tsx is the + other half: together they leave the name real width at any depth. */ +.cc-tree-name { min-width: 4ch; overflow: hidden; text-overflow: ellipsis; } +.cc-tree-count { margin-left: auto; padding-left: 8px; color: var(--cc-faint); font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10px; font-weight: 400; text-transform: none; letter-spacing: 0; } + +.cc-files-scope { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 8px 8px 6px; border-bottom: 1px solid var(--cc-line); } +.cc-scope-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 3px 3px 3px 9px; border: 1px solid var(--cc-teal-stroke); border-radius: 999px; background: var(--cc-teal-fill); color: var(--cc-teal-text); font-size: 11px; font-weight: 600; } +.cc-scope-chip > strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +.cc-scope-chip > button { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: 0; border-radius: 999px; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 13px; line-height: 1; } +.cc-scope-chip > button:hover { background: var(--cc-teal-fill2); } +.cc-scope-meta { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cc-caption); font-size: 10.5px; } + @container (max-width: 839px) { .cc-sources-split { display: block; } .cc-source-navigator { height: 100%; border-right: 0; } diff --git a/apps/console/src/types.ts b/apps/console/src/types.ts index 473a8ff..12c3d91 100644 --- a/apps/console/src/types.ts +++ b/apps/console/src/types.ts @@ -255,3 +255,14 @@ export interface DemoBundle { graph: GraphSummary concepts: ResolvedConcept[] } + +/** + * The file half of the demo bundle: one `/api/files` listing plus the + * `/api/file` answer for every path in it, both from the engine's own file APIs + * (build-demo-data.mjs). Read-only by construction — there is no write route to + * snapshot, so the demo has nothing to fake. + */ +export interface DemoFiles { + layers: LayerFiles[] + files: Record +} diff --git a/apps/console/src/views/Canvas.test.ts b/apps/console/src/views/Canvas.test.ts index d721258..e30fe8b 100644 --- a/apps/console/src/views/Canvas.test.ts +++ b/apps/console/src/views/Canvas.test.ts @@ -11,8 +11,9 @@ function concept(id: string, layer: Concept['layers'][number], dissent?: Concept sections: [{ name: 'summary', winner: layer, + sourceLayer: layer, value: id, - dissents: dissent ? [{ layer: dissent, value: `${id}-dissent` }] : undefined, + dissents: dissent ? [{ layer: dissent, sourceLayer: dissent, value: `${id}-dissent` }] : undefined, }], } } diff --git a/apps/console/src/views/Files.test.tsx b/apps/console/src/views/Files.test.tsx index ca8ba00..79c1e59 100644 --- a/apps/console/src/views/Files.test.tsx +++ b/apps/console/src/views/Files.test.tsx @@ -3,13 +3,53 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Files } from './Files' +// The real generated demo bundle — the same JSON the shipped web build imports. +// The demo tests below run against it rather than a fixture, because what they +// are checking is precisely that the build-time snapshot feeds this view. +import demoBundle from '../generated/demo-cascade.json' +import demoFiles from '../generated/demo-files.json' -const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), reload: vi.fn(), store: { mode: 'live', sources: [] as unknown[] } })) +const mocks = vi.hoisted(() => ({ + apiFetch: vi.fn(), + reload: vi.fn(), + openConcept: vi.fn(), + store: { + mode: 'live', + sources: [] as unknown[], + concepts: [] as unknown[], + query: '', + scope: null as string | null, + path: null as string | null, + reloadKey: 0, + }, +})) vi.mock('../api', () => ({ apiFetch: mocks.apiFetch })) -vi.mock('../store', () => ({ - useStore: () => ({ mode: mocks.store.mode, sources: mocks.store.sources, reload: mocks.reload }), -})) +// Scope and selection live in the store now (they are the URL), so the mock has +// to be a real state holder — a frozen object would make every selection a no-op +// and quietly pass tests that assert nothing moved. +vi.mock('../store', async () => { + const { useState } = await import('react') + return { + useStore: () => { + const [filesScope, setFilesScope] = useState(mocks.store.scope) + const [filesPath, setFilesPath] = useState(mocks.store.path) + return { + mode: mocks.store.mode, + sources: mocks.store.sources, + concepts: mocks.store.concepts, + reload: mocks.reload, + reloadKey: mocks.store.reloadKey, + query: mocks.store.query, + filesScope, + filesPath, + setFilesScope, + setFilesPath, + openConcept: mocks.openConcept, + } + }, + } +}) let container: HTMLDivElement let root: Root @@ -30,6 +70,17 @@ const FILES = { ], } +/** One tree row, by the engine path it carries in `title`. */ +function row(path: string): HTMLElement { + const match = container.querySelector(`[role="treeitem"][title="${path}"]`) + if (!match) throw new Error(`Tree row not found: ${path}`) + return match +} + +function rows(): HTMLElement[] { + return Array.from(container.querySelectorAll('[role="treeitem"]')) +} + const MEETING = { path: 'personal/meeting.md', layer: 'personal', @@ -60,8 +111,14 @@ beforeEach(() => { root = createRoot(container) mocks.apiFetch.mockReset() mocks.reload.mockReset() + mocks.openConcept.mockReset() mocks.store.mode = 'live' mocks.store.sources = [] + mocks.store.concepts = [] + mocks.store.query = '' + mocks.store.scope = null + mocks.store.path = null + mocks.store.reloadKey = 0 vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) mocks.apiFetch.mockImplementation(async (url: string) => { @@ -159,7 +216,7 @@ describe('Files view', () => { }) }) await act(async () => root.render()) - await act(async () => button('logo.png').click()) + await act(async () => row('personal/logo.png').click()) expect(mocks.apiFetch).toHaveBeenCalledWith('/api/file/raw?path=personal%2Flogo.png') await act(async () => { @@ -180,7 +237,7 @@ describe('Files view', () => { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set setter?.call(editor, 'unsaved') editor.dispatchEvent(new Event('input', { bubbles: true })) - button('logo.png').click() + row('personal/logo.png').click() }) expect(window.confirm).toHaveBeenCalled() @@ -203,18 +260,524 @@ describe('Files view', () => { expect(window.confirm).toHaveBeenCalled() }) - it('tells demo users why there is nothing to edit', async () => { - mocks.store.mode = 'demo' + it('explains an empty state when no source has files on disk', async () => { + mocks.apiFetch.mockImplementation(async () => json({ layers: [] })) + await act(async () => root.render()) + + expect(container.textContent).toContain('No source keeps files on this machine') + }) +}) + +// ---- the source navigator --------------------------------------------------- + +/** A layer whose files sit flat at the root, so every one of them is visible. */ +function flatLayer(layer: string, count: number) { + return { + layer, kind: 'files', root: `/vault/${layer}`, fileCount: count, truncated: false, + files: Array.from({ length: count }, (_, i) => { + const rel = `note-${String(i).padStart(5, '0')}.md` + return { path: `${layer}/${rel}`, name: rel, rel, ext: '.md', kind: 'text', markdown: true } + }), + } +} + +/** A layer with real nesting, for the collapse/expand contract. */ +const NESTED = { + layers: [{ + layer: 'vault', kind: 'files', root: '/vault', fileCount: 3, truncated: false, + files: [ + { path: 'vault/README.md', name: 'README.md', rel: 'README.md', ext: '.md', kind: 'text', markdown: true }, + { path: 'vault/Projects/alpha.md', name: 'alpha.md', rel: 'Projects/alpha.md', ext: '.md', kind: 'text', markdown: true }, + { path: 'vault/Projects/deep/beta.md', name: 'beta.md', rel: 'Projects/deep/beta.md', ext: '.md', kind: 'text', markdown: true }, + ], + }], +} + +/** + * One file `depth` folders down, with the `rel` that reaches it. Nothing bounds + * nesting — the engine's walk caps files, not depth — so a docs monorepo or a + * foldered vault gets here on its own. + */ +function deepLayer(depth: number) { + const rel = `${Array.from({ length: depth }, (_, i) => `level-${i + 1}`).join('/')}/buried.md` + const listing = { + layers: [{ + layer: 'vault', kind: 'files', root: '/vault', fileCount: 1, truncated: false, + files: [{ path: `vault/${rel}`, name: 'buried.md', rel, ext: '.md', kind: 'text', markdown: true }], + }], + } + return { rel, listing } +} + +function press(target: Element, key: string) { + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) +} + +const active = () => document.activeElement as HTMLElement | null + +describe('Files navigator tree', () => { + it('renders a 5,000-file source without putting 5,000 rows in the DOM', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json({ layers: [flatLayer('vault', 5000)] }) + return json({ ...MEETING, path: 'vault/note-00000.md', rel: 'note-00000.md' }) + }) + await act(async () => root.render()) + + // 5,000 files plus the layer root: the scrollable height claims all of it, + // while the DOM holds only the window. + const tree = container.querySelector('[role="tree"]')! + expect(tree.style.height).toBe(`${5001 * 28}px`) + expect(rows().length).toBeGreaterThan(0) + expect(rows().length).toBeLessThan(500) + }) + + it('keeps a row 20 folders deep readable instead of indenting its name to nothing', async () => { + const DEPTH = 20 + const { rel, listing } = deepLayer(DEPTH) + mocks.store.query = 'buried' // a filter opens every folder, which is how you reach a deep row + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(listing) + return json({ ...MEETING, path: `vault/${rel}`, rel }) + }) + await act(async () => root.render()) + + const leaf = row(`vault/${rel}`) + expect(leaf.querySelector('.cc-tree-name')?.textContent).toBe('buried.md') + + // The navigator column is minmax(220px, 300px) and .cc-tree-scroll adds + // 12px of horizontal padding, so a row is 208px wide at its narrowest. + // jsdom lays nothing out, so assert the one number that decides whether a + // name gets any width at all: the indent has to leave room for the twisty, + // the gap, the row's right padding and the name's own 4ch floor. Unclamped, + // depth 21 asks for 281px of a 208px row and the name renders at zero. + const NARROWEST_ROW = 220 - 12 + const CHROME = 12 + 6 + 8 // leaf icon, gap, right padding + const NAME_FLOOR = 4 * 6.7 // .cc-tree-name min-width: 4ch, 11px JetBrains Mono + expect(Number.parseFloat(leaf.style.paddingLeft)).toBeLessThanOrEqual(NARROWEST_ROW - CHROME - NAME_FLOOR) + + // The picture flattens past the cap; the tree's own account of itself does + // not. Screen readers read depth off aria-level, and it is still the truth. + expect(leaf.getAttribute('aria-level')).toBe(String(DEPTH + 2)) + }) + + it('collapses and expands a folder', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(NESTED) + return json({ ...MEETING, path: 'vault/README.md', rel: 'README.md' }) + }) + await act(async () => root.render()) + + // Folders start closed; the layer root does not. + expect(row('vault/Projects').getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('[title="vault/Projects/alpha.md"]')).toBeNull() + + await act(async () => row('vault/Projects').click()) + expect(row('vault/Projects').getAttribute('aria-expanded')).toBe('true') + expect(row('vault/Projects/alpha.md')).toBeTruthy() + // One level only — the nested folder is its own decision. + expect(container.querySelector('[title="vault/Projects/deep/beta.md"]')).toBeNull() + + await act(async () => row('vault/Projects').click()) + expect(container.querySelector('[title="vault/Projects/alpha.md"]')).toBeNull() + }) + + it('is a keyboard tree: arrows move, expand, collapse, and reach the parent', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(NESTED) + return json({ ...MEETING, path: 'vault/README.md', rel: 'README.md' }) + }) + await act(async () => root.render()) + + const start = row('vault') + expect(start.getAttribute('tabindex')).toBe('0') // exactly one tab stop + expect(rows().filter((r) => r.getAttribute('tabindex') === '0')).toHaveLength(1) + + await act(async () => press(start, 'ArrowDown')) + expect(active()?.title).toBe('vault/Projects') + + await act(async () => press(active()!, 'ArrowRight')) // expands + expect(row('vault/Projects').getAttribute('aria-expanded')).toBe('true') + await act(async () => press(active()!, 'ArrowRight')) // now moves to first child + expect(active()?.title).toBe('vault/Projects/deep') + + await act(async () => press(active()!, 'ArrowLeft')) // collapsed already → parent + expect(active()?.title).toBe('vault/Projects') + await act(async () => press(active()!, 'ArrowLeft')) // expanded → collapse + expect(row('vault/Projects').getAttribute('aria-expanded')).toBe('false') + + await act(async () => press(active()!, 'End')) + expect(active()?.title).toBe('vault/README.md') + await act(async () => press(active()!, 'Enter')) + expect(mocks.apiFetch).toHaveBeenCalledWith('/api/file?path=vault%2FREADME.md', expect.anything()) + }) + + it('keeps keyboard focus alive across the virtualization boundary', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json({ layers: [flatLayer('vault', 5000)] }) + return json({ ...MEETING, path: 'vault/note-00000.md', rel: 'note-00000.md' }) + }) + await act(async () => root.render()) + + await act(async () => row('vault').focus()) + for (let i = 0; i < 60; i += 1) { + // eslint-disable-next-line no-await-in-loop -- one keystroke at a time is the point + await act(async () => press(active()!, 'ArrowDown')) + } + + // Row 60 is far outside the first window. Focus is on it, it is in the DOM, + // and the DOM is still small — the row is rendered *because* it has focus. + expect(active()?.getAttribute('role')).toBe('treeitem') + expect(active()?.title).toBe('vault/note-00059.md') + expect(document.activeElement).not.toBe(document.body) + expect(rows().length).toBeLessThan(500) + expect(rows().filter((r) => r.getAttribute('tabindex') === '0')).toHaveLength(1) + + // And back up: still focused, still one tab stop, still bounded. + for (let i = 0; i < 60; i += 1) { + // eslint-disable-next-line no-await-in-loop -- ditto + await act(async () => press(active()!, 'ArrowUp')) + } + expect(active()?.title).toBe('vault') + expect(rows().length).toBeLessThan(500) + }) + + it('keeps a focused row mounted when the list is scrolled past it', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json({ layers: [flatLayer('vault', 5000)] }) + return json({ ...MEETING, path: 'vault/note-00000.md', rel: 'note-00000.md' }) + }) + await act(async () => root.render()) + await act(async () => row('vault').focus()) + await act(async () => press(active()!, 'ArrowDown')) + const focused = active()! + expect(focused.title).toBe('vault/note-00000.md') + + // The wheel, not the keyboard: nothing moved the focused row, the window + // moved out from under it. Without the always-render rule for the active + // row, this unmounts the focused node and focus falls back to . + const scroller = container.querySelector('.cc-tree-scroll')! + Object.defineProperty(scroller, 'scrollTop', { value: 90_000, configurable: true, writable: true }) + await act(async () => scroller.dispatchEvent(new Event('scroll'))) + + expect(rows().length).toBeLessThan(60) + expect(rows().map((r) => r.title)).toContain('vault/note-00000.md') + expect(document.activeElement).toBe(focused) + expect(document.activeElement).not.toBe(document.body) + // The window really did move — the focused row is stranded thousands of + // rows behind it, not merely still in view. + expect(rows().filter((r) => /note-03\d{3}\.md$/.test(r.title)).length).toBeGreaterThan(10) + }) + + it('emits rows in visual order, so browse mode reads the focused row in its place', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json({ layers: [flatLayer('vault', 5000)] }) + return json({ ...MEETING, path: 'vault/note-00000.md', rel: 'note-00000.md' }) + }) + await act(async () => root.render()) + await act(async () => row('vault').focus()) + await act(async () => press(active()!, 'ArrowDown')) + expect(active()?.title).toBe('vault/note-00000.md') + + // Rows are absolutely positioned, so the picture says nothing about whether + // the accessibility tree agrees with it — and sequential reading of a + // flattened role="tree" follows DOM order, not `top`. + const tops = () => rows().map((r) => Number.parseFloat(r.style.top)) + const ascending = (values: number[]) => values.every((v, i) => i === 0 || v > values[i - 1]) + + expect(ascending(tops())).toBe(true) + expect(rows()[rows().length - 1].title).not.toBe(active()?.title) + + // And once the window has moved past the focused row: still in the DOM — + // that is the focus guarantee — and now first, which is where it belongs. + const scroller = container.querySelector('.cc-tree-scroll')! + Object.defineProperty(scroller, 'scrollTop', { value: 90_000, configurable: true, writable: true }) + await act(async () => scroller.dispatchEvent(new Event('scroll'))) + + expect(document.activeElement).not.toBe(document.body) + expect(rows()[0].title).toBe('vault/note-00000.md') + expect(ascending(tops())).toBe(true) + }) + + it('opens a file on arrival without reorganizing the tree, but a deep link reveals its own', async () => { + const nested = { + layers: [{ + layer: 'vault', kind: 'files', root: '/vault', fileCount: 3, truncated: false, + files: [ + { path: 'vault/Projects/alpha.md', name: 'alpha.md', rel: 'Projects/alpha.md', ext: '.md', kind: 'text', markdown: true }, + { path: 'vault/Projects/deep/beta.md', name: 'beta.md', rel: 'Projects/deep/beta.md', ext: '.md', kind: 'text', markdown: true }, + ], + }], + } + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(nested) + return json({ ...MEETING, path: 'vault/Projects/alpha.md', rel: 'Projects/alpha.md' }) + }) + await act(async () => root.render()) + + // A file is open — the detail pane is never blank — but the folder + // overview is intact: nothing the user did not ask for got expanded. + expect(mocks.apiFetch).toHaveBeenCalledWith('/api/file?path=vault%2FProjects%2Falpha.md', expect.anything()) + expect(row('vault/Projects').getAttribute('aria-expanded')).toBe('false') + expect(rows()).toHaveLength(2) + + // The same file named in the URL is a request, and does reveal itself. + // A fresh key remounts, so the deep-linked path is the arriving state. + mocks.store.path = 'vault/Projects/deep/beta.md' + await act(async () => root.render()) + expect(row('vault/Projects').getAttribute('aria-expanded')).toBe('true') + expect(row('vault/Projects/deep').getAttribute('aria-expanded')).toBe('true') + expect(row('vault/Projects/deep/beta.md').getAttribute('aria-selected')).toBe('true') + }) + + it('scopes to one source and clears back to every source', async () => { + mocks.store.sources = [ + { name: 'vault', layer: 'personal', sourceKind: 'files' }, + { name: 'team-docs', layer: 'team', sourceKind: 'files' }, + ] + mocks.store.scope = 'vault' + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json({ layers: [flatLayer('vault', 3), flatLayer('team-docs', 3)] }) + return json({ ...MEETING, path: 'vault/note-00000.md', rel: 'note-00000.md' }) + }) + await act(async () => root.render()) + + expect(row('vault')).toBeTruthy() + expect(container.querySelector('[title="team-docs"]')).toBeNull() + expect(container.textContent).toContain('/vault/vault') // the root path, in the chip + + const clear = container.querySelector('.cc-scope-chip button')! + expect(clear.getAttribute('aria-label')).toContain('every source') + await act(async () => clear.click()) + + expect(row('team-docs')).toBeTruthy() + expect(container.querySelector('.cc-scope-chip')).toBeNull() + }) + + it('explains a scoped source that keeps nothing on this machine', async () => { + mocks.store.sources = [ + { name: 'vault', layer: 'personal', sourceKind: 'files' }, + { name: 'company-graph', layer: 'company', sourceKind: 'mcp' }, + ] + mocks.store.scope = 'company-graph' + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json({ layers: [flatLayer('vault', 3)] }) + return json(MEETING) + }) await act(async () => root.render()) - expect(container.textContent).toContain('live-mode view') + expect(container.textContent).toContain('company-graph keeps no files here') + expect(container.textContent).toContain('remote knowledge graph over MCP') + expect(container.querySelector('[role="tree"]')).toBeNull() + }) + + it('opens every folder while a search is active, and says so when nothing matches', async () => { + mocks.store.query = 'beta' + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(NESTED) + return json({ ...MEETING, path: 'vault/Projects/deep/beta.md', rel: 'Projects/deep/beta.md' }) + }) + await act(async () => root.render()) + + // A filtered tree is useless closed: the match is four levels down. + expect(row('vault/Projects/deep/beta.md')).toBeTruthy() + expect(container.querySelector('[title="vault/README.md"]')).toBeNull() + + mocks.store.query = 'zzzz' + mocks.store.sources = [{ name: 'vault', layer: 'personal', sourceKind: 'files' }] + await act(async () => root.render()) + expect(container.textContent).toContain('Nothing matches that') + }) + + it('still collapses a folder while a search is running', async () => { + // A filter opens every folder because every remaining row matched — but as + // a default, not a veto. Treated as a veto it left ArrowLeft unable to do + // anything but "collapse" (which then did nothing), so it could never walk + // to the parent, and a click on a folder was a visible no-op. + mocks.store.query = 'a' // matches every path below + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(NESTED) + return json({ ...MEETING, path: 'vault/Projects/alpha.md', rel: 'Projects/alpha.md' }) + }) + await act(async () => root.render()) + + expect(row('vault/Projects').getAttribute('aria-expanded')).toBe('true') + const opened = rows().length + + await act(async () => row('vault/Projects').click()) + expect(row('vault/Projects').getAttribute('aria-expanded')).toBe('false') + expect(rows().length).toBeLessThan(opened) + expect(container.querySelector('[title="vault/Projects/deep"]')).toBeNull() + + // ...and the keyboard agrees: collapsed already, so ArrowLeft walks up. + await act(async () => row('vault/Projects').focus()) + await act(async () => press(active()!, 'ArrowLeft')) + expect(active()?.title).toBe('vault') + }) + + it('hands focus to the row that inherits the tab stop, but never takes it from the search box', async () => { + // The listing is refetched in the background, so the row under the cursor + // can be deleted out from under the keyboard. The tab stop moves to the + // first row; focus, left behind on a node that no longer exists, falls to + // and the keyboard goes dead with no way back but the mouse. + const PRUNED = { + layers: [{ ...NESTED.layers[0], fileCount: 2, files: NESTED.layers[0].files.slice(0, 2) }], + } + let listing: unknown = NESTED + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(listing) + return json({ ...MEETING, path: 'vault/Projects/deep/beta.md', rel: 'Projects/deep/beta.md' }) + }) + await act(async () => root.render()) + + await act(async () => row('vault').focus()) + await act(async () => press(active()!, 'ArrowDown')) + await act(async () => press(active()!, 'ArrowRight')) // expand Projects + await act(async () => press(active()!, 'ArrowRight')) // → vault/Projects/deep + expect(active()?.title).toBe('vault/Projects/deep') + + listing = PRUNED + mocks.store.reloadKey = 1 + await act(async () => root.render()) + + expect(container.querySelector('[title="vault/Projects/deep"]')).toBeNull() + expect(document.activeElement).not.toBe(document.body) + expect(active()?.getAttribute('role')).toBe('treeitem') + expect(active()?.getAttribute('tabindex')).toBe('0') + expect(rows().filter((r) => r.getAttribute('tabindex') === '0')).toHaveLength(1) + + // The same reset usually fires while the user is typing in the search box — + // where taking focus would be worse than the bug. Focus is tracked as it + // moves, so a tree that has already handed focus away does not take it back. + const box = document.createElement('input') + document.body.appendChild(box) + box.focus() + expect(document.activeElement).toBe(box) + + mocks.store.query = 'alpha' // drops every row but vault/Projects/alpha.md + await act(async () => root.render()) + expect(container.querySelector('[title="vault/README.md"]')).toBeNull() + expect(document.activeElement).toBe(box) + box.remove() + }) +}) + +describe('Files in the demo', () => { + beforeEach(() => { mocks.store.mode = 'demo' }) + + it('renders the navigator over the build-time snapshot, with no engine behind it', async () => { + await act(async () => root.render()) + + // Every layer in the generated listing gets a root row carrying its count. + for (const layer of demoFiles.layers) { + expect(row(layer.layer).getAttribute('aria-expanded')).toBe('true') + expect(row(layer.layer).textContent).toContain(String(layer.fileCount)) + } + // A real tree, not a flat list: folders are there and start closed. + expect(row('personal/decisions').getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('[title="personal/decisions/primary-db.md"]')).toBeNull() + + // The first document is open and rendered from the snapshot's own text. + expect(container.querySelector('.cc-md')?.textContent).toContain('Postgres in every shared environment') expect(mocks.apiFetch).not.toHaveBeenCalled() }) - it('explains an empty state when no source has files on disk', async () => { - mocks.apiFetch.mockImplementation(async () => json({ layers: [] })) + it('offers no way to write, and says why', async () => { + await act(async () => root.render()) + + expect(() => button('Save')).toThrow() + expect(container.textContent).toContain('read-only in the demo') + + // The raw tab still shows the source — reading is the whole point — but the + // editor cannot take a keystroke it would have to throw away. + await act(async () => button('raw').click()) + const editor = container.querySelector('textarea')! + expect(editor.readOnly).toBe(true) + expect(editor.getAttribute('aria-label')).toContain('read-only') + expect(() => button('Save')).toThrow() + expect(mocks.apiFetch).not.toHaveBeenCalled() + }) + + it('lists a binary the snapshot cannot carry instead of hiding it', async () => { + await act(async () => root.render()) + await act(async () => row('company/assets').click()) + await act(async () => row('company/assets/contextcake-brief.pdf').click()) + + expect(container.textContent).toContain('Not in the demo snapshot') + // Never the forever-spinner: no raw fetch is attempted in the first place. + expect(container.textContent).not.toContain('Loading preview…') + expect(mocks.apiFetch).not.toHaveBeenCalled() + }) +}) + +describe('Files → concept', () => { + it('links an open document to the concept it resolves to, conflicts named not just coloured', async () => { + mocks.store.concepts = [{ + id: 'meeting', + title: 'Weekly sync', + type: 'note', + layers: ['personal'], + sections: [ + { name: 'Decision', winner: 'personal', sourceLayer: 'personal', value: 'x', dissents: [{ layer: 'team', sourceLayer: 'team-docs', value: 'y' }] }, + { name: 'Owner', winner: 'personal', sourceLayer: 'personal', value: 'z', dissents: [] }, + ], + }] await act(async () => root.render()) - expect(container.textContent).toContain('No file-backed sources yet') + expect(container.textContent).toContain('Resolves to') + // The count is words, not a hue — the a11y rule this strip has to keep. + expect(container.textContent).toContain('1 conflict') + + await act(async () => button('Weekly syncmeeting').click()) + expect(mocks.openConcept).toHaveBeenCalledWith('meeting') + }) + + it('offers no strip for a file the cascade does not read', async () => { + // Same file, no matching concept id: the cascade genuinely does not serve + // it, and a link to a concept that isn't there would be a lie. + mocks.store.concepts = [{ id: 'something-else', title: 'Other', type: 'note', layers: ['personal'], sections: [] }] + await act(async () => root.render()) + + expect(container.textContent).not.toContain('Resolves to') + }) + + it('walks from a demo document to its concept, and offers nothing for the file the cascade skips', async () => { + mocks.store.mode = 'demo' + // Ids straight from the generated cascade, so this can only pass while the + // two halves of the demo bundle are built from the same corpus. + mocks.store.concepts = demoBundle.concepts.map((c) => ({ + id: c.id, + title: (c.frontmatter?.title as string) ?? c.id, + type: 'concept', + layers: ['personal'], + sections: [], + })) + await act(async () => root.render()) + + expect(container.textContent).toContain('Resolves to') + expect(container.textContent).toContain('decisions/primary-db') + + // A .txt in an OKF bundle is read by no adapter, so it has no concept — + // and the strip says nothing rather than linking somewhere that isn't there. + await act(async () => row('personal/notes').click()) + await act(async () => row('personal/notes/scratch.txt').click()) + expect(container.textContent).not.toContain('Resolves to') + }) + + it('hides Reveal in Finder outside the desktop app, and reveals a layer-relative path inside it', async () => { + mocks.store.concepts = [] + await act(async () => root.render()) + expect(Array.from(container.querySelectorAll('button')).some((b) => b.textContent === 'Reveal in Finder')).toBe(false) + + const revealFile = vi.fn().mockResolvedValue({ ok: true }) + ;(window as unknown as { __CC_DESKTOP?: unknown }).__CC_DESKTOP = { revealFile } + await act(async () => root.render()) + await act(async () => button('Reveal in Finder').click()) + // A source name and a path inside it — never an absolute path. + expect(revealFile).toHaveBeenCalledWith('personal', 'meeting.md') + + revealFile.mockResolvedValue({ ok: false, error: 'That path is outside the folder for “personal”.' }) + await act(async () => button('Reveal in Finder').click()) + expect(container.textContent).toContain('outside the folder') + delete (window as unknown as { __CC_DESKTOP?: unknown }).__CC_DESKTOP }) }) diff --git a/apps/console/src/views/Files.tsx b/apps/console/src/views/Files.tsx index 15a8b52..dae4cca 100644 --- a/apps/console/src/views/Files.tsx +++ b/apps/console/src/views/Files.tsx @@ -4,26 +4,28 @@ // listing is cheap, so this view is useful immediately, even while sources are // still being read. Markdown opens rendered by default with a Raw tab for the // actual .md source (frontmatter, OKF heading attrs and all). -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { C, css, MONO } from '../theme' +// +// The demo runs the same navigator over a build-time snapshot of the engine's +// file APIs (layer-files.ts): same tree, same documents, same file ⇄ concept +// links, and no way to save. Editing is the one thing a snapshot cannot honour, +// so the demo drops the affordance rather than accepting a keystroke it would +// have to throw away. +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { C, css, lc, MONO, type LayerId } from '../theme' import { apiFetch } from '../api' +import type { Concept } from '../data' +import { buildTree, FileTree } from '../components/FileTree' import { Markdown } from '../components/Markdown' import { useDetailSurface } from '../components/useDetailSurface' +import { filesRevalidation, readLayerFile, useLayerFiles } from '../layer-files' +import { useReveal } from '../reveal' import { useStore } from '../store' -import type { FileContent, LayerFile, LayerFiles } from '../types' +import type { FileContent, LayerFile } from '../types' type Tab = 'rendered' | 'raw' -async function getJson(path: string): Promise { - const res = await apiFetch(path, { headers: { accept: 'application/json' } }) - const data = await res.json().catch(() => ({}) as { error?: string }) - if (!res.ok) throw new Error((data as { error?: string }).error ?? `Server returned ${res.status}`) - return data as T -} - -function fileLabel(file: LayerFile): string { - return file.rel.includes('/') ? file.rel : file.name -} +/** Extensions the cascade reads as documents — a concept id is the rel minus one of these. */ +const DOC_EXT = /\.(md|markdown|mdx|txt)$/i function Empty({ title, detail }: { title: string; detail: string }) { return ( @@ -36,11 +38,64 @@ function Empty({ title, detail }: { title: string; detail: string }) { ) } +/** An in-place explanation for the navigator column — it keeps the tree's frame. */ +function NavigatorNote({ title, children }: { title: string; children: ReactNode }) { + return ( +
+ {title} + {children} +
+ ) +} + +/** Rows, not a spinner: the shape of what is coming is itself information. */ +function TreeSkeleton() { + return ( + <> + Reading the files behind your sources… + + + ) +} + +/** + * Why a source the user can plainly see in Sources owns no files here. + * `/api/files` covers layers with a folder on disk; a remote graph or a repo + * read over the API is legitimately absent, and saying so is the difference + * between an explanation and a view that looks broken. + */ +function absentReason(kind: string | undefined): string { + if (kind === 'mcp') return 'This source serves a remote knowledge graph over MCP. ContextCake reads it live, so there are no local files to browse or edit — its concepts are in Knowledge → Concepts.' + if (kind === 'github') return 'This repository is read through the GitHub API without a clone, so nothing from it is stored on this machine. Its documents are in Knowledge → Concepts.' + return 'This source has no folder on disk, so there are no files to browse. Its content is in Knowledge → Concepts.' +} + +/** + * The concept a document file resolves to, if any. + * + * A concept id is a file's path inside its layer with the document extension + * taken off — the same derivation the engine's adapters make, which is why the + * link can be drawn without asking the server anything. A file that matches no + * concept gets no strip: the cascade genuinely does not read it (a note under + * the size cap, an asset, a file added since the last index). + */ +function conceptForFile(file: FileContent | null, concepts: Concept[]): { concept: Concept; conflicts: number } | null { + if (!file || !DOC_EXT.test(file.ext)) return null + const id = file.rel.replace(DOC_EXT, '') + const concept = concepts.find((candidate) => candidate.id === id) + if (!concept) return null + return { concept, conflicts: concept.sections.filter((s) => (s.dissents?.length ?? 0) > 0).length } +} + export function Files() { - const { mode, sources, reload, query } = useStore() - const [layers, setLayers] = useState(null) - const [listError, setListError] = useState(null) - const [selected, setSelected] = useState(null) + const { mode, concepts, sources, reload, reloadKey, query, filesScope, filesPath, setFilesScope, setFilesPath, openConcept } = useStore() const [file, setFile] = useState(null) const [fileError, setFileError] = useState(null) const [tab, setTab] = useState('rendered') @@ -51,49 +106,59 @@ export function Files() { const [previewError, setPreviewError] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const editorRef = useRef(null) - const selectedButtonRef = useRef(null) + const navigatorRef = useRef(null) const detail = useDetailSurface(detailOpen) - const selectedRef = useRef(selected) - selectedRef.current = selected + const finder = useReveal() const live = mode === 'live' - const dirty = file?.text !== undefined && draft !== file.text + const selected = filesPath + const selectedRef = useRef(selected) + selectedRef.current = selected + // Saving needs a file the engine will take a write for AND an engine to take + // it: the demo has the document but no write route behind it. + const canEdit = live && file?.editable === true + const dirty = canEdit && file?.text !== undefined && draft !== file.text - // The file list is cheap even mid-index, so it loads on its own and doesn't + // The listing is cheap even mid-index, so it loads on its own and doesn't // wait for the cascade. It re-runs when the source set changes. - useEffect(() => { - if (!live) return - let cancelled = false - void (async () => { - try { - const data = await getJson<{ layers: LayerFiles[] }>('/api/files') - if (cancelled) return - setLayers(data.layers) - setListError(null) - } catch (e) { - if (!cancelled) setListError(e instanceof Error ? e.message : String(e)) - } - })() - return () => { cancelled = true } - }, [live, sources.length]) + const { layers, error: listError } = useLayerFiles(mode, filesRevalidation(sources, reloadKey)) + + // Which source each layer belongs to, for the layer-coloured root rows. The + // hues are product semantics (personal amber / team teal / company indigo), + // reused here rather than reinvented. + const layerIds = useMemo(() => { + const map = new Map() + for (const source of sources) map.set(source.name, source.layer) + return map + }, [sources]) - const allFiles = useMemo( - () => (layers ?? []).flatMap((l) => l.files.map((f) => ({ ...f, layer: l.layer }))), - [layers], + const scopedLayers = useMemo( + () => (layers ?? []).filter((layer) => !filesScope || layer.layer === filesScope), + [layers, filesScope], ) + const allFiles = useMemo(() => scopedLayers.flatMap((l) => l.files), [scopedLayers]) // Open the first markdown file once, so the view never opens blank. + const autoOpened = useRef(null) useEffect(() => { if (selected || allFiles.length === 0) return - setSelected((allFiles.find((f) => f.markdown) ?? allFiles[0]).path) - }, [allFiles, selected]) + const first = (allFiles.find((f: LayerFile) => f.markdown) ?? allFiles[0]).path + autoOpened.current = first + setFilesPath(first) + }, [allFiles, selected, setFilesPath]) + + // Which selection the tree should open folders for. A file the user asked for + // — clicked, or named in the URL — reveals itself. The one the view opened on + // its own must not: on a 3,000-note vault that expanded two levels and buried + // the folder overview under 250 siblings before the user had done anything. + const reveal = selected && selected !== autoOpened.current ? selected : null useEffect(() => { if (!selected) return let cancelled = false void (async () => { try { - const data = await getJson(`/api/file?path=${encodeURIComponent(selected)}`) + const data = await readLayerFile(mode, selected) if (cancelled) return setFile(data) setDraft(data.text ?? '') @@ -107,7 +172,7 @@ export function Files() { } })() return () => { cancelled = true } - }, [selected]) + }, [mode, selected]) // Images and PDFs must be fetched through apiFetch so the desktop bearer is // present; putting /api/file/raw directly in src would 401 inside the app. @@ -119,6 +184,10 @@ export function Files() { setPreviewUrl(null) setPreviewError(null) if (!file || (file.kind !== 'image' && file.kind !== 'pdf')) return undefined + // The demo snapshot carries text, not bytes: there is no /api/file/raw to + // ask, and asking anyway would leave the pane on "Loading preview…" for + // good. The render says so instead. + if (!live) return undefined void (async () => { try { const res = await apiFetch(`/api/file/raw?path=${encodeURIComponent(file.path)}`) @@ -154,21 +223,23 @@ export function Files() { } }, [dirty, file?.path]) - const chooseFile = (path: string, opener?: HTMLButtonElement) => { - if (path === selected) { - selectedButtonRef.current = opener ?? null - setDetailOpen(true) - return - } + const chooseFile = useCallback((path: string) => { + if (path === selectedRef.current) { setDetailOpen(true); return } if (dirty && !window.confirm(`Discard unsaved changes to ${file?.path ?? 'this file'}?`)) return - selectedButtonRef.current = opener ?? null - setSelected(path) + setFilesPath(path) setDetailOpen(true) - } + }, [dirty, file?.path, setFilesPath]) + // Focus goes back to the tree, not to a remembered node: the row that opened + // the sheet may have been windowed out while the sheet was up. const closeDetail = useCallback(() => { setDetailOpen(false) - requestAnimationFrame(() => selectedButtonRef.current?.focus({ preventScroll: true })) + requestAnimationFrame(() => { + const tree = navigatorRef.current + const row = tree?.querySelector('[role="treeitem"][aria-selected="true"]') + ?? tree?.querySelector('[role="treeitem"][tabindex="0"]') + row?.focus({ preventScroll: true }) + }) }, []) useEffect(() => { @@ -208,8 +279,11 @@ export function Files() { } }, [file, dirty, draft, saving, reload]) - // ⌘S / Ctrl+S saves, the shortcut everyone tries in an editor. + // ⌘S / Ctrl+S saves, the shortcut everyone tries in an editor. Not bound where + // there is nothing to save: swallowing the browser's own ⌘S over a read-only + // demo would take a shortcut away and give nothing back. useEffect(() => { + if (!canEdit) return undefined const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault() @@ -218,72 +292,93 @@ export function Files() { } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [save]) + }, [canEdit, save]) - if (!live) { - return - } if (listError) { return } if (layers && layers.length === 0) { - return + return } const normalizedQuery = (query ?? '').trim().toLowerCase() - const visible = (files: LayerFile[], layer: string) => { - if (normalizedQuery && layer.toLowerCase().includes(normalizedQuery)) return files - return normalizedQuery - ? files.filter((fileEntry) => [fileEntry.path, fileEntry.name, fileEntry.rel] - .some((value) => value.toLowerCase().includes(normalizedQuery))) - : files + const matching = (files: LayerFile[], layer: string) => { + if (!normalizedQuery) return files + if (layer.toLowerCase().includes(normalizedQuery)) return files + return files.filter((fileEntry) => [fileEntry.path, fileEntry.name, fileEntry.rel] + .some((value) => value.toLowerCase().includes(normalizedQuery))) } - const visibleLayers = (layers ?? []) - .map((layer) => ({ ...layer, files: visible(layer.files, layer.layer) })) + const visibleLayers = scopedLayers + .map((layer) => ({ ...layer, files: matching(layer.files, layer.layer) })) .filter((layer) => !normalizedQuery || layer.files.length > 0) + const entries = buildTree(visibleLayers) + const scopeSource = filesScope ? sources.find((s) => s.name === filesScope) : undefined + // Scoped at a source `/api/files` never listed: it owns no folder on disk. + const scopeAbsent = Boolean(filesScope) && layers !== null && !layers.some((l) => l.layer === filesScope) + const truncated = visibleLayers.filter((layer) => layer.truncated) + const scopeColor = filesScope ? lc(layerIds.get(filesScope) ?? 'team') : null + const resolved = conceptForFile(file, concepts) return ( -
-
@@ -316,7 +413,19 @@ export function Files() { )} - {file.editable && ( + {/* Desktop only, and absent rather than disabled elsewhere: the + web build has no Finder to reveal anything in. */} + {finder.available && ( + + )} + + {canEdit && ( + {/* Icon + words, never the amber alone — the same "conflict" + marker the canvas node uses. */} + {resolved.conflicts > 0 && ( + + + {resolved.conflicts} conflict{resolved.conflicts === 1 ? '' : 's'} + + )} + + )} + {fileError && (

{fileError}

)} + {finder.error && ( +

{finder.error}

+ )}
{!file.editable && (file.kind === 'image' || file.kind === 'pdf') ? ( - previewError ? ( + !live ? ( + + ) : previewError ? ( ) : previewUrl ? ( file.kind === 'image' @@ -356,7 +500,8 @@ export function Files() { value={draft} onChange={(e) => setDraft(e.target.value)} spellCheck={false} - aria-label={`Edit ${file.path}`} + readOnly={!canEdit} + aria-label={canEdit ? `Edit ${file.path}` : `${file.path}, read-only`} style={css(`display:block; width:100%; height:100%; min-height:340px; box-sizing:border-box; padding:20px 24px; border:none; resize:none; outline:none; background:transparent; color:${C.ink}; font-family:${MONO}; font-size:12.5px; line-height:1.7; tab-size:2;`)} /> )} diff --git a/apps/console/src/views/Sources.test.tsx b/apps/console/src/views/Sources.test.tsx index de531f7..ea517b6 100644 --- a/apps/console/src/views/Sources.test.tsx +++ b/apps/console/src/views/Sources.test.tsx @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Sources } from './Sources' import type { Source } from '../data' -const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), useStore: vi.fn(), reload: vi.fn() })) +const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), useStore: vi.fn(), reload: vi.fn(), openFilesScope: vi.fn() })) vi.mock('../api', () => ({ apiFetch: mocks.apiFetch })) vi.mock('../store', () => ({ useStore: mocks.useStore })) @@ -25,11 +25,35 @@ function src(over: Partial): Source { } } +/** The store's own state, so a test can move it the way a real reload would. */ +let store: { mode: 'live' | 'demo'; sources: Source[]; reloadKey: number } + function mount(sources: Source[], mode: 'live' | 'demo' = 'live', onAddSource?: () => void) { - mocks.useStore.mockReturnValue({ mode, sources, reload: mocks.reload }) + store = { mode, sources, reloadKey: 0 } + mocks.useStore.mockImplementation(() => ({ + mode: store.mode, sources: store.sources, reloadKey: store.reloadKey, + reload: mocks.reload, openFilesScope: mocks.openFilesScope, + })) return act(async () => root.render()) } +/** + * What the store does once a write's `reload()` lands: fresh rows from + * /api/graph and a bumped `reloadKey`. Re-rendering in place rather than + * remounting is the whole point — a remount refetches the listing by itself and + * would hide the staleness these tests exist to catch. + */ +function afterWrite(sources: Source[]) { + store = { ...store, sources, reloadKey: store.reloadKey + 1 } + return act(async () => root.render()) +} + +/** A change this app did not make, arriving through the poll: rows move, `reloadKey` does not. */ +function afterPoll(sources: Source[]) { + store = { ...store, sources } + return act(async () => root.render()) +} + function button(label: string): HTMLButtonElement { const match = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === label) if (!match) throw new Error(`Button not found: ${label}`) @@ -42,6 +66,12 @@ function buttonByAria(label: string): HTMLButtonElement { return match } +/** The edit control names what its panel can change, so its label varies by kind. */ +function editLabel(source: Source): string { + const folder = !source.origin && (source.sourceKind === 'okf-local' || source.sourceKind === 'files') + return folder ? `Rename, re-level or repoint ${source.name}` : `Rename or re-level ${source.name}` +} + function sourceButton(name: string): HTMLButtonElement { const match = Array.from(container.querySelectorAll('button[role="option"]')) .find((item) => item.querySelector('strong')?.textContent === name) @@ -71,6 +101,7 @@ beforeEach(() => { mocks.apiFetch.mockReset() mocks.useStore.mockReset() mocks.reload.mockReset() + mocks.openFilesScope.mockReset() mocks.apiFetch.mockImplementation(async () => ok()) }) @@ -124,6 +155,17 @@ describe('Sources rows', () => { expect(container.querySelector('button[aria-label^="Remove"]')).toBeNull() expect(container.querySelector('button[aria-label^="Rename"]')).toBeNull() }) + + // Read-only is about writes, not about looking. The demo bundle carries the + // files behind `personal`, so the way into the navigator is offered there too. + it('still offers the way into the files in demo mode', async () => { + await mount([src({ name: 'personal' })], 'demo') + + expect(container.textContent).toContain('2 files') + await act(async () => buttonByAria('Browse the files in personal').click()) + expect(mocks.openFilesScope).toHaveBeenCalledWith('personal') + expect(container.querySelector('button[aria-label^="Remove"]')).toBeNull() + }) }) describe('Sources remove', () => { @@ -132,7 +174,7 @@ describe('Sources remove', () => { await act(async () => buttonByAria('Remove repo docs').click()) expect(container.textContent).toContain('Your files stay where they are') - expect(mocks.apiFetch).not.toHaveBeenCalled() + expect(mocks.apiFetch.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'DELETE')).toBe(false) await act(async () => button('Remove source').click()) expect(mocks.apiFetch).toHaveBeenCalledWith('/api/sources?name=repo%20docs', expect.objectContaining({ method: 'DELETE' })) @@ -253,11 +295,10 @@ describe('Sources with an invalid manifest entry', () => { }) describe('Sources rename + re-level', () => { - it('PATCHes only name and level — and says a wrong path means remove + re-add', async () => { + it('PATCHes name and level, and leaves an untouched folder out of the body', async () => { await mount([src({ name: 'notes', level: 3 })]) - await act(async () => buttonByAria('Rename or re-level notes').click()) - expect(container.textContent).toContain('remove this source and add it again') + await act(async () => buttonByAria('Rename, re-level or repoint notes').click()) await enter('#src-edit-name', 'Field notes') await act(async () => container.querySelector('button[aria-label="Raise level"]')?.click()) @@ -265,6 +306,8 @@ describe('Sources rename + re-level', () => { const call = mocks.apiFetch.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH') expect(call?.[0]).toBe('/api/sources') + // No `path`: an unchanged folder must never re-key the index entry and put + // a settled source through a full re-read for nothing. expect(JSON.parse(String((call?.[1] as RequestInit).body))).toEqual({ name: 'notes', newName: 'Field notes', level: 4 }) expect(mocks.reload).toHaveBeenCalled() }) @@ -272,7 +315,7 @@ describe('Sources rename + re-level', () => { it('warns before renaming the live layer — staged captures fail closed', async () => { await mount([src({ name: 'team', level: 2, layer: 'team', live: true })]) - await act(async () => buttonByAria('Rename or re-level team').click()) + await act(async () => buttonByAria('Rename, re-level or repoint team').click()) expect(container.textContent).toContain('disables team capture for this machine') expect(container.textContent).toContain('staged captures fail closed') }) @@ -289,7 +332,7 @@ describe('Sources rename + re-level', () => { }) await mount([src({ name: 'team', level: 2, layer: 'team' })]) - await act(async () => buttonByAria('Rename or re-level team').click()) + await act(async () => buttonByAria('Rename, re-level or repoint team').click()) await enter('#src-edit-name', 'platform') await act(async () => button('Save').click()) @@ -298,6 +341,213 @@ describe('Sources rename + re-level', () => { }) }) +describe('Sources → Files', () => { + const listing = (layers: unknown[]) => ok({ layers }) + + it('shows the file count and root path, and browses scoped to that source', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') { + return listing([{ layer: 'notes', kind: 'files', root: '/Users/me/vault', fileCount: 3014, truncated: false, files: [] }]) + } + return ok() + }) + await mount([src({ name: 'notes' })]) + + expect(container.textContent).toContain('3014 files') + expect(container.textContent).toContain('/Users/me/vault') + + await act(async () => buttonByAria('Browse the files in notes').click()) + expect(mocks.openFilesScope).toHaveBeenCalledWith('notes') + }) + + it('says a remote source keeps nothing locally, and offers no browse action', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => (url === '/api/files' ? listing([]) : ok())) + await mount([src({ name: 'company-graph', kind: 'mcp', sourceKind: 'mcp', level: 0, layer: 'company', status: 'serving' })]) + + expect(container.textContent).toContain('None on this machine — remote graph') + expect(container.querySelector('button[aria-label^="Browse"]')).toBeNull() + }) + + it('marks a truncated listing rather than quoting a count it knows is short', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') { + return listing([{ layer: 'notes', kind: 'files', root: '/vault', fileCount: 10000, truncated: true, files: [] }]) + } + return ok() + }) + await mount([src({ name: 'notes' })]) + + expect(container.textContent).toContain('10000+ files') + }) + + it('hides Reveal in Finder outside the desktop app and reveals the layer root inside it', async () => { + mocks.apiFetch.mockImplementation(async (url: string) => (url === '/api/files' + ? listing([{ layer: 'notes', kind: 'files', root: '/Users/me/vault', fileCount: 3, truncated: false, files: [] }]) + : ok())) + await mount([src({ name: 'notes' })]) + expect(container.querySelector('button[aria-label^="Reveal"]')).toBeNull() + + const revealFile = vi.fn().mockResolvedValue({ ok: true }) + ;(window as unknown as { __CC_DESKTOP?: unknown }).__CC_DESKTOP = { revealFile } + await mount([src({ name: 'notes' })]) + await act(async () => buttonByAria('Reveal the folder for notes in Finder').click()) + // The source's own name and an empty relative path — the main process + // resolves the root; the renderer never names an absolute path. + expect(revealFile).toHaveBeenCalledWith('notes', '') + delete (window as unknown as { __CC_DESKTOP?: unknown }).__CC_DESKTOP + }) +}) + +describe('Sources: the file listing follows the source', () => { + const entry = (over: Record = {}) => ({ + layer: 'notes', kind: 'files', root: '/Users/me/vault', fileCount: 3014, truncated: false, files: [], ...over, + }) + + /** `/api/files` answers from `listed`, which a test moves the way the engine would. */ + function serve(listed: () => unknown[]) { + mocks.apiFetch.mockImplementation(async (url: string) => (url === '/api/files' ? ok({ layers: listed() }) : ok())) + } + + it('refetches after a rename, instead of losing the files under the old name', async () => { + let listed = [entry()] + serve(() => listed) + await mount([src({ name: 'notes' })]) + expect(container.textContent).toContain('3014 files') + + await act(async () => buttonByAria('Rename, re-level or repoint notes').click()) + await enter('#src-edit-name', 'notes-2') + await act(async () => button('Save').click()) + // The engine keys the listing by layer name, so the same folder comes back + // under the new one. + listed = [entry({ layer: 'notes-2' })] + await afterWrite([src({ name: 'notes-2' })]) + + // Keyed on the source count, none of this moved: the map was still keyed by + // `notes`, so a source with 3,014 files read "None on this machine" and its + // way into the navigator disappeared. + expect(container.textContent).toContain('3014 files') + expect(container.textContent).toContain('/Users/me/vault') + expect(container.textContent).not.toContain('None on this machine') + expect(container.querySelector('button[aria-label="Browse the files in notes-2"]')).toBeTruthy() + }) + + it('refetches after a repoint, instead of quoting the folder it no longer reads', async () => { + let listed = [entry({ fileCount: 3 })] + serve(() => listed) + await mount([src({ name: 'notes' })]) + expect(container.textContent).toContain('/Users/me/vault') + + await act(async () => buttonByAria('Rename, re-level or repoint notes').click()) + await enter('#src-edit-path', '/Volumes/Work/notes') + await act(async () => button('Save').click()) + listed = [entry({ root: '/Volumes/Work/notes', fileCount: 7 })] + // Nothing in `sources` changed here — same name, same level, same count. + // The move is only visible in the listing, which is why `reloadKey` and not + // the rows is what has to drive the refetch. + await afterWrite([src({ name: 'notes' })]) + + expect(container.textContent).toContain('/Volumes/Work/notes') + expect(container.textContent).toContain('7 files') + expect(container.textContent).not.toContain('/Users/me/vault') + + // And the editor prefills from the listing, so reopening it offers the new + // folder rather than the one the save just moved away from. + await act(async () => buttonByAria('Rename, re-level or repoint notes').click()) + expect(container.querySelector('#src-edit-path')!.value).toBe('/Volumes/Work/notes') + }) + + it('refetches for a source added outside this app, which never calls reload()', async () => { + let listed = [entry({ fileCount: 3 })] + serve(() => listed) + await mount([src({ name: 'notes' })]) + + listed = [entry({ fileCount: 3 }), entry({ layer: 'scratch', root: '/Users/me/scratch', fileCount: 9 })] + await afterPoll([src({ name: 'notes' }), src({ name: 'scratch', level: 2, layer: 'team' })]) + + await act(async () => sourceButton('scratch').click()) + expect(container.textContent).toContain('9 files') + expect(container.textContent).toContain('/Users/me/scratch') + }) +}) + +describe('Sources: repointing a folder', () => { + const listing = (root: string) => ok({ + layers: [{ layer: 'notes', kind: 'files', root, fileCount: 3, truncated: false, files: [] }], + }) + + async function openEditor(source: Source, root = '/Users/me/vault') { + mocks.apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url === '/api/files') return listing(root) + if (init?.method === 'PATCH') return ok({ ok: true, reindexing: true, hasDocuments: true }) + return ok() + }) + await mount([source]) + await act(async () => buttonByAria(editLabel(source)).click()) + } + + it('offers a labelled folder field prefilled with the source root, and PATCHes the move', async () => { + await openEditor(src({ name: 'notes', level: 3 })) + + const field = container.querySelector('#src-edit-path')! + expect(field.value).toBe('/Users/me/vault') + // A form control with no accessible name is the Critical failure this + // panel must not reintroduce. + expect(container.querySelector('label[for="src-edit-path"]')?.textContent).toBe('Folder') + expect(container.textContent).not.toContain('remove this source and add it again') + + await enter('#src-edit-path', '/Users/me/vault-2') + await act(async () => button('Save').click()) + + const call = mocks.apiFetch.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH') + expect(JSON.parse(String((call?.[1] as RequestInit).body))).toEqual({ name: 'notes', path: '/Users/me/vault-2' }) + // The row is about to say "indexing"; naming the cause first is the + // difference between progress and a source that looks broken. + expect(container.textContent).toContain('reading it now') + }) + + it('fills the folder field from the native picker', async () => { + const chooseFolder = vi.fn().mockResolvedValue('/Volumes/Work/notes') + ;(window as unknown as { __CC_DESKTOP?: unknown }).__CC_DESKTOP = { chooseFolder } + await openEditor(src({ name: 'notes' })) + + await act(async () => button('Choose…').click()) + expect(container.querySelector('#src-edit-path')!.value).toBe('/Volumes/Work/notes') + delete (window as unknown as { __CC_DESKTOP?: unknown }).__CC_DESKTOP + }) + + it('offers no folder field for a repo read over the API, and keeps the honest advice', async () => { + await openEditor(src({ name: 'notes', sourceKind: 'github', kind: 'okf-local' })) + expect(container.querySelector('#src-edit-path')).toBeNull() + expect(container.textContent).toContain('read from its repository over the GitHub API') + }) + + it('offers no folder field for a clone, whose folder belongs to Sync', async () => { + await openEditor(src({ name: 'notes', sourceKind: 'okf-local', origin: 'https://github.com/o/r.git' })) + expect(container.querySelector('#src-edit-path')).toBeNull() + expect(container.textContent).toContain('its folder is managed by Sync') + }) + + it('renders the engine refusal verbatim rather than paraphrasing it', async () => { + mocks.apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url === '/api/files') return listing('/Users/me/vault') + if (init?.method === 'PATCH') { + return new Response( + JSON.stringify({ error: 'Folder not found: /Users/me/typo' }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ) + } + return ok() + }) + await mount([src({ name: 'notes' })]) + await act(async () => buttonByAria('Rename, re-level or repoint notes').click()) + await enter('#src-edit-path', '/Users/me/typo') + await act(async () => button('Save').click()) + + expect(container.textContent).toContain('Folder not found: /Users/me/typo') + expect(mocks.reload).not.toHaveBeenCalled() + }) +}) + describe('Sources sync', () => { it('offers Sync now for REST github layers and clone-backed layers only', async () => { await mount([ diff --git a/apps/console/src/views/Sources.tsx b/apps/console/src/views/Sources.tsx index a5ac84b..6c04d66 100644 --- a/apps/console/src/views/Sources.tsx +++ b/apps/console/src/views/Sources.tsx @@ -1,18 +1,22 @@ // Sources view: manage the layers feeding the cascade — rename, re-level, -// sync, and remove ride the engine's source API (PATCH/DELETE /api/sources, -// POST /api/sources/sync). Name and level are the only mutable fields; a -// wrong path, repo, or command is fixed by remove + re-add, and the UI says -// so instead of pretending otherwise. Errors render verbatim — including the -// engine's pack-invariant messages — never paraphrased into vagueness. +// repoint, sync, and remove ride the engine's source API (PATCH/DELETE +// /api/sources, POST /api/sources/sync). A folder-backed source can be pointed +// at a different folder in place; a repo or an MCP command genuinely can't be, +// and the UI says which case you are in rather than telling everyone to remove +// and re-add. Errors render verbatim — including the engine's pack-invariant +// messages — never paraphrased into vagueness. // Demo mode shows the same rows read-only. -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { C, css, MONO } from '../theme' import { apiFetch, progressLabel, progressPercent } from '../api' import { LayerChip } from '../components/LayerChip' import { LevelStepper } from '../components/SetupWizard' import { useDetailSurface } from '../components/useDetailSurface' +import { filesRevalidation, useLayerFiles } from '../layer-files' +import { useReveal } from '../reveal' import { useStore } from '../store' import type { Source } from '../data' +import type { LayerFiles } from '../types' // Sync of a clone-backed source runs `git pull` server-side (bounded at 120s // there) — same headroom as the wizard's mutations. @@ -94,6 +98,31 @@ function canSync(s: Source): boolean { return !s.quarantined && (s.sourceKind === 'github' || Boolean(s.origin)) } +/** + * Whether this source's folder can be repointed in place. Mirrors the engine's + * own refusal (service.mjs `pathPatchRefusal`) so the form never offers a field + * the PATCH would reject: a remote source has no folder, and a clone-backed + * layer's folder belongs to Sync. + */ +function canEditPath(s: Source): boolean { + if (s.quarantined || s.origin) return false + return s.sourceKind === 'okf-local' || s.sourceKind === 'files' +} + +/** + * The honest version of the old "remove the source and add it again" line, kept + * only for the sources where it is still true. A folder-backed source returns + * null — it has a path field now, and repeating the sentence there was the + * whole field complaint. + */ +function immutableNote(s: Source): string | null { + if (canEditPath(s)) return null + if (s.sourceKind === 'mcp') return 'This source is reached by running a command. To point it at a different MCP server, remove it and add it again.' + if (s.sourceKind === 'github') return 'This source is read from its repository over the GitHub API. To follow a different repo, remove it and add it again.' + if (s.origin) return 'This source is a clone, and its folder is managed by Sync. To follow a different repository, remove it and add it again.' + return 'The location of this source is fixed. To change it, remove the source and add it again.' +} + function btnSmallGhost(): React.CSSProperties { return css(`padding:6px 11px; background:transparent; border:1px solid ${C.line}; border-radius:8px; cursor:pointer; font:inherit; font-weight:600; font-size:11.5px; color:${C.caption};`) } @@ -147,12 +176,35 @@ function CredentialWarning({ source }: { source: Source }) { type Panel = { name: string; kind: 'edit' | 'remove' } | null +/** + * What the panel says about a source's files. A source with no folder on disk + * is a real, healthy state — an MCP graph or a repo read over the API — and the + * row says which, rather than leaving a blank that reads as a failure. + */ +function filesSummary(source: Source, entry: LayerFiles | undefined, known: boolean): string { + if (entry) return `${entry.fileCount}${entry.truncated ? '+' : ''} file${entry.fileCount === 1 ? '' : 's'}` + if (!known) return 'Reading…' + if (source.sourceKind === 'mcp') return 'None on this machine — remote graph' + if (source.sourceKind === 'github') return 'None on this machine — read over the API' + return 'None on this machine' +} + export function Sources({ onAddSource }: { onAddSource?: () => void }) { - const { mode, sources, reload, query } = useStore() + const { mode, sources, reload, reloadKey, query, openFilesScope } = useStore() const live = mode === 'live' - + // The same listing the Files view builds its tree from: a source's file count + // and root path are already in that payload, and were being thrown away. + const { layers: fileLayers } = useLayerFiles(mode, filesRevalidation(sources, reloadKey)) + const filesByLayer = useMemo(() => { + const map = new Map() + for (const entry of fileLayers ?? []) map.set(entry.layer, entry) + return map + }, [fileLayers]) + + const finder = useReveal() const [panel, setPanel] = useState(null) const [editName, setEditName] = useState('') + const [editPath, setEditPath] = useState('') const [editLevel, setEditLevel] = useState(0) const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) @@ -209,6 +261,7 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { const openEdit = (s: Source) => { setPanel({ name: s.name, kind: 'edit' }) setEditName(s.name) + setEditPath(filesByLayer.get(s.name)?.root ?? '') setEditLevel(s.level) setErr(null) } @@ -218,19 +271,35 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { const saveEdit = async (s: Source) => { const newName = editName.trim() if (!newName) { setErr('Give this source a short name.'); return } + const currentRoot = filesByLayer.get(s.name)?.root ?? '' + const newPath = editPath.trim() const body: Record = { name: s.name } if (newName !== s.name) body.newName = newName if (editLevel !== s.level) body.level = editLevel - if (body.newName === undefined && body.level === undefined) { closePanel(); return } + // Only a real move is sent. An untouched field must not re-key the index + // entry and put a settled source back through a full read for nothing. + if (canEditPath(s) && newPath && newPath !== currentRoot) body.path = newPath + if (body.newName === undefined && body.level === undefined && body.path === undefined) { closePanel(); return } setBusy(true) setErr(null) try { - await callApi('/api/sources', { + const out = await callApi('/api/sources', { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) closePanel() + // A move means a re-index, and the row is about to say "indexing" on its + // own. Naming the cause first is the difference between progress and a + // source that looks like it broke when you saved it. + if (out.reindexing === true) { + setNotice({ + name: newName, + text: out.hasDocuments === false + ? 'Pointed at the new folder — no documents spotted there yet, so it may come back empty.' + : 'Pointed at the new folder — reading it now.', + }) + } reload() } catch (e) { setErr(e instanceof Error ? e.message : String(e)) @@ -319,6 +388,7 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { const isOpen = panel?.name === s.name const editing = isOpen && panel?.kind === 'edit' const removing = isOpen && panel?.kind === 'remove' + const immutable = immutableNote(s) return
level {s.level}{s.sourceKind}{s.status === 'indexing' ? progressLabel(s.indexing) : `${s.conceptCount} concept${s.conceptCount === 1 ? '' : 's'}`}{(s.warnings ?? 0) > 0 && {s.warnings} warning{s.warnings === 1 ? '' : 's'}}
{s.status}
@@ -331,12 +401,15 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { {/* "Not supported for this source kind" would be answering the wrong question on an entry that is not a source at all. */} {!s.quarantined &&
Sync
{canSync(s) ? 'Available' : 'Not supported for this source kind'}
} + {!s.quarantined &&
Files
{filesSummary(s, filesByLayer.get(s.name), fileLayers !== null)}
} + {filesByLayer.get(s.name)?.root &&
Location
{filesByLayer.get(s.name)!.root}
} {s.origin &&
Repository
{s.origin}
} - {/* An invalid entry has no path, repo or command to speak of — the - sentence below is about a working source, and printing it here - would describe a source that was never built. */} - {!s.quarantined &&

The path, repository, or command is fixed for this source. To change it, remove the source and add it again.

} + {/* Only where it is still true. A folder-backed source can now be + repointed from the panel below, so telling its owner to remove + and re-add would be plain wrong — and an invalid entry has no + path, repo or command to speak of in the first place. */} + {!s.quarantined && immutable &&

{immutable}

} {s.quarantined && (
This entry is not a working source @@ -377,15 +450,45 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { {notice.text}
)} + {finder.error && ( +
+ {finder.error} +
+ )} {syncErr?.name === s.name && (
{syncErr.text}
)} - {live && !isOpen && ( + {/* Reading is offered in both modes; only the writes below need an + engine behind them. */} + {(live || filesByLayer.has(s.name)) && !isOpen && (
- {canSync(s) && ( + {/* The way in to the navigator. Offered only where there is + something to browse — a disabled button over a source that + keeps nothing on disk would say less than the Files row + above it already does. */} + {filesByLayer.has(s.name) && ( + + )} + {/* Desktop only — hidden, not disabled, in the browser build. */} + {live && finder.available && filesByLayer.has(s.name) && ( + + )} + {live && canSync(s) && ( } - + {/* The label names the whole panel, folder included. A control + called "Rename / level" over a form that also repoints the + source would hide the very thing this pass added — and the + visible words have to be inside the accessible name. */} + {live && !s.quarantined && ( + + )} + {live && }
)} @@ -418,8 +533,37 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) {
+ + {canEditPath(s) && ( +
+ +
+ { setEditPath(e.target.value); setErr(null) }} + spellCheck={false} + autoComplete="off" + /> + {window.__CC_DESKTOP?.chooseFolder && ( + + )} +
+
+ )} +

- Name and level are all that can change here. To point at a different path, repo, or command, remove this source and add it again. + {canEditPath(s) + ? 'Pointing this source at a different folder re-reads it from scratch. Your files are never moved or copied.' + : immutableNote(s)}

{s.live && } {err &&

{err}

} diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index b6d599d..f2ffd56 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -67,6 +67,20 @@ npm run dist # DMG + zip, ad-hoc signed in dev bridge is `src/preload.cjs`: `window.__CC_DESKTOP` exposes static launch metadata, while `window.__CC_AUTH` exposes the narrow auth/settings IPC surface. Keep both minimal; the console must keep working in plain browsers. +- **Reveal in Finder never takes a path from the renderer.** + `contextcake:reveal-file` accepts a source NAME and a path relative to it; + `src/main/reveal.mjs` resolves the root from the manifest on disk and runs the + engine's own guards (`layerRootMap` + `assertInsideRoot`, imported by path, not + copied) before `shell.showItemInFolder`. A path that escapes its source folder + is refused, never clamped — clamping would answer a request nobody made, and + quietly. Keep the payload shape: a channel that accepted an absolute path would + let a compromised renderer point Finder anywhere on the machine. The manifest + is read through the engine's `readContextManifestQuarantined`, the same + read-path tolerance `service.mjs` uses — a strict read throws on the whole + file, which made one hand-edited layer disable Reveal for every healthy source. + `scripts/navigation-test.mjs` covers traversal, an absolute rel, an out-of-root + symlink, a layer with no folder, a manifest holding a broken layer, and the + trusted-window policy. - **Every `/api` call needs the bearer token** — the console's `apiFetch` (apps/console/src/api.ts) injects it automatically. Raw `fetch('/api/…')` in renderer code will 401 inside the app. diff --git a/apps/desktop/scripts/navigation-test.mjs b/apps/desktop/scripts/navigation-test.mjs index 92673ff..a2264e8 100644 --- a/apps/desktop/scripts/navigation-test.mjs +++ b/apps/desktop/scripts/navigation-test.mjs @@ -1,5 +1,11 @@ import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { isEngineOrigin } from '../src/main/navigation.mjs' +import { resolveRevealTarget } from '../src/main/reveal.mjs' +import { TRUSTED_IPC_ROLES, trustedRolesForChannel } from '../src/main/trusted-windows.mjs' const origin = 'http://127.0.0.1:4317' @@ -9,4 +15,90 @@ assert.equal(isEngineOrigin(`http://127.0.0.1:4317@attacker.example/`, origin), assert.equal(isEngineOrigin('https://127.0.0.1:4317/console/', origin), false) assert.equal(isEngineOrigin('not a URL', origin), false) -console.log('navigation test passed (exact engine origin only; trusted-window identity is covered by unit tests)') +// ---- Reveal in Finder ------------------------------------------------------- +// The IPC exists, is main-window-only, and goes through the trusted gate — a +// plain ipcMain.handle would skip the sender check entirely. +assert.deepEqual([...trustedRolesForChannel('contextcake:reveal-file')], ['main']) +assert.ok(Object.hasOwn(TRUSTED_IPC_ROLES, 'contextcake:reveal-file')) + +const here = path.dirname(fileURLToPath(import.meta.url)) +const engineSrc = path.resolve(here, '..', '..', '..', 'packages', 'core', 'src') +const preload = fs.readFileSync(path.resolve(here, '..', 'src', 'preload.cjs'), 'utf8') +const mainSource = fs.readFileSync(path.resolve(here, '..', 'src', 'main', 'main.mjs'), 'utf8') +assert.match(mainSource, /handleTrustedIpc\('contextcake:reveal-file'/) +assert.match(preload, /revealFile:/) +// The bridge must not offer the main process a path of the renderer's +// choosing — that is the whole containment argument. +assert.doesNotMatch(preload, /reveal-file',\s*\{\s*path/) + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-reveal-')) +const layerRoot = path.join(tmp, 'vault') +const outside = path.join(tmp, 'outside') +fs.mkdirSync(path.join(layerRoot, 'notes'), { recursive: true }) +fs.mkdirSync(outside, { recursive: true }) +fs.writeFileSync(path.join(layerRoot, 'notes', 'a.md'), '# A\n') +fs.writeFileSync(path.join(outside, 'private.md'), '# private\n') +fs.symlinkSync(path.join(outside, 'private.md'), path.join(layerRoot, 'escape.md')) + +const manifestFile = path.join(tmp, 'manifest.json') +fs.writeFileSync(manifestFile, JSON.stringify({ + version: 2, + profiles: { + default: { + layers: [ + { name: 'vault', level: 3, source: 'files', path: layerRoot }, + { name: 'graph', level: 1, source: 'mcp', command: 'node', args: ['nope.mjs'] }, + ], + }, + }, +})) + +const reveal = (layer, rel) => resolveRevealTarget({ layer, rel, manifestFile, engineSrc }) +const refused = async (label, layer, rel) => { + await assert.rejects(reveal(layer, rel), (err) => err instanceof Error, `${label} must be refused`) +} + +assert.equal(await reveal('vault', 'notes/a.md'), fs.realpathSync.native(path.join(layerRoot, 'notes', 'a.md'))) +assert.equal(await reveal('vault', ''), fs.realpathSync.native(layerRoot)) + +await refused('traversal out of the layer root', 'vault', '../outside/private.md') +await refused('deep traversal', 'vault', 'notes/../../outside/private.md') +await refused('an absolute path', 'vault', path.join(outside, 'private.md')) +await refused('a symlink pointing out of the root', 'vault', 'escape.md') +await refused('a layer with no folder on disk', 'graph', 'anything.md') +await refused('an unknown layer', 'nope', 'a.md') +await refused('a missing file', 'vault', 'notes/gone.md') +await refused('a non-string layer', 42, 'notes/a.md') +await refused('a non-string rel', 'vault', { toString: () => 'notes/a.md' }) + +// One malformed layer must not take the healthy ones with it. A strict manifest +// read throws on the whole file, which disabled Reveal for every source in the +// app while the console went on listing and browsing the sources that were +// fine — the same failure mode service.mjs's quarantined read exists to remove. +fs.writeFileSync(manifestFile, JSON.stringify({ + version: 2, + profiles: { + default: { + layers: [ + { name: 'vault', level: 3, source: 'files', path: layerRoot }, + { name: 'graph', level: 1, source: 'mcp', command: 'node', args: ['nope.mjs'] }, + { name: 'noplace', level: 1 }, // hand-edited: an okf-local layer with no path + ], + }, + }, +})) + +assert.equal(await reveal('vault', 'notes/a.md'), fs.realpathSync.native(path.join(layerRoot, 'notes', 'a.md'))) +assert.equal(await reveal('vault', ''), fs.realpathSync.native(layerRoot)) +await refused('traversal, with a broken layer in the manifest', 'vault', '../outside/private.md') +await refused('a layer with no folder, with a broken layer in the manifest', 'graph', 'anything.md') +// And the refusal for the broken layer names that layer, not the whole list. +await assert.rejects( + reveal('noplace', 'a.md'), + (err) => err instanceof Error && err.message.includes('noplace') && !/list of sources/.test(err.message), + 'a quarantined layer must be refused by name', +) + +fs.rmSync(tmp, { recursive: true, force: true }) + +console.log('navigation test passed (exact engine origin only; reveal-in-finder stays inside a layer root, refuses escapes, and survives one broken layer)') diff --git a/apps/desktop/src/main/main.mjs b/apps/desktop/src/main/main.mjs index 8170ffa..f5d3eb9 100644 --- a/apps/desktop/src/main/main.mjs +++ b/apps/desktop/src/main/main.mjs @@ -6,7 +6,8 @@ import { app, BrowserWindow, Menu, dialog, ipcMain, nativeTheme, safeStorage, sc import { startEngineService } from './service-host.mjs' import { createGithubConnections, verifyGithubToken } from './github-connections.mjs' import { buildMenu } from './menu.mjs' -import { configDir, manifestPath, settingsPath } from './paths.mjs' +import { configDir, enginePaths, manifestPath, settingsPath } from './paths.mjs' +import { resolveRevealTarget } from './reveal.mjs' import { markSettingsDirty, readSettings, writeLocalSettings, writeSettings } from './settings.mjs' import { createAuthManager } from './auth.mjs' import { @@ -580,6 +581,27 @@ handleTrustedIpc('contextcake:choose-folder', async ({ window }) => { return result.canceled ? null : (result.filePaths[0] ?? null) }) +// Reveal in Finder. The renderer names a source and a path INSIDE it; the +// absolute path is resolved here, against the manifest on disk, with the +// engine's own containment guard. A path that escapes its source folder is +// refused rather than clamped — see src/main/reveal.mjs. +handleTrustedIpc('contextcake:reveal-file', async ({ layer, rel } = {}) => { + try { + const target = await resolveRevealTarget({ + layer, + rel, + manifestFile: manifestPath(), + engineSrc: enginePaths().engineSrc, + }) + shell.showItemInFolder(target) + return { ok: true } + } catch (err) { + // The reason travels as data rather than as a rejected invoke, so the + // console can render it verbatim instead of Electron's wrapper text. + return { ok: false, error: err?.message ?? 'That file could not be revealed.' } + } +}) + handleTrustedIpc('windows:open-settings', (pane) => openSettingsWindow(pane)) handleTrustedIpc('data:reload-requested', () => { trustedWindows.broadcast('data:reload-requested', undefined, ['main']) diff --git a/apps/desktop/src/main/menu.mjs b/apps/desktop/src/main/menu.mjs index 20d255f..adc0637 100644 --- a/apps/desktop/src/main/menu.mjs +++ b/apps/desktop/src/main/menu.mjs @@ -42,6 +42,10 @@ export function buildMenu(getWindow, openSettings) { { label: 'Go to Home', accelerator: 'CmdOrCtrl+1', click: () => invoke('destination:1') }, { label: 'Go to Cascade', accelerator: 'CmdOrCtrl+2', click: () => invoke('destination:2') }, { label: 'Go to Knowledge', accelerator: 'CmdOrCtrl+3', click: () => invoke('destination:3') }, + // The source navigator. ⌘3 restores whichever Knowledge subview was + // last open; this one always lands on Files, and the renderer binds + // the same chord so the browser build behaves identically. + { label: 'Go to Files', accelerator: 'CmdOrCtrl+Shift+F', click: () => invoke('view:files') }, { label: 'Go to Sources', accelerator: 'CmdOrCtrl+4', click: () => invoke('destination:4') }, { label: 'Go to Review', accelerator: 'CmdOrCtrl+5', click: () => invoke('destination:5') }, { type: 'separator' }, diff --git a/apps/desktop/src/main/paths.mjs b/apps/desktop/src/main/paths.mjs index 55279ab..d4d628d 100644 --- a/apps/desktop/src/main/paths.mjs +++ b/apps/desktop/src/main/paths.mjs @@ -13,6 +13,10 @@ export function enginePaths() { const res = process.resourcesPath return { serviceModule: path.join(res, 'engine', 'src', 'service.mjs'), + // The engine's module directory. The main process loads a couple of + // pure helpers from here (path containment for Reveal in Finder); the + // engine ITSELF still only ever runs in the utility process. + engineSrc: path.join(res, 'engine', 'src'), consoleDist: path.join(res, 'console'), cliShim: path.join(res, 'bin', 'contextcake'), } @@ -20,6 +24,7 @@ export function enginePaths() { const repoRoot = path.resolve(here, '..', '..', '..', '..') return { serviceModule: path.join(repoRoot, 'packages', 'core', 'src', 'service.mjs'), + engineSrc: path.join(repoRoot, 'packages', 'core', 'src'), consoleDist: path.join(repoRoot, 'apps', 'console', 'dist'), cliShim: path.resolve(here, '..', '..', 'resources', 'bin', 'contextcake'), } diff --git a/apps/desktop/src/main/reveal.mjs b/apps/desktop/src/main/reveal.mjs new file mode 100644 index 0000000..49bc3a2 --- /dev/null +++ b/apps/desktop/src/main/reveal.mjs @@ -0,0 +1,87 @@ +// "Reveal in Finder", resolved in the main process. +// +// The renderer asks for a LAYER NAME and a RELATIVE PATH — never an absolute +// one. That is the whole design: `shell.showItemInFolder` will happily open a +// Finder window on anything the main process hands it, so a channel that +// accepted a path would let a compromised renderer point the user's Finder at +// any file on the machine. Here the only thing the renderer can influence is +// which layer, and where inside it; the root comes from the manifest on disk. +// +// The containment check is the engine's own, imported rather than copied: +// `layerRootMap` (which layers have a folder at all — mcp and REST-read github +// layers are structurally absent) and `assertInsideRoot` (which refuses "..", +// an absolute rel, and symlinks pointing out of the root). An escaping path is +// REFUSED, never clamped back to the root: clamping would answer a request the +// user did not make, and quietly. +// +// No Electron imports, so this is exercised directly by scripts/navigation-test.mjs. +import fs from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const engines = new Map() + +/** + * The engine modules this needs, loaded once per engine root. Dynamic because + * the engine lives at a different absolute path in a dev checkout and in a + * packaged bundle (see paths.mjs), and lazy so a reveal that never happens + * costs the app nothing at boot. + */ +function loadEngine(engineSrc) { + if (!engines.has(engineSrc)) { + engines.set(engineSrc, Promise.all([ + import(pathToFileURL(path.join(engineSrc, 'layer-files.mjs')).href), + import(pathToFileURL(path.join(engineSrc, 'http-util.mjs')).href), + import(pathToFileURL(path.join(engineSrc, 'manifest.mjs')).href), + ])) + } + return engines.get(engineSrc) +} + +/** + * Absolute path for `/`, or a throw explaining the refusal. + * Never returns a path outside the named layer's root. + */ +export async function resolveRevealTarget({ layer, rel = '', manifestFile, engineSrc }) { + if (typeof layer !== 'string' || !layer) throw new Error('Reveal needs the name of a source.') + if (typeof rel !== 'string') throw new Error('Reveal needs a path inside that source.') + const [ + { layerRootMap }, + { assertInsideRoot }, + { getManifestProfileLayers, readContextManifestQuarantined }, + ] = await loadEngine(engineSrc) + + // The engine's READ-path manifest, quarantine and all — the same one + // service.mjs answers /api/files from. A strict read throws on any malformed + // layer, which made one hand-edited entry refuse Reveal for every *healthy* + // source in the app, blaming "could not read its list of sources" for a + // problem confined to one row the console was already listing as broken. + // Quarantine only ever removes: a bad layer is absent here, so it is refused + // by name below and nothing else changes. + let manifest + let quarantined + try { ({ manifest, quarantined } = readContextManifestQuarantined(manifestFile, { allowMissing: false })) } + catch { throw new Error('ContextCake could not read its list of sources.') } + + // The same profile-unified view every engine read site builds, so a manifest + // migrated to v2 still resolves its layers here. + let roots + try { + roots = layerRootMap({ ...manifest, layers: getManifestProfileLayers(manifest) }, path.dirname(manifestFile)) + } catch { throw new Error('ContextCake could not read its list of sources.') } + + const entry = roots.get(layer) + if (!entry) { + // Say which of the two it is. A source that is misconfigured is a thing the + // user can fix; one that simply keeps nothing locally is not. + if (quarantined.some((broken) => broken.name === layer)) { + throw new Error(`“${layer}” is not set up correctly — open Sources to fix it.`) + } + throw new Error(`“${layer}” keeps no files on this machine.`) + } + + const abs = path.resolve(entry.root, rel) + const real = assertInsideRoot(abs, entry.root, `That path is outside the folder for “${layer}”.`) + if (!fs.existsSync(real)) throw new Error('That file is no longer on disk.') + return real +} diff --git a/apps/desktop/src/main/trusted-windows.mjs b/apps/desktop/src/main/trusted-windows.mjs index 5664cdd..b33e9c7 100644 --- a/apps/desktop/src/main/trusted-windows.mjs +++ b/apps/desktop/src/main/trusted-windows.mjs @@ -25,6 +25,9 @@ export const TRUSTED_IPC_ROLES = Object.freeze({ 'contextcake:cli-status': Object.freeze(['main']), 'contextcake:cli-install': Object.freeze(['main']), 'contextcake:choose-folder': Object.freeze(['main']), + // Only the main window browses files, and the payload is a layer name plus a + // relative path — never a path the renderer chose (see reveal.mjs). + 'contextcake:reveal-file': Object.freeze(['main']), 'windows:open-settings': Object.freeze(['main']), 'data:reload-requested': Object.freeze(['settings']), }) diff --git a/apps/desktop/src/preload.cjs b/apps/desktop/src/preload.cjs index 4867a43..6e1864c 100644 --- a/apps/desktop/src/preload.cjs +++ b/apps/desktop/src/preload.cjs @@ -57,6 +57,10 @@ contextBridge.exposeInMainWorld('__CC_DESKTOP', { onReloadRequested: (cb) => subscribe('data:reload-requested', cb), }, chooseFolder: () => ipcRenderer.invoke('contextcake:choose-folder'), + // Show a file in Finder. Deliberately a source name plus a path INSIDE that + // source — this bridge cannot carry an absolute path, so the main process is + // the only thing that ever decides where on disk Finder is pointed. + revealFile: (layer, rel) => ipcRenderer.invoke('contextcake:reveal-file', { layer: String(layer ?? ''), rel: String(rel ?? '') }), cli: { getStatus: () => ipcRenderer.invoke('contextcake:cli-status'), install: () => ipcRenderer.invoke('contextcake:cli-install'), diff --git a/apps/playground/demo-layers/personal/notes/scratch.txt b/apps/playground/demo-layers/personal/notes/scratch.txt new file mode 100644 index 0000000..6a15069 --- /dev/null +++ b/apps/playground/demo-layers/personal/notes/scratch.txt @@ -0,0 +1,9 @@ +Scratch — not filed yet. + +- The ClickHouse ETL retried three times last night before it went green. Ask + Data whether that retry window is deliberate or just the default. +- The staging smoke test doesn't cover the auth token TTL. It should. +- Rollback first, then fix forward. Wrote that down after Tuesday. + +Plain .txt, so the OKF reader for this bundle never turns it into a concept: it +lives in the folder, not in the cascade. diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs index 7eb0a5c..edbb872 100644 --- a/apps/site/astro.config.mjs +++ b/apps/site/astro.config.mjs @@ -48,6 +48,7 @@ export default defineConfig({ { label: 'Guides', items: [ + { label: 'Browsing your context files', slug: 'docs/guides/browsing-your-files' }, { label: 'Playground tour', slug: 'docs/guides/playground-tour' }, { label: 'Foreign MCP sources', slug: 'docs/guides/foreign-mcp-sources' }, { label: 'The capture write path', slug: 'docs/guides/capture-write-path' }, diff --git a/apps/site/src/content/docs/docs/guides/browsing-your-files.md b/apps/site/src/content/docs/docs/guides/browsing-your-files.md new file mode 100644 index 0000000..a5c3d1a --- /dev/null +++ b/apps/site/src/content/docs/docs/guides/browsing-your-files.md @@ -0,0 +1,147 @@ +--- +title: Browsing your context files +description: Walk from a source to the documents behind it, edit one in place, and follow it back to the concept it resolves to. +--- + +Every concept ContextCake serves came from a file somewhere. **Knowledge → Files** +is the navigator over those files: a tree per source, the document rendered +beside it, and a link in both directions between a file and the concept it +becomes. + +The [Web Demo](/demo) has the same navigator, read-only, over the three-layer +demo bundle — the tree, the documents, and the file ⇄ concept links, with no +Save. + +## From a source to its files + +In **Sources**, select a source. Two rows in the panel are worth reading before +you open anything: **Files** is how many files that source holds, and +**Location** is the folder it reads. **Browse files** opens the navigator scoped +to it. + +Two other ways in: + +- **Knowledge → Files** (`⇧⌘F`) shows every source at once. +- The command palette (`⌘K`) carries one *Browse files in ``* entry per + source. + +## The tree + +Each source is a root row with its file count and its layer colour; folders sit +under it, closed, each with the number of files in its subtree. A vault of a few +thousand notes therefore opens as a short list of folders rather than a wall of +file names — expand only the part you care about. Only the rows on screen exist +in the page, so the tree costs the same at 30 files as at 3,000. + +It is a keyboard tree: + +| Key | Does | +|-----|------| +| `↑` `↓` | Move between visible rows | +| `→` | Open a folder, then step into it | +| `←` | Close a folder, or jump to its parent | +| `Home` `End` | First / last row | +| `Enter` | Open a file, or toggle a folder | + +The top-bar search box filters by file name and path, and opens every folder +that still holds a match — a filtered tree is no use closed. That is a starting +point, not a lock: `←` and a click still close a folder while the filter is on, +and it stays closed when you clear the search. If a source holds +more files than the scan limit allows, the navigator says so at the top of the +tree and tells you where to raise it (Settings → Indexing). + +## Scoping to one source + +Arriving from **Browse files** scopes the tree to that source: a chip names it, +with the file count and the folder underneath. Clear the chip to see every +source again. Scoping to a source that keeps nothing on this machine says which +kind of source it is instead of showing an empty folder — see +[Sources with no files to browse](#sources-with-no-files-to-browse). + +## Deep links + +The navigator is addressable, and the URL updates as you browse: + +- `#/files` — the whole tree +- `#/files/team` — scoped to the `team` source +- `#/files/team/runbooks/deploy.md` — that file open, its folders revealed + +The second segment is the path inside the source, so a name with a space or a +note six folders deep both survive the round trip. This is the link to paste +into a ticket when you want a colleague to look at the same document. + +## Reading and editing + +Markdown opens rendered. The **Raw** tab shows the file exactly as written — +frontmatter, `{#anchor}` heading attributes and all — and that tab is the +editor. + +Edit, then **Save** (`⌘S`). ContextCake writes the file and re-resolves the +cascade, so Concepts, Cascade and Conflicts agree with what you just typed +rather than with what was there when the app started. Editing a section that +another layer disagrees with is one way to settle a conflict; the +[merge resolver](/docs/guides/playground-tour#resolving-conflicts) is the other. + +Some limits are deliberate: + +- Saving is refused if the file changed on disk after you opened it. Reopen it + and merge — nothing is overwritten in the meantime. +- This view only ever overwrites files that already exist, and only inside a + source's own folder. It creates nothing and deletes nothing. +- Images and PDFs preview instead of opening for edit. A text file above the + indexer's size cap opens read-only, because it is a file the cascade does not + read either. +- Files in a cloned repository are editable, but the edit lives only in your + clone — and a dirty clone can make the next **Sync** fail. + +## A file and the concept it becomes + +An open document names the concept it resolves to, with that concept's conflict +count; click through to read the merged result in **Concepts**. Going the other +way, every contributor listed on a concept has an **Open file** button that +lands on the exact file in that layer. + +The rule behind both links is simple: a concept id is the file's path inside its +source with the document extension removed, so `runbooks/deploy.md` in the +`team` source is the concept `runbooks/deploy` — the same derivation the engine +makes when it reads the layer. + +Which extensions count depends on the kind of source: a +[ContextCake bundle](/docs/concepts/okf-bundles) reads `.md`, and a Markdown +folder reads `.md`, `.mdx`, and `.txt`. Everything else in the folder — an +image, a PDF, a `.txt` note sitting in an OKF bundle — is listed in the tree and +has no concept behind it. Those files get no link rather than one that opens on +an error. + +## Reveal in Finder + +In the Mac app, the file header has **Reveal in Finder**, and a source's panel +has one for the folder itself. The app resolves the location from the source +name and the path inside it and refuses anything that escapes that source's +folder, so a reveal can only ever land inside the folder you pointed it at. The +browser build has no Finder, so the button is absent there rather than present +and dead. + +## Repointing a source's folder + +Moved your notes? In **Sources**, open **Rename / level / folder**, edit +**Location**, and save. ContextCake re-reads the new folder and leaves your +other sources alone — you do not have to remove the source and add it back. + +This is offered for folder-backed sources: a ContextCake bundle or a Markdown +folder. A cloned repository's folder is managed by **Sync**, and a GitHub-API or +MCP source has no folder to move; for those, remove the source and add it again. + +## Sources with no files to browse + +Two source kinds keep nothing on your machine and so never appear in the tree: + +- **A GitHub repository read over the API** — indexed without a clone, so no + file from it is stored locally. +- **An MCP source** — a remote graph ContextCake reads live and translates at + read time. See [Foreign MCP sources](/docs/guides/foreign-mcp-sources). + +That absence is a healthy state, not a failure, and the navigator says so when +you scope to one of them. Their content is in **Knowledge → Concepts** like +everything else. A repository you cloned is the exception: the clone is a real +folder on disk, so it does show up here. diff --git a/apps/site/src/pages/demo.astro b/apps/site/src/pages/demo.astro index 5e8946d..02be2ce 100644 --- a/apps/site/src/pages/demo.astro +++ b/apps/site/src/pages/demo.astro @@ -16,8 +16,8 @@ const narrativePoints = [ body: 'An override does not erase the value it replaced.', }, { - title: 'One place to inspect both', - body: 'Browse resolved concepts and their conflicts in the same interface.', + title: 'From the answer to the file', + body: 'Browse resolved concepts and their conflicts, then open the document any section came from.', }, ]; --- diff --git a/packages/core/src/service.mjs b/packages/core/src/service.mjs index a613bbb..6c3ba55 100644 --- a/packages/core/src/service.mjs +++ b/packages/core/src/service.mjs @@ -1000,7 +1000,7 @@ export function createEngineService({ if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; } if (req.method === "POST") { json(res, 200, await addSourceApi(await readBody(req))); return true; } if (req.method === "DELETE") { json(res, 200, removeSourceApi(url.searchParams.getAll("name"))); return true; } - json(res, 200, patchSourceApi(await readBody(req))); + json(res, 200, await patchSourceApi(await readBody(req))); return true; } if (p === "/api/sources/sync" && req.method === "POST") { @@ -1781,12 +1781,52 @@ export function createEngineService({ return dir; } - function patchSourceApi(rawBody) { + /** + * Which layers may have their folder repointed, and why the rest may not. + * A remote source has no folder to speak of, and a clone-backed layer's path + * is owned by Sync (gitCloneOrPull writes CACHE_DIR/, never layer.path) + * — repointing it would leave a source that reads one folder and syncs + * another, which is worse than refusing. + */ + function pathPatchRefusal(layer) { + const kind = layer.source ?? "okf-local"; + if (kind === "mcp") return "An MCP source is reached by command, not by folder. Remove it and add it again to point at a different server."; + if (kind === "github") return "A GitHub source is read from its repository, not from a folder on this machine. Remove it and add it again to point at a different repo."; + if (layer.origin) return "This source is a clone of " + layer.origin + ". Its folder is managed by Sync — remove it and add it again to point somewhere else."; + if (kind !== "okf-local" && kind !== "files") return `A "${kind}" source has no editable folder path.`; + return null; + } + + async function patchSourceApi(rawBody) { const b = parseJson(rawBody); + // A path change is validated before the manifest is touched, with the same + // cheap probe the add path uses — folder-missing and not-a-folder fail the + // request, size never does. The kind is re-checked inside the mutation + // below; this read only decides which extensions the probe looks for. + let nextPath; + let probed = null; + if (b.path !== undefined) { + // Typed before it is coerced: String(["/etc"]) is "/etc", so an array + // would otherwise walk straight through the trim and the probe. + if (typeof b.path !== "string") throw httpError(400, "Give this source a folder path"); + const layer = getManifestProfileLayers(readManifest()).find((candidate) => candidate.name === b.name); + if (!layer) throw httpError(404, `No source named "${b.name}"`); + const refusal = pathPatchRefusal(layer); + if (refusal) throw httpError(400, refusal); + nextPath = expandHome(b.path.trim()); + if (!nextPath) throw httpError(400, "Give this source a folder path"); + const kind = layer.source ?? "okf-local"; + probed = await probeFolder(path.resolve(MANIFEST_DIR, nextPath), kind === "files" ? FILES_EXTENSIONS : [".md"]); + } mutateContextManifest(MANIFEST, (manifest) => { const layers = getManifestProfileLayers(manifest); const layer = layers.find((candidate) => candidate.name === b.name); if (!layer) throw httpError(404, `No source named "${b.name}"`); + if (nextPath !== undefined) { + const refusal = pathPatchRefusal(layer); + if (refusal) throw httpError(400, refusal); + layer.path = nextPath; + } if (b.level !== undefined && Number.isFinite(+b.level)) layer.level = +b.level; if (b.newName && b.newName !== b.name) { if (!/^[a-zA-Z0-9 _-]{1,40}$/.test(b.newName)) throw httpError(400, "Invalid new name"); @@ -1795,7 +1835,14 @@ export function createEngineService({ } }, { allowMissing: false, allowTransitional: true }); reload(); - return { ok: true }; + // A new folder is a new content IDENTITY, so adoptIndexes finds no entry to + // carry over and the source re-indexes from scratch. That is the correct + // outcome, not a shortcoming of adoption: the snapshot it would have + // carried is an index of a folder this source no longer reads, and serving + // it would answer with documents the user just pointed away from. The + // client is told to expect a re-index rather than left to infer it from a + // row that flipped back to "indexing". + return { ok: true, ...(probed ? { reindexing: true, hasDocuments: probed.found, scanComplete: probed.complete } : {}) }; } async function syncSourceApi(name) { diff --git a/packages/core/tests/service-test.sh b/packages/core/tests/service-test.sh index b106891..ea1b539 100755 --- a/packages/core/tests/service-test.sh +++ b/packages/core/tests/service-test.sh @@ -386,6 +386,69 @@ code 200 "$(C -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"ki code 200 "$(C -X DELETE "${AUTH[@]}" "$BASE/api/sources?name=keep")" "remove the user folder source" [ -f "$TMP/keepme/keep.md" ] && pass "a user folder is never touched by remove" || fail "user folder deleted on remove" +# ---- repointing a source at a different folder (PATCH path) ------------------- +# The one field the app used to answer with "remove the source and add it again". +# What matters here is that the source really reads the NEW folder afterwards +# (not just that the manifest string changed), that a bad path leaves the +# manifest exactly as it was, and that name/level survive a path-only patch. +echo "editable source path (PATCH path)" +mkdir -p "$TMP/from-here" "$TMP/to-here" +printf '# Old Home\n\n## Body\n\nthe folder it was added with.\n' > "$TMP/from-here/old-home.md" +printf '# New Home\n\n## Body\n\nthe folder it was repointed to.\n' > "$TMP/to-here/new-home.md" +code 200 "$(C -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"kind\":\"files\",\"name\":\"movable\",\"level\":2,\"path\":\"$TMP/from-here\"}" "$BASE/api/sources")" "add the source that will be repointed" +curl -s "${AUTH[@]}" "$BASE/api/graph?wait=15000" >/dev/null +curl -s "${AUTH[@]}" "$BASE/api/resolve?concept=old-home" | grep -q 'the folder it was added with' && pass "the source serves its original folder" || fail "original folder not served" + +MOVE="$(curl -s -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"movable\",\"path\":\"$TMP/to-here\"}" "$BASE/api/sources")" +[ "$(JQ 'JSON.stringify([d.ok, d.reindexing, d.hasDocuments])' <<<"$MOVE")" = '[true,true,true]' ] && pass "the path patch reports a re-index and a folder with documents" || fail "path patch response ($MOVE)" +curl -s "${AUTH[@]}" "$BASE/api/graph?wait=15000" >/dev/null +curl -s "${AUTH[@]}" "$BASE/api/resolve?concept=new-home" | grep -q 'the folder it was repointed to' && pass "the source re-indexes against the new folder" || fail "new folder not indexed" +[ "$(curl -s "${AUTH[@]}" "$BASE/api/resolve?concept=old-home" | JQ 'JSON.stringify(d.contributors ?? [])')" = "[]" ] && pass "the old folder's concepts stop resolving" || fail "old folder still resolving after the move" +ROW="$(curl -s "${AUTH[@]}" "$BASE/api/graph" | JQ 'JSON.stringify((({name,level,conceptCount}) => ({name,level,conceptCount}))(d.sources.find((s) => s.name === "movable") ?? {}))')" +[ "$ROW" = '{"name":"movable","level":2,"conceptCount":1}' ] && pass "name and level survive a path-only patch" || fail "path patch disturbed name/level ($ROW)" + +code 400 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"movable\",\"path\":\"$TMP/does-not-exist\"}" "$BASE/api/sources")" "a missing folder fails the patch" +code 400 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"movable\",\"path\":\"$TMP/to-here/new-home.md\"}" "$BASE/api/sources")" "a file instead of a folder fails the patch" +code 400 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d '{"name":"movable","path":" "}' "$BASE/api/sources")" "an empty path fails the patch" +# An array reaching String() would collapse to its single element and sail +# through the trim and the probe, so the type is checked before the coercion. +code 400 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"movable\",\"path\":[\"$TMP/to-here\"]}" "$BASE/api/sources")" "a path that is not a string fails the patch" +grep -q "$TMP/to-here" "$TMP/manifest.json" && pass "a refused path patch leaves the manifest on the last good folder" || fail "manifest mutated by a refused path patch" +code 404 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"no-such-source\",\"path\":\"$TMP/to-here\"}" "$BASE/api/sources")" "an unknown source name is a 404, not a new layer" + +# Kinds with no folder to repoint. Each answers with the reason rather than a +# generic refusal, because "remove and add it again" is still the right advice +# for exactly these. +PR="$(curl -s -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"gr\",\"path\":\"$TMP/to-here\"}" "$BASE/api/sources")" +code 400 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"gr\",\"path\":\"$TMP/to-here\"}" "$BASE/api/sources")" "a github source refuses a path patch" +grep -q 'repository' <<<"$PR" && pass "the github refusal names the repository" || fail "github refusal copy ($PR)" +code 200 "$(C -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"kind\":\"mcp\",\"name\":\"mv-mcp\",\"level\":0,\"command\":\"node\",\"args\":[\"$TMP/flaky.mjs\"],\"trusted\":true}" "$BASE/api/sources")" "add an mcp source to patch" +PR="$(curl -s -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"mv-mcp\",\"path\":\"$TMP/to-here\"}" "$BASE/api/sources")" +code 400 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"mv-mcp\",\"path\":\"$TMP/to-here\"}" "$BASE/api/sources")" "an mcp source refuses a path patch" +grep -q 'command' <<<"$PR" && pass "the mcp refusal names the command" || fail "mcp refusal copy ($PR)" +code 200 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d '{"name":"mv-mcp","newName":"mv-mcp2","level":1}' "$BASE/api/sources")" "rename/re-level still works on a kind that refuses paths" +code 200 "$(C -X DELETE "${AUTH[@]}" "$BASE/api/sources?name=mv-mcp2")" "cleanup the mcp source" + +# A clone-backed layer reads layer.path but SYNCS into .cache/repos/. +# Repointing it would leave a source that reads one folder and pulls into +# another, so it is refused even though its kind is okf-local. +mkdir -p "$CLONE" +printf -- '---\ntype: note\ntitle: C\n---\n\n# C\n\n## S {#s}\n\nclone doc.\n' > "$CLONE/c.md" +node -e ' + const fs = require("node:fs"); + const m = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + (m.profiles?.default?.layers ?? m.layers).push( + { name: "cl3", level: 1, path: ".cache/repos/github.com__o__gh1", origin: "https://github.com/o/gh1.git", ref: null }, + ); + fs.writeFileSync(process.argv[1], JSON.stringify(m, null, 2) + "\n"); +' "$TMP/manifest.json" +PR="$(curl -s -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"cl3\",\"path\":\"$TMP/to-here\"}" "$BASE/api/sources")" +code 400 "$(C -X PATCH "${AUTH[@]}" -H 'content-type: application/json' -d "{\"name\":\"cl3\",\"path\":\"$TMP/to-here\"}" "$BASE/api/sources")" "a clone-backed source refuses a path patch" +grep -q 'Sync' <<<"$PR" && pass "the clone refusal points at Sync" || fail "clone refusal copy ($PR)" +code 200 "$(C -X DELETE "${AUTH[@]}" "$BASE/api/sources?name=cl3")" "cleanup the clone-backed layer" +code 200 "$(C -X DELETE "${AUTH[@]}" "$BASE/api/sources?name=movable")" "cleanup the repointed source" +[ -f "$TMP/to-here/new-home.md" ] && pass "repointing never touches either folder" || fail "path patch touched user files" + echo "allowMutations: false (token unset)" code 200 "$(C "$BASE2/api/graph")" "reads work with no header when token unset" code 405 "$(C -X POST -H 'content-type: application/json' -d '{}' "$BASE2/api/sources")" "POST /api/sources returns 405"