From 4599aed66b605d63257afc1278332dc4c8b9e116 Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 11:53:36 -0400 Subject: [PATCH 01/18] feat(console): give Sources a way into the files behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Files view existed but nothing pointed at it: Sources offered Sync, Rename and Remove, the file list always showed every layer at once, and no file had a URL. A source's file count and root path were already in the /api/files payload the console fetched, and were being dropped on the floor. - Sources' detail panel now reads that payload: a Files row (with the honest "none on this machine" for a remote graph or a REST-read repo), a Location row, and Browse files as the primary action. - The Files view takes a scope, so "browse this source" means one source rather than a search box over all of them. Clearing the scope keeps the open file — widening the navigator is not closing a document. - #/files// is a real address: bookmarkable, survives reload, and Back restores what the hash says. A file is carried in the URL only while the navigator is scoped to the layer that holds it, so parse and serialize are exact inverses — the same bare/deep split Concepts makes. - One palette entry per source, and a Go to Files item bound to the same chord in the desktop View menu and the browser build. Co-Authored-By: Claude Opus 5 Signed-off-by: John Siracusa --- apps/console/src/App.tsx | 24 ++++++++-- apps/console/src/desktop.d.ts | 2 +- apps/console/src/layer-files.ts | 48 ++++++++++++++++++++ apps/console/src/shell-navigation.test.ts | 39 +++++++++++++++- apps/console/src/shell-navigation.ts | 42 +++++++++++++++++- apps/console/src/store.test.tsx | 42 +++++++++++++++++- apps/console/src/store.tsx | 54 +++++++++++++++++++---- apps/console/src/views/Sources.test.tsx | 47 ++++++++++++++++++-- apps/console/src/views/Sources.tsx | 42 +++++++++++++++++- apps/desktop/src/main/menu.mjs | 4 ++ 10 files changed, 322 insertions(+), 22 deletions(-) create mode 100644 apps/console/src/layer-files.ts diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 39a0fa6..f82b0fc 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,26 @@ 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. + ...(mode === 'live' + ? 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 +287,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 +320,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/desktop.d.ts b/apps/console/src/desktop.d.ts index 43b0aa9..666e776 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 }> diff --git a/apps/console/src/layer-files.ts b/apps/console/src/layer-files.ts new file mode 100644 index 0000000..0ea531e --- /dev/null +++ b/apps/console/src/layer-files.ts @@ -0,0 +1,48 @@ +// The `/api/files` listing, shared by the two views that need it. +// +// Sources reads it for a source's file count and root path; Files reads it to +// build the navigator tree. One module so both 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 +// both views explain that state instead of rendering an empty list. +import { useEffect, useState } from 'react' +import { apiFetch } from './api' +import type { LayerFiles } from './types' + +export async function fetchLayerFiles(): Promise { + 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 ?? [] +} + +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 whatever identifies the + * current source set. The listing is cheap even mid-index, so this deliberately + * does not wait on the cascade. + */ +export function useLayerFiles(enabled: boolean, revalidate: unknown): LayerFilesState { + const [state, setState] = useState({ layers: null, error: null }) + + useEffect(() => { + if (!enabled) return + let cancelled = false + void (async () => { + try { + const layers = await fetchLayerFiles() + if (!cancelled) setState({ layers, error: null }) + } catch (e) { + if (!cancelled) setState({ layers: null, error: e instanceof Error ? e.message : String(e) }) + } + })() + return () => { cancelled = true } + }, [enabled, revalidate]) + + return state +} 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..f839cb9 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, setFilesScope, setFilesPath } = useStore() return (
`${t.name}:${t.phase}:${t.loaded}/${t.total ?? '?'}${t.refreshing ? ':refreshing' : ''}`).join(',')} > + + +
) } @@ -401,4 +406,39 @@ 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('') + }) }) diff --git a/apps/console/src/store.tsx b/apps/console/src/store.tsx index b1fdda3..9f2821e 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,11 @@ 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. */ + openFilesScope: (layer: string | null) => void setQuery: (q: string) => void openChat: () => void closeChat: () => void @@ -245,6 +254,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) @@ -540,6 +551,22 @@ 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. + */ + const openFilesScope = useCallback((layer: string | null) => { + if (!dispatchNavigationGuard()) return + setFilesScopeState(layer) + setFilesPathState((current) => (layer && current && !current.startsWith(`${layer}/`) ? null : current)) + setViewState('files') + }, []) + useEffect(() => { window.__CC_DESKTOP?.uiState?.set({ lastView: view, @@ -563,15 +590,17 @@ 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 = view === 'files' + ? filesHash(filesScope, filesPath) + : view === 'concepts' && selConcept && conceptRouteMode === 'deep' + ? `#/concepts/${encodeURIComponent(selConcept)}` + : `#/${view}` 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, view, selConcept, filesScope, filesPath]) useEffect(() => { const onPop = () => { @@ -592,6 +621,12 @@ 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) @@ -740,13 +775,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, 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]) + }), [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, setView, setQuery, setFilesScope, setFilesPath, openFilesScope, openChat, closeChat]) return {children} } diff --git a/apps/console/src/views/Sources.test.tsx b/apps/console/src/views/Sources.test.tsx index de531f7..b28c185 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 })) @@ -26,7 +26,7 @@ function src(over: Partial): Source { } function mount(sources: Source[], mode: 'live' | 'demo' = 'live', onAddSource?: () => void) { - mocks.useStore.mockReturnValue({ mode, sources, reload: mocks.reload }) + mocks.useStore.mockReturnValue({ mode, sources, reload: mocks.reload, openFilesScope: mocks.openFilesScope }) return act(async () => root.render()) } @@ -71,6 +71,7 @@ beforeEach(() => { mocks.apiFetch.mockReset() mocks.useStore.mockReset() mocks.reload.mockReset() + mocks.openFilesScope.mockReset() mocks.apiFetch.mockImplementation(async () => ok()) }) @@ -132,7 +133,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' })) @@ -298,6 +299,46 @@ 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') + }) +}) + 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..d28b2b9 100644 --- a/apps/console/src/views/Sources.tsx +++ b/apps/console/src/views/Sources.tsx @@ -5,14 +5,16 @@ // so instead of pretending otherwise. 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 { useLayerFiles } from '../layer-files' 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. @@ -147,9 +149,30 @@ 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, 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(live, sources.length) + const filesByLayer = useMemo(() => { + const map = new Map() + for (const entry of fileLayers ?? []) map.set(entry.layer, entry) + return map + }, [fileLayers]) const [panel, setPanel] = useState(null) const [editName, setEditName] = useState('') @@ -331,6 +354,8 @@ 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 @@ -385,6 +410,19 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { {live && !isOpen && (
+ {/* 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) && ( + + )} {canSync(s) && ( - ) - })} - - ) - })} -
+
+
From abd8ab3eaebb24aee0cf66187196e18ce72bab7d Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 12:31:47 -0400 Subject: [PATCH 03/18] feat(core): let a folder-backed source be repointed, not re-added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH /api/sources now accepts `path` for the local/files kinds: expandHome, then the same cheap probeFolder the add path uses (folder-missing and not-a-folder fail; size never does), then the manifest write. Name and level survive a path-only patch, and a refused patch leaves the manifest exactly as it was. Refused where there is no folder to repoint: an MCP source is reached by command, a github source is read from its repo, and a clone-backed layer's folder belongs to Sync — gitCloneOrPull writes CACHE_DIR/, never layer.path, so repointing it would leave a source that reads one folder and pulls into another. Each refusal says which case it is, because "remove and add it again" is still the right advice for exactly these. A new folder is a new content identity, so adoptIndexes finds nothing to carry and the entry re-indexes from zero. Measured on a 3,000-note vault: the PATCH itself answers in 22ms, then the row reads indexing / 0 concepts for ~16s while it re-reads, and the old folder's concepts stop resolving immediately. That is the honest outcome — the snapshot adoption would have carried indexes a folder this source no longer reads. The response says `reindexing: true` so a client can name the cause before the row goes blue. Co-Authored-By: Claude Opus 5 Signed-off-by: John Siracusa --- CLAUDE.md | 1 + packages/core/src/service.mjs | 50 ++++++++++++++++++++++-- packages/core/tests/service-test.sh | 60 +++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b738aa..5015639 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. diff --git a/packages/core/src/service.mjs b/packages/core/src/service.mjs index a613bbb..bfb5209 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,49 @@ 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) { + 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(String(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 +1832,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..1e97673 100755 --- a/packages/core/tests/service-test.sh +++ b/packages/core/tests/service-test.sh @@ -386,6 +386,66 @@ 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" +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" From c99576037dc3dbf9879fb6c9c62e666cd53b99ea Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 12:32:00 -0400 Subject: [PATCH 04/18] feat(desktop): reveal a source's files in Finder, resolved in the main process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shell.showItemInFolder opens a Finder window on whatever it is handed, so a channel that accepted a path would let a compromised renderer point the user's Finder at any file on the machine. This one takes a source NAME and a path relative to it — the preload cannot express an absolute path at all. src/main/reveal.mjs reads the manifest from disk, builds the same profile-unified layer view every engine read site builds, and runs the engine's own guards: layerRootMap (mcp and REST-read github layers are structurally absent — they have no folder) and assertInsideRoot (".." , an absolute rel, and symlinks pointing out of the root). Imported by path rather than copied, so the containment rule keeps exactly one implementation. An escaping path is refused, never clamped back to the root: clamping would answer a request nobody made, and quietly. The IPC is main-window-only in the trusted-window policy. navigation-test.mjs covers the policy, the preload shape, and the resolver against a real layer root: traversal, deep traversal, an absolute rel, a symlink out of the root, a layer with no folder, an unknown layer, a missing file, and non-string arguments. Co-Authored-By: Claude Opus 5 Signed-off-by: John Siracusa --- apps/desktop/CLAUDE.md | 10 ++++ apps/desktop/scripts/navigation-test.mjs | 66 +++++++++++++++++++++- apps/desktop/src/main/main.mjs | 24 +++++++- apps/desktop/src/main/paths.mjs | 5 ++ apps/desktop/src/main/reveal.mjs | 68 +++++++++++++++++++++++ apps/desktop/src/main/trusted-windows.mjs | 3 + apps/desktop/src/preload.cjs | 4 ++ 7 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/reveal.mjs diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index b6d599d..b538dfc 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -67,6 +67,16 @@ 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. + `scripts/navigation-test.mjs` covers traversal, an absolute rel, an out-of-root + symlink, a layer with no folder, 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..220fda4 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,62 @@ 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' }) + +fs.rmSync(tmp, { recursive: true, force: true }) + +console.log('navigation test passed (exact engine origin only; reveal-in-finder stays inside a layer root and refuses escapes)') 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/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..abaf9ed --- /dev/null +++ b/apps/desktop/src/main/reveal.mjs @@ -0,0 +1,68 @@ +// "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 }] = await loadEngine(engineSrc) + + let manifest + try { manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')) } + 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) 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'), From e504691e83829d7cd251bcb182fd3b4a85e92cb0 Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 12:32:20 -0400 Subject: [PATCH 05/18] feat(console): walk between a file and the concept it resolves to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files and concepts are two ends of one thing, and until now you could only walk it in one direction. An open document names the concept it becomes: the file's rel minus its document extension, matched against a loaded concept id. Checked against a real files layer — 3,000 of 3,000 markdown notes matched, and the 30 attachments beside them matched nothing, which is the point: a file the cascade does not read gets no strip rather than a link to a concept that isn't there. A contested concept says so in words and an icon, the same marker the canvas node uses, never the amber alone. The other direction needs the contributor's real source name, not its lane, so ConceptSection and Dissent now carry sourceLayer — two sources can share a lane and only the name identifies the one holding the file. The "Open file" link is drawn from the /api/files listing rather than guessed as .md, so it appears only where a file actually exists: a files-kind layer may hold the concept as .mdx or .txt, and an MCP or REST-read contributor keeps nothing here at all. Both directions run the navigation guard exactly once, through store actions rather than setView plus a setter — the second ask was the bug that moved the selection whether or not the user said yes. Reveal in Finder rides along on the file header, hidden rather than disabled outside the desktop app: a control that can never do anything says less than no control at all. Co-Authored-By: Claude Opus 5 Signed-off-by: John Siracusa --- apps/console/CLAUDE.md | 14 +++- apps/console/src/api.ts | 2 + apps/console/src/components/ConceptDetail.tsx | 57 ++++++++++++++++ apps/console/src/data.ts | 9 ++- apps/console/src/desktop.d.ts | 7 ++ apps/console/src/layer-files.ts | 15 ++-- apps/console/src/reveal.ts | 40 +++++++++++ apps/console/src/store.tsx | 38 +++++++++-- apps/console/src/views/Canvas.test.ts | 3 +- apps/console/src/views/Files.test.tsx | 64 ++++++++++++++++- apps/console/src/views/Files.tsx | 68 ++++++++++++++++++- 11 files changed, 300 insertions(+), 17 deletions(-) create mode 100644 apps/console/src/reveal.ts diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index 1445689..f1394e7 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -50,9 +50,17 @@ and `npm test`; CI runs both. dev/build/typecheck/test all regenerate 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. + 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 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..527d3bf 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 { 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 } = useStore() + const { layers } = useLayerFiles(mode === 'live', sources.length) + 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/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 666e776..150aacc 100644 --- a/apps/console/src/desktop.d.ts +++ b/apps/console/src/desktop.d.ts @@ -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 index 0ea531e..9ad5be6 100644 --- a/apps/console/src/layer-files.ts +++ b/apps/console/src/layer-files.ts @@ -1,10 +1,17 @@ // The `/api/files` listing, shared by the two views that need it. // // Sources reads it for a source's file count and root path; Files reads it to -// build the navigator tree. One module so both 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 -// both views explain that state instead of rendering an empty list. +// 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. +// +// Deliberately uncached. 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 } from './api' import type { LayerFiles } from './types' 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/store.tsx b/apps/console/src/store.tsx index 9f2821e..d99de8b 100644 --- a/apps/console/src/store.tsx +++ b/apps/console/src/store.tsx @@ -195,8 +195,14 @@ export interface Store { /** 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. */ - openFilesScope: (layer: 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 @@ -559,14 +565,34 @@ export function StoreProvider({ children }: { children: ReactNode }) { * 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) => { + const openFilesScope = useCallback((layer: string | null, file?: string | null) => { if (!dispatchNavigationGuard()) return setFilesScopeState(layer) - setFilesPathState((current) => (layer && current && !current.startsWith(`${layer}/`) ? null : current)) + 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, @@ -779,10 +805,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, setView, setTriageTab, setSelSignal, setSelConflict, setSelConcept, setQuery, - setFilesScope, setFilesPath, openFilesScope, + setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat, setChatInput, filtered, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, - }), [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, setView, setQuery, setFilesScope, setFilesPath, openFilesScope, openChat, closeChat]) + }), [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, setView, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat]) return {children} } 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 7730f9e..d0ab175 100644 --- a/apps/console/src/views/Files.test.tsx +++ b/apps/console/src/views/Files.test.tsx @@ -7,7 +7,15 @@ import { Files } from './Files' const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), reload: vi.fn(), - store: { mode: 'live', sources: [] as unknown[], query: '', scope: null as string | null, path: null as string | null }, + openConcept: vi.fn(), + store: { + mode: 'live', + sources: [] as unknown[], + concepts: [] as unknown[], + query: '', + scope: null as string | null, + path: null as string | null, + }, })) vi.mock('../api', () => ({ apiFetch: mocks.apiFetch })) @@ -23,12 +31,14 @@ vi.mock('../store', async () => { return { mode: mocks.store.mode, sources: mocks.store.sources, + concepts: mocks.store.concepts, reload: mocks.reload, query: mocks.store.query, filesScope, filesPath, setFilesScope, setFilesPath, + openConcept: mocks.openConcept, } }, } @@ -94,8 +104,10 @@ 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 @@ -501,3 +513,53 @@ describe('Files navigator tree', () => { expect(container.textContent).toContain('Nothing matches that') }) }) + +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('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('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 b01b13e..fdbdb6a 100644 --- a/apps/console/src/views/Files.tsx +++ b/apps/console/src/views/Files.tsx @@ -7,15 +7,20 @@ 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 { useLayerFiles } from '../layer-files' +import { useReveal } from '../reveal' import { useStore } from '../store' import type { FileContent, LayerFile } from '../types' type Tab = 'rendered' | 'raw' +/** Extensions the cascade reads as documents — a concept id is the rel minus one of these. */ +const DOC_EXT = /\.(md|markdown|mdx|txt)$/i + async function getJson(path: string): Promise { const res = await apiFetch(path, { headers: { accept: 'application/json' } }) const data = await res.json().catch(() => ({}) as { error?: string }) @@ -73,8 +78,25 @@ function absentReason(kind: string | undefined): string { 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, filesScope, filesPath, setFilesScope, setFilesPath } = useStore() + const { mode, concepts, sources, reload, query, filesScope, filesPath, setFilesScope, setFilesPath, openConcept } = useStore() const [file, setFile] = useState(null) const [fileError, setFileError] = useState(null) const [tab, setTab] = useState('rendered') @@ -87,6 +109,7 @@ export function Files() { const editorRef = useRef(null) const navigatorRef = useRef(null) const detail = useDetailSurface(detailOpen) + const finder = useReveal() const live = mode === 'live' const selected = filesPath @@ -288,6 +311,7 @@ export function Files() { 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 (
@@ -381,6 +405,18 @@ export function Files() {
)} + {/* Desktop only, and absent rather than disabled elsewhere: the + web build has no Finder to reveal anything in. */} + {finder.available && ( + + )} + {file.editable && ( + {/* 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') ? ( From 46c0cd7cc209a41f1502741758e56a77dc7a8b3d Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 12:32:20 -0400 Subject: [PATCH 06/18] feat(console): edit a source's folder instead of removing and re-adding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail panel said, verbatim, "The path, repository, or command is fixed for this source. To change it, remove the source and add it again." For a folder-backed source that is no longer true, so it is gone; for a repo or an MCP command it still is, so each of those keeps its own version of the sentence saying which case it is. The folder is a labelled field in the existing rename/level panel — inline, where the other two live, not a new dialog — with the native picker beside it in the desktop app. Only a real move is sent: an untouched field must not re-key the index entry and put a settled source through a full re-read for nothing. A move answers with reindexing:true, and the panel names the cause before the row turns blue on its own. The control that opens the panel now names what the panel can change; a button called "Rename / level" over a form that also repoints the source would hide the very thing this adds, and the visible words have to be inside the accessible name. Reveal in Finder sits beside Browse files, for the sources that keep something on this machine, and only inside the desktop app. Co-Authored-By: Claude Opus 5 Signed-off-by: John Siracusa --- apps/console/src/views/Sources.test.tsx | 112 +++++++++++++++++++- apps/console/src/views/Sources.tsx | 130 +++++++++++++++++++++--- 2 files changed, 224 insertions(+), 18 deletions(-) diff --git a/apps/console/src/views/Sources.test.tsx b/apps/console/src/views/Sources.test.tsx index b28c185..0903abb 100644 --- a/apps/console/src/views/Sources.test.tsx +++ b/apps/console/src/views/Sources.test.tsx @@ -42,6 +42,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) @@ -254,11 +260,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()) @@ -266,6 +271,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() }) @@ -273,7 +280,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') }) @@ -290,7 +297,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()) @@ -337,6 +344,101 @@ describe('Sources → Files', () => { 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: 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', () => { diff --git a/apps/console/src/views/Sources.tsx b/apps/console/src/views/Sources.tsx index d28b2b9..6cad042 100644 --- a/apps/console/src/views/Sources.tsx +++ b/apps/console/src/views/Sources.tsx @@ -1,9 +1,10 @@ // 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, useMemo, useRef, useState } from 'react' import { C, css, MONO } from '../theme' @@ -12,6 +13,7 @@ import { LayerChip } from '../components/LayerChip' import { LevelStepper } from '../components/SetupWizard' import { useDetailSurface } from '../components/useDetailSurface' import { useLayerFiles } from '../layer-files' +import { useReveal } from '../reveal' import { useStore } from '../store' import type { Source } from '../data' import type { LayerFiles } from '../types' @@ -96,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};`) } @@ -174,8 +201,10 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { 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) @@ -232,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) } @@ -241,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)) @@ -342,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}
@@ -358,10 +405,11 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { {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 @@ -402,6 +450,11 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { {notice.text}
)} + {finder.error && ( +
+ {finder.error} +
+ )} {syncErr?.name === s.name && (
{syncErr.text} @@ -423,6 +476,16 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { onClick={() => openFilesScope(s.name)} >Browse files )} + {/* Desktop only — hidden, not disabled, in the browser build. */} + {finder.available && filesByLayer.has(s.name) && ( + + )} {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. */} + {!s.quarantined && ( + + )}
)} @@ -456,8 +531,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}

} From 4ba23088c62dcb524c5cb9425b595b4b54a57e8e Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 13:04:16 -0400 Subject: [PATCH 07/18] feat(console): let the Web Demo browse the files behind its sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigator was live-only, so the one place people evaluate ContextCake without installing it — the public Web Demo — showed an explanatory gate where the feature is. The demo now renders the same navigator, read-only: the tree, folder counts, scoping, deep links, the rendered document, and the file ⇄ concept links in both directions. build-demo-data.mjs produces the fixture by calling the engine's own listFilesApi/readFileApi over the demo bundle, the same way it already shells out to resolver.mjs for the cascade — the snapshot is generated engine output, never hand-authored, and it captures only the two GET answers, so there is no write path for the demo to fake. Saving is gated on `live && file.editable`, which also unbinds ⌘S and makes the editor readOnly. A binary in the snapshot says it carries text rather than spinning on a raw fetch that cannot be served. Sources keeps its read-only demo panel but still offers the way in, and the palette entry per source goes with it: browsing is a read. Layer roots are rewritten repo-relative in the fixture — the bundle ships publicly and the build machine's home directory has no business in it. The demo corpus gains one file, personal/notes/scratch.txt: a plain .txt in an OKF bundle is listed by the navigator and read by no adapter, which is exactly the case the "resolves to" strip has to stay silent about. Signed-off-by: John Siracusa --- CLAUDE.md | 2 +- apps/console/CLAUDE.md | 19 +++-- apps/console/scripts/build-demo-data.mjs | 41 +++++++-- apps/console/src/App.tsx | 17 ++-- apps/console/src/components/ConceptDetail.tsx | 2 +- apps/console/src/layer-files.ts | 69 +++++++++++---- apps/console/src/types.ts | 11 +++ apps/console/src/views/Files.test.tsx | 84 +++++++++++++++++-- apps/console/src/views/Files.tsx | 54 +++++++----- apps/console/src/views/Sources.test.tsx | 11 +++ apps/console/src/views/Sources.tsx | 14 ++-- .../demo-layers/personal/notes/scratch.txt | 9 ++ 12 files changed, 260 insertions(+), 73 deletions(-) create mode 100644 apps/playground/demo-layers/personal/notes/scratch.txt diff --git a/CLAUDE.md b/CLAUDE.md index 5015639..d8290ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,4 +146,4 @@ Key files: - **`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 f1394e7..ab5378a 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,9 +48,13 @@ 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 — + 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 @@ -79,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 diff --git a/apps/console/scripts/build-demo-data.mjs b/apps/console/scripts/build-demo-data.mjs index e5a3a6b..5dfb3b9 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) { @@ -121,9 +130,27 @@ const graph = { 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 f82b0fc..2106dae 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -176,15 +176,14 @@ export function App() { { 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. - ...(mode === 'live' - ? 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), - })) - : []), + // 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 }, diff --git a/apps/console/src/components/ConceptDetail.tsx b/apps/console/src/components/ConceptDetail.tsx index 527d3bf..15de6fe 100644 --- a/apps/console/src/components/ConceptDetail.tsx +++ b/apps/console/src/components/ConceptDetail.tsx @@ -25,7 +25,7 @@ const contributorKey = (layer: string, conceptId: string) => JSON.stringify([lay */ function useFileByContributor(): Map { const { mode, sources } = useStore() - const { layers } = useLayerFiles(mode === 'live', sources.length) + const { layers } = useLayerFiles(mode, sources.length) return useMemo(() => { const best = new Map() for (const entry of layers ?? []) { diff --git a/apps/console/src/layer-files.ts b/apps/console/src/layer-files.ts index 9ad5be6..497165e 100644 --- a/apps/console/src/layer-files.ts +++ b/apps/console/src/layer-files.ts @@ -1,28 +1,56 @@ -// The `/api/files` listing, shared by the two views that need it. +// The `/api/files` listing and the `/api/file` read, shared by every view that +// needs them. // -// Sources reads it 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 +// 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. // -// Deliberately uncached. 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). +// 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 } from './api' -import type { LayerFiles } from './types' +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(): Promise { +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 +} + export interface LayerFilesState { /** Null until the first answer lands — "unknown", never "empty". */ layers: LayerFiles[] | null @@ -32,24 +60,33 @@ export interface LayerFilesState { /** * `revalidate` re-runs the walk when it changes; pass whatever identifies the * current source set. The listing is cheap even mid-index, so this deliberately - * does not wait on the cascade. + * 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(enabled: boolean, revalidate: unknown): LayerFilesState { - const [state, setState] = useState({ layers: null, error: null }) +export function useLayerFiles(mode: Mode, revalidate: unknown): LayerFilesState { + const [state, setState] = useState( + () => ({ layers: mode === 'demo' ? demoFiles.layers : null, error: null }), + ) useEffect(() => { - if (!enabled) return + 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() + 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 } - }, [enabled, revalidate]) + }, [mode, revalidate]) return state } 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/Files.test.tsx b/apps/console/src/views/Files.test.tsx index d0ab175..dea158b 100644 --- a/apps/console/src/views/Files.test.tsx +++ b/apps/console/src/views/Files.test.tsx @@ -3,6 +3,11 @@ 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(), @@ -252,14 +257,6 @@ describe('Files view', () => { expect(window.confirm).toHaveBeenCalled() }) - it('tells demo users why there is nothing to edit', async () => { - mocks.store.mode = 'demo' - await act(async () => root.render()) - - expect(container.textContent).toContain('live-mode view') - expect(mocks.apiFetch).not.toHaveBeenCalled() - }) - it('explains an empty state when no source has files on disk', async () => { mocks.apiFetch.mockImplementation(async () => json({ layers: [] })) await act(async () => root.render()) @@ -514,6 +511,54 @@ describe('Files navigator tree', () => { }) }) +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('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 = [{ @@ -545,6 +590,29 @@ describe('Files → concept', () => { 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()) diff --git a/apps/console/src/views/Files.tsx b/apps/console/src/views/Files.tsx index fdbdb6a..ff49a49 100644 --- a/apps/console/src/views/Files.tsx +++ b/apps/console/src/views/Files.tsx @@ -4,6 +4,12 @@ // 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). +// +// 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' @@ -11,7 +17,7 @@ import type { Concept } from '../data' import { buildTree, FileTree } from '../components/FileTree' import { Markdown } from '../components/Markdown' import { useDetailSurface } from '../components/useDetailSurface' -import { useLayerFiles } from '../layer-files' +import { readLayerFile, useLayerFiles } from '../layer-files' import { useReveal } from '../reveal' import { useStore } from '../store' import type { FileContent, LayerFile } from '../types' @@ -21,13 +27,6 @@ type Tab = 'rendered' | 'raw' /** Extensions the cascade reads as documents — a concept id is the rel minus one of these. */ const DOC_EXT = /\.(md|markdown|mdx|txt)$/i -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 Empty({ title, detail }: { title: string; detail: string }) { return (
@@ -115,11 +114,14 @@ export function Files() { const selected = filesPath const selectedRef = useRef(selected) selectedRef.current = selected - const dirty = file?.text !== undefined && draft !== file.text + // 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 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. - const { layers, error: listError } = useLayerFiles(live, sources.length) + const { layers, error: listError } = useLayerFiles(mode, sources.length) // Which source each layer belongs to, for the layer-coloured root rows. The // hues are product semantics (personal amber / team teal / company indigo), @@ -156,7 +158,7 @@ export function Files() { 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 ?? '') @@ -170,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. @@ -182,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)}`) @@ -273,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() @@ -283,11 +292,8 @@ export function Files() { } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [save]) + }, [canEdit, save]) - if (!live) { - return - } if (listError) { return } @@ -388,6 +394,8 @@ export function Files() { {(file.bytes / 1024).toFixed(1)} KB · edited {new Date(file.modified).toLocaleString()} {savedAt && !dirty && · saved {savedAt}} {dirty && · unsaved changes} + {/* Where the Save button would be, an honest reason it isn't. */} + {!live && · read-only in the demo}
@@ -417,7 +425,7 @@ export function Files() { >Reveal in Finder )} - {file.editable && ( + {canEdit && ( )} {/* Desktop only — hidden, not disabled, in the browser build. */} - {finder.available && filesByLayer.has(s.name) && ( + {live && finder.available && filesByLayer.has(s.name) && ( )} - {canSync(s) && ( + {live && canSync(s) && ( )} - + {live && } )} 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. From 089f62768a6431229d0babbb831e91b72786160f Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 13:04:25 -0400 Subject: [PATCH 08/18] docs: document the source navigator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Files view had no user-facing documentation — the only file explorer described anywhere was the playground's, which is a different surface for a different route. Adds a guide under Guides (first, ahead of the playground tour: the app is the recommended route) covering the way in from a source, the tree and its keyboard contract, scoping, deep links, reading and editing with the limits that are deliberate, the file ⇄ concept rule, Reveal in Finder, repointing a source's folder, and which source kinds keep no files to browse at all. The demo page promised only resolved concepts and conflicts; it shows the navigator now, so the third card says so. The console README predated both the Files and Sources views and listed neither. Signed-off-by: John Siracusa --- apps/console/README.md | 18 ++- apps/site/astro.config.mjs | 1 + .../docs/docs/guides/browsing-your-files.md | 145 ++++++++++++++++++ apps/site/src/pages/demo.astro | 4 +- 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 apps/site/src/content/docs/docs/guides/browsing-your-files.md 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/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..de24d98 --- /dev/null +++ b/apps/site/src/content/docs/docs/guides/browsing-your-files.md @@ -0,0 +1,145 @@ +--- +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. 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.', }, ]; --- From 6949b479e604e430cf48b8743c09932cc5c6a7df Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 13:46:00 -0400 Subject: [PATCH 09/18] fix(console): refetch the file listing when a source moves, not when one is added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three views that read /api/files all keyed their effect on `sources.length`, and a reload() replaces the sources array without ever changing its length. So the two writes this PR added were invisible to the panel it added: - rename `notes` → `notes-2`: filesByLayer is still keyed by `notes`, so a source with 3,000 files renders "None on this machine" and its Browse files button disappears. - repoint `notes` from /a to /b: the PATCH lands and the engine re-indexes, but Location and the file count keep quoting /a — and the edit panel prefills the folder field with it. Both self-heal on remount, which is why nothing caught them. `filesRevalidation()` is now the one answer to that question, used at all three call sites. The source names cover add/remove/rename, including a change made outside this app that arrives through the poll rather than a write; the store's `reloadKey` covers a repoint, where the name is the one thing that did not change. Three tests, each pinning one half: rename and repoint go red on `sources.length` with exactly the reported symptoms, and the out-of-band add keeps the coverage the count used to give. Signed-off-by: John Siracusa --- apps/console/CLAUDE.md | 6 ++ apps/console/src/components/ConceptDetail.tsx | 6 +- apps/console/src/layer-files.ts | 29 +++++- apps/console/src/store.tsx | 12 ++- apps/console/src/views/Files.test.tsx | 48 +++++++++ apps/console/src/views/Files.tsx | 6 +- apps/console/src/views/Sources.test.tsx | 98 ++++++++++++++++++- apps/console/src/views/Sources.tsx | 6 +- 8 files changed, 194 insertions(+), 17 deletions(-) diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index ab5378a..4610844 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -139,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/src/components/ConceptDetail.tsx b/apps/console/src/components/ConceptDetail.tsx index 15de6fe..c0351bd 100644 --- a/apps/console/src/components/ConceptDetail.tsx +++ b/apps/console/src/components/ConceptDetail.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { C, css, lc, MONO, conceptTypeStyle } from '../theme' import { layerName } from '../data' import type { Concept } from '../data' -import { useLayerFiles } from '../layer-files' +import { filesRevalidation, useLayerFiles } from '../layer-files' import { useStore } from '../store' import { LayerChip } from './LayerChip' @@ -24,8 +24,8 @@ const contributorKey = (layer: string, conceptId: string) => JSON.stringify([lay * link, and so no affordance that opens on an error. */ function useFileByContributor(): Map { - const { mode, sources } = useStore() - const { layers } = useLayerFiles(mode, sources.length) + const { mode, sources, reloadKey } = useStore() + const { layers } = useLayerFiles(mode, filesRevalidation(sources, reloadKey)) return useMemo(() => { const best = new Map() for (const entry of layers ?? []) { diff --git a/apps/console/src/layer-files.ts b/apps/console/src/layer-files.ts index 497165e..f32569a 100644 --- a/apps/console/src/layer-files.ts +++ b/apps/console/src/layer-files.ts @@ -51,6 +51,24 @@ export async function readLayerFile(mode: Mode, path: string): Promise source.name)].join('\u0000') +} + export interface LayerFilesState { /** Null until the first answer lands — "unknown", never "empty". */ layers: LayerFiles[] | null @@ -58,11 +76,12 @@ export interface LayerFilesState { } /** - * `revalidate` re-runs the walk when it changes; pass whatever identifies the - * current source set. 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. + * `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( diff --git a/apps/console/src/store.tsx b/apps/console/src/store.tsx index d99de8b..fa2038d 100644 --- a/apps/console/src/store.tsx +++ b/apps/console/src/store.tsx @@ -216,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) @@ -807,8 +815,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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, filesScope, filesPath, query, chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, filtered, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, setView, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, 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/views/Files.test.tsx b/apps/console/src/views/Files.test.tsx index dea158b..8b5af83 100644 --- a/apps/console/src/views/Files.test.tsx +++ b/apps/console/src/views/Files.test.tsx @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ query: '', scope: null as string | null, path: null as string | null, + reloadKey: 0, }, })) @@ -38,6 +39,7 @@ vi.mock('../store', async () => { sources: mocks.store.sources, concepts: mocks.store.concepts, reload: mocks.reload, + reloadKey: mocks.store.reloadKey, query: mocks.store.query, filesScope, filesPath, @@ -116,6 +118,7 @@ beforeEach(() => { 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) => { @@ -290,6 +293,22 @@ const NESTED = { }], } +/** + * 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 })) } @@ -312,6 +331,35 @@ describe('Files navigator tree', () => { 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) diff --git a/apps/console/src/views/Files.tsx b/apps/console/src/views/Files.tsx index ff49a49..dae4cca 100644 --- a/apps/console/src/views/Files.tsx +++ b/apps/console/src/views/Files.tsx @@ -17,7 +17,7 @@ import type { Concept } from '../data' import { buildTree, FileTree } from '../components/FileTree' import { Markdown } from '../components/Markdown' import { useDetailSurface } from '../components/useDetailSurface' -import { readLayerFile, useLayerFiles } from '../layer-files' +import { filesRevalidation, readLayerFile, useLayerFiles } from '../layer-files' import { useReveal } from '../reveal' import { useStore } from '../store' import type { FileContent, LayerFile } from '../types' @@ -95,7 +95,7 @@ function conceptForFile(file: FileContent | null, concepts: Concept[]): { concep } export function Files() { - const { mode, concepts, sources, reload, query, filesScope, filesPath, setFilesScope, setFilesPath, openConcept } = useStore() + 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') @@ -121,7 +121,7 @@ export function Files() { // 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. - const { layers, error: listError } = useLayerFiles(mode, 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), diff --git a/apps/console/src/views/Sources.test.tsx b/apps/console/src/views/Sources.test.tsx index aa9ae28..ea517b6 100644 --- a/apps/console/src/views/Sources.test.tsx +++ b/apps/console/src/views/Sources.test.tsx @@ -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, openFilesScope: mocks.openFilesScope }) + 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}`) @@ -374,6 +398,78 @@ describe('Sources → Files', () => { }) }) +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: [] }], diff --git a/apps/console/src/views/Sources.tsx b/apps/console/src/views/Sources.tsx index 596ce43..6c04d66 100644 --- a/apps/console/src/views/Sources.tsx +++ b/apps/console/src/views/Sources.tsx @@ -12,7 +12,7 @@ import { apiFetch, progressLabel, progressPercent } from '../api' import { LayerChip } from '../components/LayerChip' import { LevelStepper } from '../components/SetupWizard' import { useDetailSurface } from '../components/useDetailSurface' -import { useLayerFiles } from '../layer-files' +import { filesRevalidation, useLayerFiles } from '../layer-files' import { useReveal } from '../reveal' import { useStore } from '../store' import type { Source } from '../data' @@ -190,11 +190,11 @@ function filesSummary(source: Source, entry: LayerFiles | undefined, known: bool } export function Sources({ onAddSource }: { onAddSource?: () => void }) { - const { mode, sources, reload, query, openFilesScope } = 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, sources.length) + 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) From aa26de85d787334e8f98acda0e4257ab730534a7 Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 13:46:12 -0400 Subject: [PATCH 10/18] fix(console): stop deep nesting from indenting a tree row's name to nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paddingLeft: 8 + depth * 13` had no cap and `.cc-tree-name` had no floor, inside a `minmax(220px, 300px)` column with `overflow-x: hidden`. Measured against the navigator's own CSS at the column's *maximum* width: depth=10 indent=138px name=112.9px visible depth=14 indent=190px name= 72.0px visible depth=20 indent=268px name= 0.0px blank depth=30 indent=398px name= 0.0px blank A file 20 folders down was a row you could click and focus with nothing written on it. `walkAll` caps files, not depth, so a docs monorepo or a foldered vault reaches this; the 3,000-note test vault maxes out at depth 3, which is why it would have shipped. The indent stops growing at 10 levels — the deepest inset that still leaves a name room at the column's narrowest — and `.cc-tree-name` gets a 4ch floor so flex cannot take it to zero either way. Past the cap rows share an inset; `aria-level` and the row's `title` still carry the real depth, so the picture flattens and the tree's account of itself does not. Signed-off-by: John Siracusa --- apps/console/src/components/FileTree.tsx | 17 ++++++++++++++++- apps/console/src/styles.css | 7 ++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/console/src/components/FileTree.tsx b/apps/console/src/components/FileTree.tsx index 53360c2..02af11a 100644 --- a/apps/console/src/components/FileTree.tsx +++ b/apps/console/src/components/FileTree.tsx @@ -27,6 +27,21 @@ export const ROW_HEIGHT = 28 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` — @@ -186,7 +201,7 @@ const Row = memo(function Row({ entry, index, active, selected, expanded, layerI data-root={root ? 'true' : undefined} title={entry.path} className="cc-tree-row" - style={{ top: index * ROW_HEIGHT, paddingLeft: 8 + entry.depth * 13 }} + style={{ top: index * ROW_HEIGHT, paddingLeft: rowIndent(entry.depth) }} onClick={() => onOpen(entry)} > {dir ? :