From 94a156da5d7ee3f2d8557482a62692663bc3a186 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:12:51 -0700 Subject: [PATCH 01/37] fix(file-tree): filter excluded paths in core updates --- packages/core/src/file-tree/file-tree.ts | 13 +- .../src/file-tree/watch/classifier.test.ts | 142 ++++++++++-------- .../core/src/file-tree/watch/classifier.ts | 11 +- 3 files changed, 87 insertions(+), 79 deletions(-) diff --git a/packages/core/src/file-tree/file-tree.ts b/packages/core/src/file-tree/file-tree.ts index c45a0b29bf..31f8bb2229 100644 --- a/packages/core/src/file-tree/file-tree.ts +++ b/packages/core/src/file-tree/file-tree.ts @@ -3,7 +3,7 @@ import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; import type { IFileWatchService, RawFileEvent, WatchHandle } from '../fs'; import { LiveCollection, type KeyedOp } from '../lib'; import { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './errors'; -import { watchIgnoreGlobs } from './ignores'; +import { isExcludedPath, watchIgnoreGlobs } from './ignores'; import { listChildren } from './list'; import type { FileNode, NodeId } from './models/tree'; import { NodeIdAssigner } from './node-id'; @@ -23,8 +23,6 @@ const REVALIDATE_INTERVAL_MS = 5 * 60_000; export type FileTreeOptions = { rootPath: string; watcher: IFileWatchService; - watchDebounceMs?: number; - revalidateIntervalMs?: number; onError?: FileTreeOnError; }; @@ -55,7 +53,7 @@ export class FileTree implements IFileTree { ); }, { - debounceMs: options.watchDebounceMs ?? WATCH_DEBOUNCE_MS, + debounceMs: WATCH_DEBOUNCE_MS, ignore: watchIgnoreGlobs(), onResync: () => { void this.resync().catch((error) => @@ -64,7 +62,7 @@ export class FileTree implements IFileTree { }, } ); - const interval = options.revalidateIntervalMs ?? REVALIDATE_INTERVAL_MS; + const interval = REVALIDATE_INTERVAL_MS; this.revalidateTimer = interval > 0 ? setInterval(() => { @@ -212,10 +210,11 @@ export class FileTree implements IFileTree { const listed = await listChildren(this.rootPath, dirPath); if (!listed.success) return listed; - const listedPaths = new Set(listed.data.map((entry) => entry.path)); + const listedEntries = listed.data.filter((entry) => !isExcludedPath(entry.path)); + const listedPaths = new Set(listedEntries.map((entry) => entry.path)); let sequence = this.removeMissingChildren(scope, listedPaths); - const nodes = listed.data.map((entry) => + const nodes = listedEntries.map((entry) => this.ids.upsert(entry, scope, this.ids.getByPath(entry.path)?.childrenLoaded) ); const loaded = await this.collection.loadScope(scope, async () => diff --git a/packages/core/src/file-tree/watch/classifier.test.ts b/packages/core/src/file-tree/watch/classifier.test.ts index d3a127d7c3..1818b447a7 100644 --- a/packages/core/src/file-tree/watch/classifier.test.ts +++ b/packages/core/src/file-tree/watch/classifier.test.ts @@ -1,31 +1,41 @@ +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; -import { err, ok } from '@emdash/shared'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, afterEach } from 'vitest'; import type { RawFileEvent } from '../../fs'; -import type { DevIno, ListedEntry } from '../list'; +import { statEntry, type DevIno, type ListedEntry } from '../list'; import type { FileNodeType, NodeId } from '../models/tree'; import { NodeIdAssigner } from '../node-id'; -import { classifyFileTreeWatchEvents, type FileTreeStatEntry } from './classifier'; +import { classifyFileTreeWatchEvents } from './classifier'; -const rootPath = path.resolve('repo'); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); describe('classifyFileTreeWatchEvents', () => { it('ignores content update events', async () => { + const root = await makeRoot(); const ids = new NodeIdAssigner(); ids.upsert(entry('a.txt', 'file', '1:1'), null); - const classification = await classify(ids, [{ kind: 'update', path: absPath('a.txt') }]); + const classification = await classify(root, ids, [ + { kind: 'update', path: absPath(root, 'a.txt') }, + ]); expect(classification.ops).toEqual([]); expect(classification.unloadedScopes).toEqual([]); }); it('emits a put for a create event in a loaded scope', async () => { + const root = await makeRoot(); + await writeFile(path.join(root, 'a.txt'), 'a', 'utf8'); const ids = new NodeIdAssigner(); - const classification = await classify(ids, [{ kind: 'create', path: absPath('a.txt') }], { - stats: [entry('a.txt', 'file', '1:1')], - }); + const classification = await classify(root, ids, [ + { kind: 'create', path: absPath(root, 'a.txt') }, + ]); expect(classification.ops).toMatchObject([ { op: 'put', key: expect.any(Number), value: { path: 'a.txt', parentId: null } }, @@ -34,54 +44,56 @@ describe('classifyFileTreeWatchEvents', () => { }); it('ignores creates under unloaded directory scopes', async () => { + const root = await makeRoot(); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, 'src', 'a.ts'), 'a', 'utf8'); const ids = new NodeIdAssigner(); - ids.upsert(entry('src', 'directory', '1:1'), null); + ids.upsert(unwrap(await statEntry(root, 'src')), null); - const classification = await classify(ids, [{ kind: 'create', path: absPath('src/a.ts') }], { - stats: [entry('src/a.ts', 'file', '1:2')], - }); + const classification = await classify(root, ids, [ + { kind: 'create', path: absPath(root, 'src/a.ts') }, + ]); expect(classification.ops).toEqual([]); expect(ids.getByPath('src/a.ts')).toBeUndefined(); }); it('ignores runtime creates for excluded paths before statting them', async () => { + const root = await makeRoot(); const ids = new NodeIdAssigner(); - let statCalls = 0; - const classification = await classifyFileTreeWatchEvents( + const classification = await classify( + root, + ids, [ - { kind: 'create', path: absPath('node_modules') }, - { kind: 'create', path: absPath('node_modules/pkg/index.js') }, - { kind: 'create', path: absPath('.DS_Store') }, + { kind: 'create', path: absPath(root, 'node_modules') }, + { kind: 'create', path: absPath(root, 'node_modules/pkg/index.js') }, + { kind: 'create', path: absPath(root, '.DS_Store') }, ], - { - rootPath, - ids, - isScopeLoaded: () => true, - statEntry: async () => { - statCalls += 1; - return err({ type: 'fs-error', path: '', message: 'stat should not be called' }); - }, - } + { isScopeLoaded: () => true } ); expect(classification.ops).toEqual([]); expect(classification.unloadedScopes).toEqual([]); - expect(statCalls).toBe(0); expect(ids.getByPath('node_modules')).toBeUndefined(); expect(ids.getByPath('.DS_Store')).toBeUndefined(); }); it('cascades deletes for unmatched directory tombstones', async () => { + const root = await makeRoot(); const ids = new NodeIdAssigner(); const src = ids.upsert(entry('src', 'directory', '1:1'), null, true); const nested = ids.upsert(entry('src/nested', 'directory', '1:2'), src.id, true); const file = ids.upsert(entry('src/nested/a.ts', 'file', '1:3'), nested.id); - const classification = await classify(ids, [{ kind: 'delete', path: absPath('src') }], { - loadedScopes: new Set([null, src.id, nested.id]), - }); + const classification = await classify( + root, + ids, + [{ kind: 'delete', path: absPath(root, 'src') }], + { + loadedScopes: new Set([null, src.id, nested.id]), + } + ); expect(classification.ops).toEqual([ { op: 'del', key: file.id }, @@ -94,19 +106,16 @@ describe('classifyFileTreeWatchEvents', () => { }); it('reuses a file node id for a delete/create rename batch with matching inode', async () => { + const root = await makeRoot(); + await writeFile(path.join(root, 'a.txt'), 'a', 'utf8'); const ids = new NodeIdAssigner(); - const before = ids.upsert(entry('a.txt', 'file', '1:1'), null); + const before = ids.upsert(unwrap(await statEntry(root, 'a.txt')), null); - const classification = await classify( - ids, - [ - { kind: 'delete', path: absPath('a.txt') }, - { kind: 'create', path: absPath('b.txt') }, - ], - { - stats: [entry('b.txt', 'file', '1:1')], - } - ); + await rename(path.join(root, 'a.txt'), path.join(root, 'b.txt')); + const classification = await classify(root, ids, [ + { kind: 'delete', path: absPath(root, 'a.txt') }, + { kind: 'create', path: absPath(root, 'b.txt') }, + ]); expect(classification.ops).toEqual([ { @@ -121,20 +130,24 @@ describe('classifyFileTreeWatchEvents', () => { }); it('moves loaded descendants when a directory rename reuses the directory id', async () => { + const root = await makeRoot(); + await mkdir(path.join(root, 'src', 'nested'), { recursive: true }); + await writeFile(path.join(root, 'src', 'nested', 'a.ts'), 'a', 'utf8'); const ids = new NodeIdAssigner(); - const src = ids.upsert(entry('src', 'directory', '1:1'), null, true); - const nested = ids.upsert(entry('src/nested', 'directory', '1:2'), src.id, true); - const file = ids.upsert(entry('src/nested/a.ts', 'file', '1:3'), nested.id); + const src = ids.upsert(unwrap(await statEntry(root, 'src')), null, true); + const nested = ids.upsert(unwrap(await statEntry(root, 'src/nested')), src.id, true); + const file = ids.upsert(unwrap(await statEntry(root, 'src/nested/a.ts')), nested.id); + await rename(path.join(root, 'src'), path.join(root, 'lib')); const classification = await classify( + root, ids, [ - { kind: 'delete', path: absPath('src') }, - { kind: 'create', path: absPath('lib') }, + { kind: 'delete', path: absPath(root, 'src') }, + { kind: 'create', path: absPath(root, 'lib') }, ], { loadedScopes: new Set([null, src.id, nested.id]), - stats: [entry('lib', 'directory', '1:1')], } ); @@ -159,46 +172,46 @@ describe('classifyFileTreeWatchEvents', () => { }); it('ignores events outside the watched root', async () => { + const root = await makeRoot(); + const outside = await makeRoot(); const ids = new NodeIdAssigner(); - const classification = await classify(ids, [ - { kind: 'create', path: path.resolve('outside/a.txt') }, + const classification = await classify(root, ids, [ + { kind: 'create', path: path.join(outside, 'a.txt') }, ]); expect(classification.ops).toEqual([]); }); }); +async function makeRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'emdash-file-tree-classifier-')); + roots.push(root); + return root; +} + async function classify( + rootPath: string, ids: NodeIdAssigner, events: RawFileEvent[], options: { loadedScopes?: Set; - stats?: ListedEntry[]; + isScopeLoaded?: (scope: NodeId | null) => boolean; } = {} ) { const loadedScopes = options.loadedScopes ?? new Set([null]); return classifyFileTreeWatchEvents(events, { rootPath, ids, - isScopeLoaded: (scope) => loadedScopes.has(scope), - statEntry: statEntryFrom(options.stats ?? []), + isScopeLoaded: options.isScopeLoaded ?? ((scope) => loadedScopes.has(scope)), }); } -function statEntryFrom(entries: ListedEntry[]): FileTreeStatEntry { - const byPath = new Map(entries.map((listed) => [listed.path, listed])); - return async (_root, relPath) => { - const listed = byPath.get(relPath); - return listed ? ok(listed) : err({ type: 'not-found', path: relPath }); - }; -} - function entry(path: string, type: FileNodeType, devIno?: DevIno): ListedEntry { return { path, name: basename(path), type, devIno }; } -function absPath(relPath: string): string { +function absPath(rootPath: string, relPath: string): string { return path.join(rootPath, ...relPath.split('/')); } @@ -206,3 +219,8 @@ function basename(relPath: string): string { const index = relPath.lastIndexOf('/'); return index === -1 ? relPath : relPath.slice(index + 1); } + +function unwrap(result: { success: true; data: T } | { success: false; error: E }): T { + if (!result.success) throw new Error(`Expected ok result: ${JSON.stringify(result.error)}`); + return result.data; +} diff --git a/packages/core/src/file-tree/watch/classifier.ts b/packages/core/src/file-tree/watch/classifier.ts index ff37dabee7..4ca1bbedca 100644 --- a/packages/core/src/file-tree/watch/classifier.ts +++ b/packages/core/src/file-tree/watch/classifier.ts @@ -1,24 +1,16 @@ import path from 'node:path'; -import type { Result } from '@emdash/shared'; import type { RawFileEvent } from '../../fs'; import type { KeyedOp } from '../../lib'; -import type { FileTreeError } from '../errors'; import { isExcludedPath } from '../ignores'; import { statEntry as statFileTreeEntry, type ListedEntry } from '../list'; import type { FileNode, NodeId } from '../models/tree'; import type { NodeIdAssigner, Tombstone } from '../node-id'; import { parentRelPath, resolveInsideRoot } from '../paths'; -export type FileTreeStatEntry = ( - rootPath: string, - relPath: string -) => Promise>; - export type FileTreeWatchClassifierOptions = { rootPath: string; ids: NodeIdAssigner; isScopeLoaded: (scope: NodeId | null) => boolean; - statEntry?: FileTreeStatEntry; }; export type FileTreeWatchClassification = { @@ -30,7 +22,6 @@ export async function classifyFileTreeWatchEvents( events: RawFileEvent[], options: FileTreeWatchClassifierOptions ): Promise { - const statEntry = options.statEntry ?? statFileTreeEntry; const tombstones: Tombstone[] = []; const ops: Array> = []; const unloadedScopes: NodeId[] = []; @@ -49,7 +40,7 @@ export async function classifyFileTreeWatchEvents( continue; } - const stat = await statEntry(options.rootPath, relPath); + const stat = await statFileTreeEntry(options.rootPath, relPath); if (!stat.success) continue; const parentId = parentScopeFor(stat.data, options.ids); if (parentId === undefined || !options.isScopeLoaded(parentId)) continue; From a8f523fd1d01bffb7ff9781837c15dfd1c34d615 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:12:58 -0700 Subject: [PATCH 02/37] feat(live): notify collection mirror applied updates --- .../lib/stores/live/collection-mirror.test.ts | 18 ++++++++++++++++++ .../lib/stores/live/collection-mirror.ts | 13 ++++++++++--- .../src/renderer/lib/stores/live/index.ts | 6 +++++- .../renderer/lib/stores/live/mirror-version.ts | 4 ++-- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/lib/stores/live/collection-mirror.test.ts b/apps/emdash-desktop/src/renderer/lib/stores/live/collection-mirror.test.ts index e348b5aeee..54e4d81eec 100644 --- a/apps/emdash-desktop/src/renderer/lib/stores/live/collection-mirror.test.ts +++ b/apps/emdash-desktop/src/renderer/lib/stores/live/collection-mirror.test.ts @@ -158,6 +158,24 @@ describe('CollectionMirror', () => { ]); }); + it('notifies only when snapshots and deltas are accepted', () => { + const applied: string[] = []; + const mirror = new CollectionMirror({ + onApplied: (change) => { + applied.push( + `${change.kind}:${change.kind === 'snapshot' ? change.snapshot.sequence : change.update.sequence}` + ); + }, + }); + + mirror.applyUpdate(delta([{ op: 'put', key: 'buffered', value: { label: 'buffered' } }], 2)); + mirror.setSnapshot(snapshot([['a', { label: 'a' }]], 1)); + mirror.applyUpdate(delta([{ op: 'put', key: 'stale', value: { label: 'stale' } }], 1)); + mirror.applyUpdate(delta([{ op: 'put', key: 'b', value: { label: 'b' } }], 3)); + + expect(applied).toEqual(['snapshot:1', 'delta:2', 'delta:3']); + }); + it('accepts snapshot-shaped updates', () => { const mirror = new CollectionMirror(); mirror.applyUpdate({ kind: 'snapshot', ...snapshot([['a', { label: 'a' }]], 1) }); diff --git a/apps/emdash-desktop/src/renderer/lib/stores/live/collection-mirror.ts b/apps/emdash-desktop/src/renderer/lib/stores/live/collection-mirror.ts index e417a37fdb..c0aa44d6f7 100644 --- a/apps/emdash-desktop/src/renderer/lib/stores/live/collection-mirror.ts +++ b/apps/emdash-desktop/src/renderer/lib/stores/live/collection-mirror.ts @@ -6,8 +6,13 @@ type CollectionDelta = Extract, { kind: 'delta' }>; const DEFAULT_MAX_BUFFERED_DELTAS = 1_000; -export type CollectionMirrorOptions = { +export type CollectionMirrorChange = + | { kind: 'snapshot'; snapshot: CollectionSnapshot } + | { kind: 'delta'; update: CollectionDelta }; + +export type CollectionMirrorOptions = { maxBufferedDeltas?: number; + onApplied?: (change: CollectionMirrorChange) => void; }; export class CollectionMirror { @@ -18,8 +23,8 @@ export class CollectionMirror { private readonly droppedBufferedDeltaGenerations = new Set(); private pendingDeltas: Array> = []; - constructor(options: CollectionMirrorOptions = {}) { - this.maxBufferedDeltas = options.maxBufferedDeltas ?? DEFAULT_MAX_BUFFERED_DELTAS; + constructor(private readonly opts: CollectionMirrorOptions = {}) { + this.maxBufferedDeltas = this.opts.maxBufferedDeltas ?? DEFAULT_MAX_BUFFERED_DELTAS; makeObservable(this, { revision: observable, }); @@ -101,6 +106,7 @@ export class CollectionMirror { this.entriesByKey = new Map(snapshot.entries); this.version.accept(snapshot.generation, snapshot.sequence); this.revision += 1; + this.opts.onApplied?.({ kind: 'snapshot', snapshot }); }); this.version.flushAfterApply(generationChanged); if (this.droppedBufferedDeltaGenerations.delete(snapshot.generation)) { @@ -129,6 +135,7 @@ export class CollectionMirror { } this.version.accept(update.generation, update.sequence); this.revision += 1; + this.opts.onApplied?.({ kind: 'delta', update }); }); this.version.flushAfterApply(generationChanged); } diff --git a/apps/emdash-desktop/src/renderer/lib/stores/live/index.ts b/apps/emdash-desktop/src/renderer/lib/stores/live/index.ts index 4673ea7434..1bfe120b58 100644 --- a/apps/emdash-desktop/src/renderer/lib/stores/live/index.ts +++ b/apps/emdash-desktop/src/renderer/lib/stores/live/index.ts @@ -6,6 +6,10 @@ export { type MirrorBindingStatus, } from './bind-mirror'; export { coalesce } from './coalesce'; -export { CollectionMirror, type CollectionMirrorOptions } from './collection-mirror'; +export { + CollectionMirror, + type CollectionMirrorChange, + type CollectionMirrorOptions, +} from './collection-mirror'; export { ModelMirror } from './model-mirror'; export { OptimisticModel } from './optimistic-model'; diff --git a/apps/emdash-desktop/src/renderer/lib/stores/live/mirror-version.ts b/apps/emdash-desktop/src/renderer/lib/stores/live/mirror-version.ts index b795da9f8b..8f23621c31 100644 --- a/apps/emdash-desktop/src/renderer/lib/stores/live/mirror-version.ts +++ b/apps/emdash-desktop/src/renderer/lib/stores/live/mirror-version.ts @@ -13,8 +13,8 @@ export class MirrorVersion { private waiters: Waiter[] = []; constructor( - private readonly waitLabel: string, - private readonly disposedLabel: string + private readonly waitLabel: 'live collection' | 'live model', + private readonly disposedLabel: 'CollectionMirror' | 'ModelMirror' ) { makeObservable(this, { sequence: observable, From 1c04239edb3aacae69ee10108d529f60d0c62dc6 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:13:06 -0700 Subject: [PATCH 03/37] feat(file-tree): wire desktop runtime and rpc --- .../src/main/core/fs/controller.ts | 16 + .../src/main/core/fs/file-tree/controller.ts | 41 ++ .../src/main/core/fs/impl/local-fs.ts | 6 + .../src/main/core/fs/impl/ssh-fs.ts | 8 +- apps/emdash-desktop/src/main/core/fs/types.ts | 14 +- .../main/core/runtime/legacy/ssh-file-tree.ts | 518 ++++++++++++++++++ .../src/main/core/runtime/runtime-manager.ts | 10 + .../src/main/core/runtime/types.ts | 2 + .../main/core/workspaces/workspace-factory.ts | 58 +- .../workspaces/workspace-registry.test.ts | 10 +- .../core/workspaces/workspace-registry.ts | 10 +- .../src/main/core/workspaces/workspace.ts | 2 + apps/emdash-desktop/src/main/rpc.ts | 2 + .../src/shared/core/fs/file-tree-errors.ts | 18 + .../src/shared/core/fs/file-tree.ts | 11 + .../src/shared/core/fs/fsEvents.ts | 9 + 16 files changed, 721 insertions(+), 14 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts create mode 100644 apps/emdash-desktop/src/shared/core/fs/file-tree-errors.ts create mode 100644 apps/emdash-desktop/src/shared/core/fs/file-tree.ts diff --git a/apps/emdash-desktop/src/main/core/fs/controller.ts b/apps/emdash-desktop/src/main/core/fs/controller.ts index 1d888a47fa..6192fc7828 100644 --- a/apps/emdash-desktop/src/main/core/fs/controller.ts +++ b/apps/emdash-desktop/src/main/core/fs/controller.ts @@ -14,6 +14,15 @@ import { type SearchOptions, } from './types'; +/** + * Legacy workspace filesystem RPC surface. + * + * Keep this for non-tree file operations: editor read/write/image/copy, Monaco + * invalidation, lifecycle script config watches, project settings, and related + * workspace services. The file tree no longer uses this surface; new tree code + * should go through `workspace.fileTree` / `@emdash/core/file-tree`. + */ + // One watcher per (projectId, workspaceId) pair, shared across all consumers via labels. // Local: single recursive @parcel/watcher subscription — update() is a no-op. // SSH: poll-based — update() receives the union of all labels' paths to poll. @@ -49,6 +58,11 @@ function joinWorkspacePath(rootPath: string, filePath: string): string { } export const filesController = createRPCController({ + /** + * @deprecated Not used by the editor file tree. Prefer `workspace.fileTree` + * for tree data, and add narrowly-scoped file operation RPCs for other use + * cases instead of growing this generic listing route. + */ listFiles: async ( projectId: string, workspaceId: string, @@ -296,6 +310,8 @@ export const filesController = createRPCController({ paths: string[], label = 'default' ) => { + // Legacy raw filesystem watch channel. Do not use this for the file tree; + // tree subscriptions are served by `workspace.fileTree` and `fileTreeUpdateChannel`. const env = resolveWorkspace(projectId, workspaceId); if (!env) { return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); diff --git a/apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts b/apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts new file mode 100644 index 0000000000..564fe59f63 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts @@ -0,0 +1,41 @@ +import type { NodeId } from '@emdash/core/file-tree'; +import { err, ok } from '@emdash/shared'; +import { resolveWorkspace } from '@main/core/projects/utils'; +import type { FileTreeMutationResult, FileTreeSnapshotResult } from '@shared/core/fs/file-tree'; +import { createRPCController } from '@shared/lib/ipc/rpc'; + +export const fileTreeController = createRPCController({ + getSnapshot: async (projectId: string, workspaceId: string): Promise => { + const workspace = resolveWorkspace(projectId, workspaceId); + if (!workspace) return err({ type: 'not_found' }); + return await workspace.fileTree.getSnapshot(); + }, + + expandDir: async ( + projectId: string, + workspaceId: string, + dirId: NodeId | null + ): Promise => { + const workspace = resolveWorkspace(projectId, workspaceId); + if (!workspace) return err({ type: 'not_found' }); + const result = await workspace.fileTree.expandDir(dirId); + return result.success ? ok({ sequences: result.data }) : err(result.error); + }, + + revealPath: async ( + projectId: string, + workspaceId: string, + filePath: string + ): Promise => { + const workspace = resolveWorkspace(projectId, workspaceId); + if (!workspace) return err({ type: 'not_found' }); + const result = await workspace.fileTree.revealPath(filePath); + return result.success ? ok({ sequences: result.data }) : err(result.error); + }, + + refresh: async (projectId: string, workspaceId: string): Promise => { + const workspace = resolveWorkspace(projectId, workspaceId); + if (!workspace) return err({ type: 'not_found' }); + return await workspace.fileTree.refresh(); + }, +}); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts index 10102b1dce..1e1200f407 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts @@ -140,6 +140,12 @@ const IMAGE_MIME_TYPES: Record = { '.ico': 'image/x-icon', }; +/** + * Legacy local `FileSystemProvider` implementation. + * + * Keep for non-tree workspace file operations. The editor file tree uses + * `@emdash/core/file-tree` directly and should not add new behavior here. + */ export class LocalFileSystem implements FileSystemProvider { private listAbort: AbortController | null = null; diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts index 3a3f42529a..a5544a46c0 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts @@ -59,8 +59,12 @@ function fileEntryMetadataChanged(prev: FileEntry, next: FileEntry): boolean { } /** - * SshFileSystem implements IFileSystem using SFTP over SSH. - * Provides path traversal protection and proper error handling. + * Legacy SSH `FileSystemProvider` implementation using SFTP/SSH exec. + * + * This remains active for non-tree file operations and transitional SSH + * adapters. The editor file tree uses `LegacySshFileTreeRuntime` only as a + * temporary bridge until the `@emdash/core` file-tree runtime can run where the + * remote workspace lives. */ export class SshFileSystem implements FileSystemProvider { private cachedSftp: SFTPWrapper | undefined; diff --git a/apps/emdash-desktop/src/main/core/fs/types.ts b/apps/emdash-desktop/src/main/core/fs/types.ts index 2870a19bf1..1193d9b512 100644 --- a/apps/emdash-desktop/src/main/core/fs/types.ts +++ b/apps/emdash-desktop/src/main/core/fs/types.ts @@ -137,8 +137,18 @@ export interface SearchMatch { } /** - * Filesystem interface abstraction - * Implementations: LocalFileSystem (local disk), RemoteFileSystem (SFTP over SSH) + * Legacy workspace filesystem abstraction. + * + * This provider remains active for non-tree workspace file operations + * (read/write/image/copy/search/config watches/project setup). Do not extend it + * for the editor file tree; file-tree reads, scopes, and deltas live in + * `@emdash/core/file-tree` and are exposed through `workspace.fileTree`. + * + * Longer term this desktop-side provider should disappear behind filesystem APIs + * owned by `@emdash/core`. Those APIs should run where the workspace lives and + * call `node:fs` directly: desktop imports core directly for local projects, + * while the workspace server imports the same core API and exposes it to + * desktop for remote projects. */ export interface FileSystemProvider { /** diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts new file mode 100644 index 0000000000..51c8f2bd0d --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts @@ -0,0 +1,518 @@ +import path from 'node:path'; +import type { + FileNode, + FileTreeError, + FileTreeLease, + FileTreeSequences, + FileTreeSnapshot, + FileTreeUpdate, + IFileTree, + IFileTreeRuntime, + NodeId, + SubscribedSnapshot, +} from '@emdash/core/file-tree'; +import { LiveCollection, ResourceMap, type KeyedOp } from '@emdash/core/lib'; +import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import { FileSystemError, FileSystemErrorCodes } from '@main/core/fs/types'; +import type { FileEntry } from '@main/core/fs/types'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { log } from '@main/lib/logger'; + +const SSH_FILE_TREE_POLL_MS = 4_000; +const SSH_FILE_TREE_EXCLUDED_NAMES = new Set([ + '.git', + 'dist', + 'build', + '.next', + 'out', + '.turbo', + 'coverage', + '.nyc_output', + '.cache', + 'tmp', + 'temp', + '.DS_Store', + 'Thumbs.db', + '.vscode-test', + '.idea', + '__pycache__', + '.pytest_cache', + 'venv', + '.venv', + 'target', + '.terraform', + '.serverless', + '.checkouts', + 'checkouts', + '.conductor', + '.cursor', + '.claude', + '.devin', + '.amp', + '.codex', + '.aider', + '.continue', + '.cody', + '.windsurf', + 'worktrees', + '.worktrees', + '.emdash', + 'node_modules', +]); + +type LegacyListedEntry = { + path: string; + name: string; + type: 'file' | 'directory'; +}; + +/** + * Legacy SSH compatibility layer for the core file-tree contract. + * + * This adapter deliberately does not reuse `@emdash/core`'s native `FileTree`. + * Core owns the local, node:fs-backed implementation; this transitional layer + * translates SSH/SFTP polling into the same public snapshot/update interface + * until the core runtime can run where the remote workspace lives. + */ +export class LegacySshFileTreeRuntime implements IFileTreeRuntime { + private readonly trees: ResourceMap; + private disposeRequested = false; + + constructor(private readonly proxy: SshClientProxy) { + this.trees = new ResourceMap({ + teardown: (_rootPath, tree) => tree.dispose(), + onError: (context, error) => + log.warn('LegacySshFileTreeRuntime: teardown failed', { + context, + error: String(error), + }), + }); + } + + async open(rootPath: string): Promise> { + if (this.disposeRequested) throw new Error('LegacySshFileTreeRuntime disposed'); + const normalizedRoot = normalizeRemoteRootPath(rootPath); + const lease = await this.trees.acquire(normalizedRoot, async () => { + return new LegacySshFileTree(this.proxy, normalizedRoot, (context, error) => + log.warn('LegacySshFileTreeRuntime: background error', { + context, + error: String(error), + }) + ); + }); + + try { + const ready = await lease.value.ready(); + if (!ready.success) { + lease.release(); + return err(ready.error); + } + return ok(lease); + } catch (error) { + lease.release(); + throw error; + } + } + + async dispose(): Promise { + this.disposeRequested = true; + this.trees.dispose(); + } +} + +class LegacySshFileTree implements IFileTree { + readonly rootPath: string; + private readonly collection = new LiveCollection({ + scopeOf: (node) => node.parentId, + }); + private readonly fs: SshFileSystem; + private readonly pathToId = new Map(); + private readonly nodes = new Map(); + private readonly childrenByParent = new Map>(); + private readonly scopeLoads = new Map< + NodeId | null, + Promise> + >(); + private readonly pollTimer: ReturnType; + private nextId = 1; + private disposed = false; + private readyPromise: Promise> | null = null; + + constructor( + proxy: SshClientProxy, + rootPath: string, + private readonly onError: (context: string, error: unknown) => void + ) { + this.rootPath = rootPath; + this.fs = new SshFileSystem(proxy, rootPath); + this.pollTimer = setInterval(() => { + if (this.collection.subscriberCount === 0) return; + void this.refreshLoadedScopes().then( + (result) => { + if (!result.success) this.onError(`ssh file-tree refresh ${this.rootPath}`, result.error); + }, + (error) => this.onError(`ssh file-tree refresh ${this.rootPath}`, error) + ); + }, SSH_FILE_TREE_POLL_MS); + } + + async ready(): Promise> { + if (this.readyPromise) return this.readyPromise; + + const readyPromise = (async (): Promise> => { + const loaded = await this.loadDirectoryScope(null); + if (!loaded.success) return err(loaded.error); + return ok(); + })().catch((error): Result => { + if (this.readyPromise === readyPromise) { + this.readyPromise = null; + } + throw error; + }); + this.readyPromise = readyPromise; + return readyPromise; + } + + async getSnapshot(): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + return ok(this.collection.getCached()); + } + + subscribe(cb: (update: FileTreeUpdate) => void): Unsubscribe { + return this.collection.subscribe(cb); + } + + async subscribeWithSnapshot( + cb: (update: FileTreeUpdate) => void + ): Promise, FileTreeError>> { + const unsubscribe = this.subscribe(cb); + const snapshot = await this.getSnapshot(); + if (!snapshot.success) { + unsubscribe(); + return err(snapshot.error); + } + return ok({ snapshot: snapshot.data, unsubscribe }); + } + + async expandDir(dirId: NodeId | null): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + return this.loadDirectoryScope(dirId); + } + + async revealPath(pathToReveal: string): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + const normalized = normalizeRemoteRelPath(pathToReveal); + if (!normalized.success) return normalized; + + const parts = normalized.data.split('/').filter(Boolean); + let sequences: FileTreeSequences = {}; + for (let index = 0; index < parts.length; index += 1) { + const relPath = parts.slice(0, index + 1).join('/'); + const node = this.getByPath(relPath); + if (!node) return err({ type: 'not-found', path: relPath }); + const shouldExpand = index < parts.length - 1 || node.type === 'directory'; + if (!shouldExpand) continue; + if (node.type !== 'directory') { + return err({ type: 'not-directory', id: node.id, path: node.path }); + } + const expanded = await this.loadDirectoryScope(node.id); + if (!expanded.success) return expanded; + sequences = mergeSequences(sequences, expanded.data); + } + return ok(sequences); + } + + async refresh(): Promise> { + const refreshed = await this.refreshLoadedScopes(); + if (!refreshed.success) return err(refreshed.error); + return ok(this.collection.getCached()); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + clearInterval(this.pollTimer); + this.collection.dispose(); + } + + private async refreshLoadedScopes(): Promise> { + const scopes = this.collection.loadedScopes(); + let sequences: FileTreeSequences = {}; + for (const scope of scopes) { + if (scope !== null && !this.nodes.has(scope)) continue; + const refreshed = await this.loadDirectoryScope(scope); + if (!refreshed.success) { + const recovered = this.recoverMissingLoadedScope(scope, refreshed.error); + if (!recovered.success) return err(recovered.error); + sequences = mergeSequences(sequences, recovered.data); + continue; + } + sequences = mergeSequences(sequences, refreshed.data); + } + return ok(sequences); + } + + private async loadDirectoryScope( + scope: NodeId | null + ): Promise> { + const existing = this.scopeLoads.get(scope); + if (existing) return existing; + + const loading = this.loadDirectoryScopeInternal(scope); + this.scopeLoads.set(scope, loading); + void loading.finally(() => { + if (this.scopeLoads.get(scope) === loading) this.scopeLoads.delete(scope); + }); + return loading; + } + + private async loadDirectoryScopeInternal( + scope: NodeId | null + ): Promise> { + const dirNode = scope === null ? null : this.nodes.get(scope); + if (scope !== null && !dirNode) return err({ type: 'not-found', id: scope }); + if (dirNode && dirNode.type !== 'directory') { + return err({ type: 'not-directory', id: dirNode.id, path: dirNode.path }); + } + + const dirPath = dirNode?.path ?? ''; + const listed = await this.listChildren(dirPath); + if (!listed.success) return listed; + + const listedPaths = new Set(listed.data.map((entry) => entry.path)); + let sequence = this.removeMissingChildren(scope, listedPaths); + const nodes = listed.data.map((entry) => + this.upsertNode(entry, scope, this.getByPath(entry.path)?.childrenLoaded) + ); + const loaded = await this.collection.loadScope(scope, async () => + ok(nodes.map((node) => [node.id, node] as const)) + ); + if (!loaded.success) return loaded; + sequence = Math.max(sequence, loaded.data); + + if (dirNode && !dirNode.childrenLoaded) { + const updated = { ...dirNode, childrenLoaded: true }; + this.setNode(updated); + sequence = Math.max(sequence, this.collection.put(updated.id, updated)); + } + + return ok(sequence === 0 ? {} : { tree: sequence }); + } + + private async listChildren(dirPath: string): Promise> { + const normalized = normalizeRemoteRelPath(dirPath, { allowEmpty: true }); + if (!normalized.success) return normalized; + + try { + const result = await this.fs.list(normalized.data, { includeHidden: true }); + const entries: LegacyListedEntry[] = []; + for (const entry of result.entries) { + const relPath = entry.path.replace(/\\/g, '/'); + if (isLegacySshExcludedPath(relPath)) continue; + if (entry.type !== 'dir' && entry.type !== 'file') continue; + entries.push(toListedEntry(entry)); + } + entries.sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return ok(entries); + } catch (error) { + return err(toFileTreeError(error, normalized.data)); + } + } + + private removeMissingChildren(parentId: NodeId | null, listedPaths: Set): number { + const missing = this.childrenOf(parentId) + .filter((node) => !listedPaths.has(node.path)) + .map((node) => node.id); + return this.removeSubtrees(missing); + } + + private removeSubtrees(rootIds: NodeId[]): number { + const ops: Array> = []; + const removedScopes: NodeId[] = []; + for (const rootId of rootIds) { + const removed = this.removeSubtree(rootId); + for (const node of removed) { + ops.push({ op: 'del', key: node.id }); + if (node.type === 'directory') removedScopes.push(node.id); + } + } + + let sequence = this.collection.apply(ops); + for (const scope of removedScopes) + sequence = Math.max(sequence, this.collection.unloadScope(scope)); + return sequence; + } + + private recoverMissingLoadedScope( + scope: NodeId | null, + error: FileTreeError + ): Result { + if (scope === null || (error.type !== 'not-found' && error.type !== 'not-directory')) { + return err(error); + } + + const sequence = this.removeSubtrees([scope]); + return ok(sequence === 0 ? {} : { tree: sequence }); + } + + private getByPath(path: string): FileNode | undefined { + const id = this.pathToId.get(path); + return id === undefined ? undefined : this.nodes.get(id); + } + + private upsertNode( + entry: LegacyListedEntry, + parentId: NodeId | null, + childrenLoaded?: boolean + ): FileNode { + const existingId = this.pathToId.get(entry.path); + const id = existingId ?? this.nextId++; + const previous = this.nodes.get(id); + const node: FileNode = { + id, + path: entry.path, + name: entry.name, + parentId, + type: entry.type, + childrenLoaded: + entry.type === 'directory' ? (childrenLoaded ?? previous?.childrenLoaded ?? false) : false, + }; + this.setNode(node); + return node; + } + + private setNode(node: FileNode): void { + const previous = this.nodes.get(node.id); + if (previous) { + this.pathToId.delete(previous.path); + this.removeChild(previous.parentId, node.id); + } + this.pathToId.set(node.path, node.id); + this.addChild(node.parentId, node.id); + this.nodes.set(node.id, node); + } + + private removeSubtree(rootId: NodeId): FileNode[] { + const removed: FileNode[] = []; + const visit = (id: NodeId) => { + const node = this.nodes.get(id); + if (!node) return; + for (const child of this.childrenOf(id)) visit(child.id); + this.removeNode(id); + removed.push(node); + }; + visit(rootId); + return removed; + } + + private removeNode(id: NodeId): void { + const node = this.nodes.get(id); + if (!node) return; + this.pathToId.delete(node.path); + this.removeChild(node.parentId, id); + this.nodes.delete(id); + } + + private childrenOf(parentId: NodeId | null): FileNode[] { + const ids = this.childrenByParent.get(parentId); + if (!ids) return []; + const children: FileNode[] = []; + for (const id of ids) { + const node = this.nodes.get(id); + if (node) children.push(node); + } + return children; + } + + private addChild(parentId: NodeId | null, id: NodeId): void { + let children = this.childrenByParent.get(parentId); + if (!children) { + children = new Set(); + this.childrenByParent.set(parentId, children); + } + children.add(id); + } + + private removeChild(parentId: NodeId | null, id: NodeId): void { + const children = this.childrenByParent.get(parentId); + if (!children) return; + children.delete(id); + if (children.size === 0) this.childrenByParent.delete(parentId); + } +} + +function toListedEntry(entry: FileEntry): LegacyListedEntry { + const relPath = entry.path.replace(/\\/g, '/'); + return { + path: relPath, + name: path.posix.basename(relPath), + type: entry.type === 'dir' ? 'directory' : 'file', + }; +} + +function toFileTreeError(error: unknown, relPath: string): FileTreeError { + if (error instanceof FileSystemError) { + if (error.code === FileSystemErrorCodes.NOT_FOUND) return { type: 'not-found', path: relPath }; + if (error.code === FileSystemErrorCodes.NOT_DIRECTORY) { + return { type: 'not-directory', path: relPath }; + } + if ( + error.code === FileSystemErrorCodes.INVALID_PATH || + error.code === FileSystemErrorCodes.PATH_ESCAPE + ) { + return { type: 'invalid-path', path: relPath, message: error.message }; + } + return { type: 'fs-error', path: relPath, message: error.message }; + } + return { type: 'fs-error', path: relPath, message: String(error) }; +} + +function normalizeRemoteRootPath(rootPath: string): string { + const normalized = path.posix.normalize(rootPath.replace(/\\/g, '/')); + return path.posix.isAbsolute(normalized) ? normalized : path.posix.resolve('/', normalized); +} + +function normalizeRemoteRelPath( + input: string, + options: { allowEmpty?: boolean } = {} +): Result { + if (input.includes('\0')) { + return err({ type: 'invalid-path', path: input, message: 'Path contains a null byte' }); + } + if (path.posix.isAbsolute(input) || path.win32.isAbsolute(input)) { + return err({ type: 'invalid-path', path: input, message: 'Absolute paths are not allowed' }); + } + + const parts = input + .replace(/\\/g, '/') + .split('/') + .filter((part) => part.length > 0 && part !== '.'); + if (parts.includes('..')) { + return err({ + type: 'invalid-path', + path: input, + message: 'Parent path segments are not allowed', + }); + } + + const normalized = parts.join('/'); + if (!normalized && !options.allowEmpty) { + return err({ type: 'invalid-path', path: input, message: 'Path must not be empty' }); + } + return ok(normalized); +} + +function isLegacySshExcludedPath(relPath: string): boolean { + return relPath.split('/').some((segment) => SSH_FILE_TREE_EXCLUDED_NAMES.has(segment)); +} + +function mergeSequences(left: FileTreeSequences, right: FileTreeSequences): FileTreeSequences { + return { tree: Math.max(left.tree ?? 0, right.tree ?? 0) || undefined }; +} diff --git a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts index 19a214f2af..99a540a58e 100644 --- a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts +++ b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts @@ -1,14 +1,20 @@ +import { FileTreeRuntime } from '@emdash/core/file-tree'; import { GitRuntime } from '@emdash/core/git'; import { ResourceMap } from '@emdash/core/lib'; import type { Lease } from '@emdash/shared'; import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import { log } from '@main/lib/logger'; import { ConstantHealthSource } from './health'; +import { LegacySshFileTreeRuntime } from './legacy/ssh-file-tree'; import { LegacySshGitRuntime } from './legacy/ssh-git'; import { machineKey, type MachineRef, type MachineRuntime, type RuntimeManager } from './types'; class LocalMachineRuntime implements MachineRuntime { readonly machine: MachineRef = { kind: 'local' }; + readonly fileTree = new FileTreeRuntime({ + onError: (context, error) => + log.warn('Local FileTreeRuntime background error', { context, error: String(error) }), + }); readonly git = new GitRuntime({ onError: (context, error) => log.warn('Local GitRuntime background error', { context, error: String(error) }), @@ -16,12 +22,14 @@ class LocalMachineRuntime implements MachineRuntime { readonly health = new ConstantHealthSource(); dispose(): void { + void this.fileTree.dispose(); void this.git.dispose(); } } class SshMachineRuntime implements MachineRuntime { readonly machine: MachineRef; + readonly fileTree: LegacySshFileTreeRuntime; readonly git: LegacySshGitRuntime; readonly health = new ConstantHealthSource(); @@ -30,10 +38,12 @@ class SshMachineRuntime implements MachineRuntime { proxy: Awaited> ) { this.machine = { kind: 'ssh', connectionId }; + this.fileTree = new LegacySshFileTreeRuntime(proxy); this.git = new LegacySshGitRuntime(proxy); } dispose(): void { + void this.fileTree.dispose(); this.git.dispose(); } } diff --git a/apps/emdash-desktop/src/main/core/runtime/types.ts b/apps/emdash-desktop/src/main/core/runtime/types.ts index 9fc7ddc92c..064c6875c5 100644 --- a/apps/emdash-desktop/src/main/core/runtime/types.ts +++ b/apps/emdash-desktop/src/main/core/runtime/types.ts @@ -1,3 +1,4 @@ +import type { IFileTreeRuntime } from '@emdash/core/file-tree'; import type { IGitRuntime } from '@emdash/core/git'; import type { IDisposable, Lease, Unsubscribe } from '@emdash/shared'; @@ -15,6 +16,7 @@ export interface HealthSource { export interface MachineRuntime extends IDisposable { readonly machine: MachineRef; + readonly fileTree: IFileTreeRuntime; readonly git: IGitRuntime; readonly health: HealthSource; } diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts index 62229c07ba..e7b4231a66 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts @@ -24,6 +24,7 @@ import { type WorkspaceFactoryResult } from '@main/core/workspaces/workspace-reg import { handleGitWorktreeUpdate } from '@main/core/workspaces/workspace-worktree-update'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; +import { fileTreeUpdateChannel } from '@shared/core/fs/fsEvents'; import { gitWorktreeUpdateChannel } from '@shared/core/git/events'; import type { Task } from '@shared/core/tasks/tasks'; import { getEffectiveTaskSettings } from '../projects/settings/effective-task-settings'; @@ -129,11 +130,8 @@ export function createWorkspaceFactory( terminals: workspaceTerminals, }); - const runtimeLease = await context.workspaceRuntime.manager.acquire( - context.workspaceRuntime.machine - ); - const worktreeLease = await runtimeLease.value.git.openWorktree(workDir); - const gitWorktree = worktreeLease.value; + const runtime = await acquireWorkspaceRuntime(context.workspaceRuntime, workDir); + const { gitWorktree, fileTree } = runtime; const gitRepository = context.gitRepository ?? new GitRepositoryService(gitWorktree.repository, context.settings); @@ -143,11 +141,13 @@ export function createWorkspaceFactory( context.gitRepositoryFetchService ?? new GitRepositoryFetchService(gitRepository, () => gitRepository.getBaseRemote()); let unsubscribeGitUpdates: (() => void) | undefined; + let unsubscribeFileTreeUpdates: (() => void) | undefined; const workspace: Workspace = { id: workspaceId, path: workDir, fs: workspaceFs, + fileTree, gitWorktree, settings: context.settings, lifecycleService, @@ -156,8 +156,9 @@ export function createWorkspaceFactory( dispose: () => { unsubscribeGitUpdates?.(); unsubscribeGitUpdates = undefined; - worktreeLease.release(); - runtimeLease.release(); + unsubscribeFileTreeUpdates?.(); + unsubscribeFileTreeUpdates = undefined; + runtime.release(); }, }; @@ -176,6 +177,13 @@ export function createWorkspaceFactory( }); }) ); + unsubscribeFileTreeUpdates = ws.fileTree.subscribe((update) => { + events.emit(fileTreeUpdateChannel, { + projectId: context.projectId, + workspaceId, + update, + }); + }); if (ownsFetchService) { gitRepositoryFetchService.start(); @@ -271,6 +279,42 @@ export function createWorkspaceFactory( }; } +async function acquireWorkspaceRuntime( + workspaceRuntime: WorkspaceFactoryContext['workspaceRuntime'], + workDir: string +) { + const runtimeLease = await workspaceRuntime.manager.acquire(workspaceRuntime.machine); + try { + const worktreeLease = await runtimeLease.value.git.openWorktree(workDir); + try { + const openedFileTree = await runtimeLease.value.fileTree.open(workDir); + if (!openedFileTree.success) { + throw new Error(`Failed to open file tree: ${JSON.stringify(openedFileTree.error)}`); + } + + const fileTreeLease = openedFileTree.data; + let released = false; + return { + gitWorktree: worktreeLease.value, + fileTree: fileTreeLease.value, + release: () => { + if (released) return; + released = true; + fileTreeLease.release(); + worktreeLease.release(); + runtimeLease.release(); + }, + }; + } catch (error) { + worktreeLease.release(); + throw error; + } + } catch (error) { + runtimeLease.release(); + throw error; + } +} + type TaskProviderOpts = { projectId: string; taskId: string; diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts index a3a70e17a3..42e5ab1538 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts @@ -5,9 +5,11 @@ import { WorkspaceRegistry } from './workspace-registry'; function makeWorkspace(id: string): { workspace: Workspace; dispose: ReturnType; + fileTreeDispose: ReturnType; gitDispose: ReturnType; } { const dispose = vi.fn(async () => {}); + const fileTreeDispose = vi.fn(); const gitDispose = vi.fn(); return { @@ -15,6 +17,7 @@ function makeWorkspace(id: string): { id, path: `/tmp/${id}`, fs: {} as Workspace['fs'], + fileTree: { dispose: fileTreeDispose } as unknown as Workspace['fileTree'], gitWorktree: { dispose: gitDispose } as unknown as Workspace['gitWorktree'], settings: {} as Workspace['settings'], lifecycleService: { @@ -24,6 +27,7 @@ function makeWorkspace(id: string): { gitRepositoryFetchService: {} as Workspace['gitRepositoryFetchService'], }, dispose, + fileTreeDispose, gitDispose, }; } @@ -68,7 +72,7 @@ describe('WorkspaceRegistry', () => { it('disposes workspace resources when ref count reaches zero', async () => { const registry = new WorkspaceRegistry(); - const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); + const { workspace, dispose, fileTreeDispose, gitDispose } = makeWorkspace('branch:main'); const factory = vi.fn(async () => ({ workspace })); await registry.acquire('branch:main', 'test-project', factory); @@ -76,10 +80,12 @@ describe('WorkspaceRegistry', () => { await registry.release('branch:main'); expect(dispose).not.toHaveBeenCalled(); + expect(fileTreeDispose).not.toHaveBeenCalled(); expect(gitDispose).not.toHaveBeenCalled(); expect(registry.refCount('branch:main')).toBe(1); await registry.release('branch:main'); + expect(fileTreeDispose).toHaveBeenCalledTimes(1); expect(gitDispose).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); expect(registry.get('branch:main')).toBeUndefined(); @@ -101,8 +107,10 @@ describe('WorkspaceRegistry', () => { await registry.releaseAll(); + expect(first.fileTreeDispose).toHaveBeenCalledTimes(1); expect(first.gitDispose).toHaveBeenCalledTimes(1); expect(first.dispose).toHaveBeenCalledTimes(1); + expect(second.fileTreeDispose).toHaveBeenCalledTimes(1); expect(second.gitDispose).toHaveBeenCalledTimes(1); expect(second.dispose).toHaveBeenCalledTimes(1); expect(registry.refCount('branch:main')).toBe(0); diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts index 72feb519f5..7646a23186 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts @@ -84,7 +84,10 @@ export class WorkspaceRegistry { await entry.onDestroy?.(entry.workspace); } await entry.workspace.dispose?.(); - if (!entry.workspace.dispose) await entry.workspace.gitWorktree.dispose(); + if (!entry.workspace.dispose) { + entry.workspace.fileTree.dispose(); + await entry.workspace.gitWorktree.dispose(); + } await entry.workspace.lifecycleService.dispose(); if (mode === 'detach') { await entry.onDetach?.(entry.workspace); @@ -121,7 +124,10 @@ export class WorkspaceRegistry { await entry.onDestroy?.(entry.workspace); } await entry.workspace.dispose?.(); - if (!entry.workspace.dispose) await entry.workspace.gitWorktree.dispose(); + if (!entry.workspace.dispose) { + entry.workspace.fileTree.dispose(); + await entry.workspace.gitWorktree.dispose(); + } await entry.workspace.lifecycleService.dispose(); if (mode === 'detach') { await entry.onDetach?.(entry.workspace); diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace.ts index 2b8b2e3d4c..9e8d148a38 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace.ts @@ -1,3 +1,4 @@ +import type { IFileTree } from '@emdash/core/file-tree'; import type { IGitWorktree } from '@emdash/core/git'; import type { FileSystemProvider } from '@main/core/fs/types'; import type { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; @@ -9,6 +10,7 @@ export interface Workspace { readonly id: string; readonly path: string; readonly fs: FileSystemProvider; + readonly fileTree: IFileTree; readonly gitWorktree: IGitWorktree; readonly settings: ProjectSettingsProvider; readonly lifecycleService: LifecycleScriptService; diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index 5f5df792ff..4faccceb96 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -10,6 +10,7 @@ import { editorBufferController } from './core/editor/controller'; import { featurebaseController } from './core/featurebase/controller'; import { forgejoController } from './core/forgejo/controller'; import { filesController } from './core/fs/controller'; +import { fileTreeController } from './core/fs/file-tree/controller'; import { gitRepositoryController } from './core/git/repository/controller'; import { gitWorktreeController } from './core/git/worktree/controller'; import { githubController } from './core/github/controller'; @@ -85,6 +86,7 @@ export const rpcRouter = createRPCRouter({ workspace: createRPCNamespace({ gitWorktree: gitWorktreeController, fs: filesController, + fileTree: fileTreeController, editor: editorBufferController, }), }); diff --git a/apps/emdash-desktop/src/shared/core/fs/file-tree-errors.ts b/apps/emdash-desktop/src/shared/core/fs/file-tree-errors.ts new file mode 100644 index 0000000000..acb49c6895 --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/fs/file-tree-errors.ts @@ -0,0 +1,18 @@ +import type { FileTreeError } from '@emdash/core/file-tree'; + +export type FileTreeNotFoundError = { type: 'not_found' }; +export type FileTreeOperationError = FileTreeNotFoundError | FileTreeError; + +export function fileTreeOperationErrorMessage(error: FileTreeOperationError): string { + switch (error.type) { + case 'not_found': + return 'File tree not found'; + case 'fs-error': + case 'invalid-path': + return error.message; + case 'not-directory': + return `Path is not a directory: ${error.path ?? error.id ?? 'unknown'}`; + case 'not-found': + return `Path not found: ${error.path ?? error.id ?? 'unknown'}`; + } +} diff --git a/apps/emdash-desktop/src/shared/core/fs/file-tree.ts b/apps/emdash-desktop/src/shared/core/fs/file-tree.ts new file mode 100644 index 0000000000..00d864b1e1 --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/fs/file-tree.ts @@ -0,0 +1,11 @@ +import type { FileTreeSequences, FileTreeSnapshot } from '@emdash/core/file-tree'; +import type { Result } from '@emdash/shared'; +import type { FileTreeOperationError } from './file-tree-errors'; + +export type FileTreeSnapshotResult = Result; + +export type FileTreeMutationData = { + sequences: FileTreeSequences; +}; + +export type FileTreeMutationResult = Result; diff --git a/apps/emdash-desktop/src/shared/core/fs/fsEvents.ts b/apps/emdash-desktop/src/shared/core/fs/fsEvents.ts index 66e2831d96..f324615d1a 100644 --- a/apps/emdash-desktop/src/shared/core/fs/fsEvents.ts +++ b/apps/emdash-desktop/src/shared/core/fs/fsEvents.ts @@ -1,3 +1,4 @@ +import type { FileTreeUpdate } from '@emdash/core/file-tree'; import type { FileWatchEvent } from '@shared/core/fs/fs'; import { defineEvent } from '@shared/lib/ipc/events'; @@ -6,3 +7,11 @@ export const fsWatchEventChannel = defineEvent<{ workspaceId: string; events: FileWatchEvent[]; }>('fs:watch-event'); + +export type FileTreeUpdateEvent = { + projectId: string; + workspaceId: string; + update: FileTreeUpdate; +}; + +export const fileTreeUpdateChannel = defineEvent('fs:file-tree-update'); From 0b9b47e756c76a786e5202f8c32b834df69e2326 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:13:20 -0700 Subject: [PATCH 04/37] feat(file-tree): consume live collection in renderer --- .../components/changes-tree-utils.ts | 17 +- .../components/virtualized-changes-tree.tsx | 2 +- .../tasks/editor/editor-file-tree.tsx | 28 +- .../tasks/editor/stores/files-store-utils.ts | 146 ---- .../tasks/editor/stores/files-store.test.ts | 327 ++++---- .../tasks/editor/stores/files-store.ts | 710 ++++++++++-------- .../tree-utils.test.ts} | 54 +- .../features/tasks/file-tree/tree-utils.ts | 147 ++++ .../tasks/stores/workspace-view-model.test.ts | 7 +- apps/emdash-desktop/src/shared/core/fs/fs.ts | 12 - 10 files changed, 805 insertions(+), 645 deletions(-) delete mode 100644 apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store-utils.ts rename apps/emdash-desktop/src/renderer/features/tasks/{editor/stores/files-store-utils.test.ts => file-tree/tree-utils.test.ts} (82%) create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-tree-utils.ts b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-tree-utils.ts index 536a3fb3d6..637fb9483c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-tree-utils.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-tree-utils.ts @@ -1,18 +1,21 @@ import type { GitChange } from '@emdash/core/git'; -import { makeNode, sortFileNodes } from '@renderer/features/tasks/editor/stores/files-store-utils'; -import { type FileNode } from '@shared/core/fs/fs'; +import { + makeNode, + sortFileNodes, + type NestedFileNode, +} from '@renderer/features/tasks/file-tree/tree-utils'; export interface ChangesTree { - rootNodes: FileNode[]; + rootNodes: NestedFileNode[]; changeByPath: Map; directoryPaths: Set; } export function buildChangesTree(changes: GitChange[]): ChangesTree { - const nodesByPath = new Map(); + const nodesByPath = new Map(); const changeByPath = new Map(); const directoryPaths = new Set(); - const rootNodes: FileNode[] = []; + const rootNodes: NestedFileNode[] = []; for (const change of changes) { changeByPath.set(change.path, change); @@ -21,7 +24,7 @@ export function buildChangesTree(changes: GitChange[]): ChangesTree { if (parts.length === 0) continue; let prefix = ''; - let parentNode: FileNode | null = null; + let parentNode: NestedFileNode | null = null; for (let i = 0; i < parts.length; i++) { const segment = parts[i]!; prefix = prefix ? `${prefix}/${segment}` : segment; @@ -51,7 +54,7 @@ export function buildChangesTree(changes: GitChange[]): ChangesTree { }; } -function sortRecursively(nodes: FileNode[]): FileNode[] { +function sortRecursively(nodes: NestedFileNode[]): NestedFileNode[] { const sorted = sortFileNodes(nodes); for (const node of sorted) { if (node.children.length > 0) { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/virtualized-changes-tree.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/virtualized-changes-tree.tsx index 71f82683da..a027e33bd8 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/virtualized-changes-tree.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/virtualized-changes-tree.tsx @@ -6,7 +6,7 @@ import { buildVisibleRows, isChainExpanded, type TreeRow, -} from '@renderer/features/tasks/editor/stores/files-store-utils'; +} from '@renderer/features/tasks/file-tree/tree-utils'; import { FileIcon } from '@renderer/lib/editor/file-icon'; import { cn } from '@renderer/utils/utils'; import { ChangeStatusAffordance } from './changes-list-item'; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-file-tree.tsx b/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-file-tree.tsx index b00d1ce89e..d707b3802b 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-file-tree.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-file-tree.tsx @@ -2,14 +2,14 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { ChevronDown, ChevronRight, Copy, FileText, Folder, FolderOpen } from 'lucide-react'; import { runInAction } from 'mobx'; import { observer } from 'mobx-react-lite'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { CompactedPathLabel } from '@renderer/features/tasks/editor/compacted-path-label'; import type { FilesStore } from '@renderer/features/tasks/editor/stores/files-store'; import { buildVisibleRows, isChainExpanded, type TreeRow, -} from '@renderer/features/tasks/editor/stores/files-store-utils'; +} from '@renderer/features/tasks/file-tree/tree-utils'; import { useTaskViewContext, useWorkspace, @@ -90,6 +90,7 @@ async function importLocalFiles(args: { } ); if (!result.success) throw new Error(resultErrorMessage(result.error)); + files.confirmOptimisticNodes(inserted); } catch (error) { for (const p of inserted) files.removeNode(p); await files.loadDir(destDirPath, true); @@ -389,26 +390,9 @@ export const EditorFileTree = observer(function EditorFileTree() { const editorView = taskView.editorView; const [isDragOverRoot, setIsDragOverRoot] = useState(false); - const visibleRows = files ? buildVisibleRows(files.rootNodes, editorView.expandedPaths) : []; - - const prefetchKey = files - ? visibleRows - .filter( - (row) => - row.node.type === 'directory' && - !files.loadedPaths.has(row.node.path) && - !files.pendingPaths.has(row.node.path) - ) - .map((row) => row.node.path) - .join('\0') - : ''; - - useEffect(() => { - if (!files || !prefetchKey) return; - for (const path of prefetchKey.split('\0')) { - void files.loadDir(path); - } - }, [prefetchKey, files]); + const visibleRows = files + ? buildVisibleRows(files.rootNodes, editorView.expandedPaths, files.childrenById) + : []; const parentRef = useRef(null); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store-utils.ts b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store-utils.ts deleted file mode 100644 index dc4f21ece0..0000000000 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store-utils.ts +++ /dev/null @@ -1,146 +0,0 @@ -// --------------------------------------------------------------------------- -// Excluded directory/file names for the task editor file tree. -// --------------------------------------------------------------------------- - -import { type FileNode } from '@shared/core/fs/fs'; - -const EXCLUDED_NAMES = new Set([ - '.git', - 'dist', - 'build', - '.next', - 'out', - '.turbo', - 'coverage', - '.nyc_output', - '.cache', - 'tmp', - 'temp', - '.DS_Store', - 'Thumbs.db', - '.vscode-test', - '.idea', - '__pycache__', - '.pytest_cache', - 'venv', - '.venv', - 'target', - '.terraform', - '.serverless', - '.checkouts', - 'checkouts', - '.conductor', - '.cursor', - '.claude', - '.devin', - '.amp', - '.codex', - '.aider', - '.continue', - '.cody', - '.windsurf', - 'worktrees', - '.worktrees', - '.emdash', - 'node_modules', -]); - -export function isExcluded(path: string): boolean { - return path.split('/').some((seg) => EXCLUDED_NAMES.has(seg)); -} - -// --------------------------------------------------------------------------- -// Helpers for building FileNode from a raw entry path -// --------------------------------------------------------------------------- - -export function normalizeFileTreePath(path: string): string { - return path.replace(/\\/g, '/').split('/').filter(Boolean).join('/'); -} - -export function makeNode(relPath: string, type: 'file' | 'directory', mtime?: Date): FileNode { - const path = normalizeFileTreePath(relPath); - const parts = path.split('/').filter(Boolean); - const name = parts[parts.length - 1] ?? path; - const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : null; - const depth = parts.length - 1; - const extension = type === 'file' && name.includes('.') ? name.split('.').pop() : undefined; - - return { - path, - name, - parentPath, - depth, - type, - children: [], - isHidden: name.startsWith('.'), - extension, - mtime, - }; -} - -// --------------------------------------------------------------------------- -// Sibling sorting -// Directories come before files; within each group, alphabetical order. -// --------------------------------------------------------------------------- - -export function sortFileNodes(nodes: readonly FileNode[]): FileNode[] { - return [...nodes].sort((a, b) => { - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.localeCompare(b.name); - }); -} - -// --------------------------------------------------------------------------- -// Visible rows derivation -// --------------------------------------------------------------------------- - -export interface TreeRow { - node: FileNode; - chain: FileNode[]; - renderDepth: number; -} - -function extendChain(node: FileNode): FileNode[] { - const chain: FileNode[] = [node]; - const visited = new Set([node.path]); - let current = node; - while ( - current.type === 'directory' && - current.children.length === 1 && - current.children[0].type === 'directory' && - !visited.has(current.children[0].path) - ) { - current = current.children[0]; - visited.add(current.path); - chain.push(current); - } - return chain; -} - -export function isChainExpanded(chain: readonly FileNode[], expandedPaths: Set): boolean { - for (const segment of chain) { - if (expandedPaths.has(segment.path)) return true; - } - return false; -} - -export function buildVisibleRows( - rootNodes: readonly FileNode[], - expandedPaths: Set -): TreeRow[] { - const rows: TreeRow[] = []; - - function walk(nodes: readonly FileNode[], renderDepth: number) { - for (const node of nodes) { - const chain = node.type === 'directory' ? extendChain(node) : [node]; - const terminus = chain[chain.length - 1]; - rows.push({ node: terminus, chain, renderDepth }); - if (terminus.type === 'directory' && isChainExpanded(chain, expandedPaths)) { - walk(terminus.children, renderDepth + 1); - } - } - } - - walk(rootNodes, 0); - return rows; -} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.test.ts index 24c84ae99a..38871feb53 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.test.ts @@ -1,21 +1,22 @@ +import type { FileNode, FileTreeUpdate, NodeId } from '@emdash/core/file-tree'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { FileWatchEvent } from '@shared/core/fs/fs'; +import { fileTreeUpdateChannel } from '@shared/core/fs/fsEvents'; import { FilesStore } from './files-store'; const mocks = vi.hoisted(() => ({ - listFiles: vi.fn(), - watchSetPaths: vi.fn(), - watchStop: vi.fn(), + getSnapshot: vi.fn(), + expandDir: vi.fn(), + revealPath: vi.fn(), eventOn: vi.fn(), })); vi.mock('@renderer/lib/ipc', () => ({ rpc: { workspace: { - fs: { - listFiles: mocks.listFiles, - watchSetPaths: mocks.watchSetPaths, - watchStop: mocks.watchStop, + fileTree: { + getSnapshot: mocks.getSnapshot, + expandDir: mocks.expandDir, + revealPath: mocks.revealPath, }, }, }, @@ -24,226 +25,264 @@ vi.mock('@renderer/lib/ipc', () => ({ }, })); -type Entry = { - path: string; - type: 'file' | 'dir'; -}; +function node( + id: NodeId, + path: string, + type: 'file' | 'directory', + parentId: NodeId | null = null, + childrenLoaded = false +): FileNode { + const parts = path.split('/').filter(Boolean); + return { + id, + path, + name: parts[parts.length - 1] ?? path, + parentId, + type, + childrenLoaded, + }; +} -function okEntries(entries: Entry[]) { +function snapshot(entries: FileNode[], sequence = 1, generation = 1) { return { success: true as const, data: { - entries, - total: entries.length, + entries: entries.map((entry) => [entry.id, entry] as [NodeId, FileNode]), + generation, + sequence, }, }; } -function setupListFiles(entriesByDir: Record): void { - mocks.listFiles.mockImplementation( - async (_projectId: string, _workspaceId: string, dirPath: string) => { - const key = dirPath === '.' ? '' : dirPath; - return okEntries(entriesByDir[key] ?? []); - } - ); +function mutation(sequence: number) { + return { + success: true as const, + data: { + sequences: { tree: sequence }, + }, + }; } async function flushAsyncWork(): Promise { await Promise.resolve(); await Promise.resolve(); - await Promise.resolve(); } describe('FilesStore', () => { + let emit: ((payload: { workspaceId: string; update: FileTreeUpdate }) => void) | undefined; + beforeEach(() => { - vi.useFakeTimers(); - mocks.listFiles.mockReset(); - mocks.watchSetPaths.mockReset(); - mocks.watchStop.mockReset(); + emit = undefined; + mocks.getSnapshot.mockReset(); + mocks.expandDir.mockReset(); + mocks.revealPath.mockReset(); mocks.eventOn.mockReset(); - mocks.watchSetPaths.mockResolvedValue({ success: true, data: { supported: true } }); - mocks.watchStop.mockResolvedValue({ success: true, data: {} }); + mocks.eventOn.mockImplementation( + (channel: typeof fileTreeUpdateChannel, handler: typeof emit) => { + expect(channel).toBe(fileTreeUpdateChannel); + emit = handler; + return vi.fn(); + } + ); }); afterEach(() => { - vi.clearAllTimers(); vi.useRealTimers(); + vi.clearAllMocks(); }); - it('loads the root directory and warms discovered child directories in the background', async () => { - setupListFiles({ - '': [ - { path: 'src', type: 'dir' }, - { path: 'README.md', type: 'file' }, - ], - src: [{ path: 'src/index.ts', type: 'file' }], - }); + it('hydrates from the file-tree snapshot without eagerly expanding directories', async () => { + mocks.getSnapshot.mockResolvedValue( + snapshot([node(1, 'src', 'directory'), node(2, 'README.md', 'file')]) + ); const store = new FilesStore('project-1', 'workspace-1'); await store.tree.load(); - await flushAsyncWork(); - store.dispose(); - expect(mocks.listFiles).toHaveBeenCalledWith('project-1', 'workspace-1', '.', { - recursive: false, - includeHidden: true, - }); - expect(mocks.listFiles).toHaveBeenCalledWith('project-1', 'workspace-1', 'src', { - recursive: false, - includeHidden: true, - }); + expect(mocks.getSnapshot).toHaveBeenCalledWith('project-1', 'workspace-1'); + expect(mocks.expandDir).not.toHaveBeenCalled(); expect(store.loadedPaths.has('')).toBe(true); - expect(store.loadedPaths.has('src')).toBe(true); - expect(store.rootNodes.map((node) => node.path)).toEqual(['src', 'README.md']); + expect(store.loadedPaths.has('src')).toBe(false); + expect(store.rootNodes.map((entry) => entry.path)).toEqual(['src', 'README.md']); + store.dispose(); }); - it('loads a child directory on demand', async () => { - setupListFiles({ - '': [{ path: 'src', type: 'dir' }], - src: [{ path: 'src/index.ts', type: 'file' }], + it('loads a child directory on demand and indexes children by parent id', async () => { + mocks.getSnapshot.mockResolvedValue(snapshot([node(1, 'src', 'directory')])); + mocks.expandDir.mockImplementation(async () => { + emit?.({ + workspaceId: 'workspace-1', + update: { + kind: 'delta', + generation: 1, + sequence: 2, + ops: [ + { op: 'put', key: 1, value: node(1, 'src', 'directory', null, true) }, + { op: 'put', key: 2, value: node(2, 'src/index.ts', 'file', 1) }, + ], + }, + }); + return mutation(2); }); const store = new FilesStore('project-1', 'workspace-1'); await store.tree.load(); await store.loadDir('src'); - vi.runOnlyPendingTimers(); - expect(mocks.listFiles).toHaveBeenCalledWith('project-1', 'workspace-1', 'src', { - recursive: false, - includeHidden: true, - }); + expect(mocks.expandDir).toHaveBeenCalledWith('project-1', 'workspace-1', 1); expect(store.loadedPaths.has('src')).toBe(true); expect(store.nodes.has('src/index.ts')).toBe(true); - expect(store.nodes.get('src')?.children.map((node) => node.path)).toEqual(['src/index.ts']); + expect(store.childrenById.get(1)?.map((entry) => entry.path)).toEqual(['src/index.ts']); store.dispose(); }); - it('sorts loaded directory entries once after applying the batch', async () => { - setupListFiles({ - '': [ - { path: 'z-file.ts', type: 'file' }, - { path: 'components', type: 'dir' }, - { path: 'a-file.ts', type: 'file' }, - { path: 'alpha', type: 'dir' }, - ], - }); + it('sorts root nodes and loaded child buckets directories first', async () => { + mocks.getSnapshot.mockResolvedValue( + snapshot([ + node(1, 'z-file.ts', 'file'), + node(2, 'components', 'directory'), + node(3, 'a-file.ts', 'file'), + node(4, 'alpha', 'directory'), + node(5, 'components/z.ts', 'file', 2), + node(6, 'components/a', 'directory', 2), + ]) + ); const store = new FilesStore('project-1', 'workspace-1'); await store.tree.load(); - expect(store.rootNodes.map((node) => node.path)).toEqual([ + expect(store.rootNodes.map((entry) => entry.path)).toEqual([ 'alpha', 'components', 'a-file.ts', 'z-file.ts', ]); + expect(store.childrenById.get(2)?.map((entry) => entry.path)).toEqual([ + 'components/a', + 'components/z.ts', + ]); store.dispose(); }); - it('normalizes loaded child paths into the current folder children', async () => { - setupListFiles({ - '': [{ path: 'src', type: 'dir' }], - src: [ - { path: 'src\\components', type: 'dir' }, - { path: 'src\\index.ts', type: 'file' }, - ], - }); + it('updates only the affected children bucket for accepted deltas', async () => { + mocks.getSnapshot.mockResolvedValue( + snapshot([ + node(1, 'src', 'directory', null, true), + node(2, 'README.md', 'file'), + node(3, 'src/a.ts', 'file', 1), + ]) + ); const store = new FilesStore('project-1', 'workspace-1'); await store.tree.load(); - await store.loadDir('src'); - vi.runOnlyPendingTimers(); - expect(store.rootNodes.map((node) => node.path)).toEqual(['src']); - expect(store.nodes.get('src')?.children.map((node) => node.path)).toEqual([ - 'src/components', - 'src/index.ts', - ]); - expect(store.nodes.has('src\\components')).toBe(false); + const rootBefore = store.rootNodes; + const srcChildrenBefore = store.childrenById.get(1); + + emit?.({ + workspaceId: 'workspace-1', + update: { + kind: 'delta', + generation: 1, + sequence: 2, + ops: [{ op: 'put', key: 4, value: node(4, 'src/b.ts', 'file', 1) }], + }, + }); + await flushAsyncWork(); + + expect(store.rootNodes).toBe(rootBefore); + expect(store.childrenById.get(1)).not.toBe(srcChildrenBefore); + expect(store.childrenById.get(1)?.map((entry) => entry.path)).toEqual(['src/a.ts', 'src/b.ts']); store.dispose(); }); - it('removes stale descendants and loaded markers when a loaded directory becomes a file', async () => { - const entriesByDir: Record = { - '': [{ path: 'src', type: 'dir' }], - src: [{ path: 'src/components', type: 'dir' }], - 'src/components': [{ path: 'src/components/Button.tsx', type: 'file' }], - }; - setupListFiles(entriesByDir); + it('loads and expands ancestor directories when revealing a file', async () => { + mocks.getSnapshot.mockResolvedValue(snapshot([node(1, 'src', 'directory')])); + mocks.revealPath.mockImplementation(async () => { + emit?.({ + workspaceId: 'workspace-1', + update: { + kind: 'delta', + generation: 1, + sequence: 2, + ops: [ + { op: 'put', key: 1, value: node(1, 'src', 'directory', null, true) }, + { op: 'put', key: 2, value: node(2, 'src/a', 'directory', 1, true) }, + { op: 'put', key: 3, value: node(3, 'src/a/b.ts', 'file', 2) }, + ], + }, + }); + return mutation(2); + }); const store = new FilesStore('project-1', 'workspace-1'); + const expandedPaths = new Set(); await store.tree.load(); - await flushAsyncWork(); - - expect(store.nodes.has('src/components/Button.tsx')).toBe(true); - expect(store.loadedPaths.has('src/components')).toBe(true); - - entriesByDir.src = [{ path: 'src/components', type: 'file' }]; - await store.loadDir('src', true); - vi.runOnlyPendingTimers(); + await store.revealFile('src/a/b.ts', expandedPaths); - expect(store.nodes.get('src/components')?.type).toBe('file'); - expect(store.nodes.get('src/components')?.children).toEqual([]); - expect(store.nodes.has('src/components/Button.tsx')).toBe(false); - expect(store.loadedPaths.has('src/components')).toBe(false); - expect(store.nodes.get('src')?.children.map((node) => node.path)).toEqual(['src/components']); + expect(mocks.revealPath).toHaveBeenCalledWith('project-1', 'workspace-1', 'src/a/b.ts'); + expect([...expandedPaths]).toEqual(['src', 'src/a']); + expect(store.nodes.has('src/a/b.ts')).toBe(true); store.dispose(); }); - it('sorts watch-created siblings after processing the event batch', async () => { - let emit: ((data: { workspaceId: string; events: FileWatchEvent[] }) => void) | undefined; - mocks.eventOn.mockImplementation((_channel: string, handler: typeof emit) => { - emit = handler; - return vi.fn(); - }); - setupListFiles({ '': [] }); + it('adds optimistic nodes under loaded parents and reconciles them by path', async () => { + mocks.getSnapshot.mockResolvedValue(snapshot([node(1, 'src', 'directory', null, true)])); const store = new FilesStore('project-1', 'workspace-1'); - store.startWatching(); await store.tree.load(); + expect(store.addOptimisticNodes([{ relPath: 'src/new.ts', type: 'file' }])).toEqual([ + 'src/new.ts', + ]); + expect(store.nodes.get('src/new.ts')?.id).toBeLessThan(0); + emit?.({ workspaceId: 'workspace-1', - events: [ - { type: 'create', entryType: 'file', path: 'z-file.ts' }, - { type: 'create', entryType: 'directory', path: 'components' }, - { type: 'create', entryType: 'file', path: 'a-file.ts' }, - { type: 'create', entryType: 'directory', path: 'alpha' }, - ], + update: { + kind: 'delta', + generation: 1, + sequence: 2, + ops: [{ op: 'put', key: 2, value: node(2, 'src/new.ts', 'file', 1) }], + }, }); + await flushAsyncWork(); - expect(store.rootNodes.map((node) => node.path)).toEqual([ - 'alpha', - 'components', - 'a-file.ts', - 'z-file.ts', - ]); + expect(store.nodes.get('src/new.ts')?.id).toBe(2); + expect(store.childrenById.get(1)?.map((entry) => entry.path)).toEqual(['src/new.ts']); store.dispose(); }); - it('loads and expands ancestor directories when revealing a file', async () => { - setupListFiles({ - '': [{ path: 'src', type: 'dir' }], - src: [{ path: 'src/a', type: 'dir' }], - 'src/a': [{ path: 'src/a/b.ts', type: 'file' }], - }); + it('rolls back optimistic nodes by path', async () => { + mocks.getSnapshot.mockResolvedValue(snapshot([node(1, 'src', 'directory', null, true)])); const store = new FilesStore('project-1', 'workspace-1'); - const expandedPaths = new Set(); + await store.tree.load(); + store.addOptimisticNodes([{ relPath: 'src/new.ts', type: 'file' }]); + store.removeNode('src/new.ts'); + + expect(store.nodes.has('src/new.ts')).toBe(false); + store.dispose(); + }); + it('expires unreconciled optimistic nodes after a safety timeout', async () => { + vi.useFakeTimers(); + mocks.getSnapshot.mockResolvedValue(snapshot([node(1, 'src', 'directory', null, true)])); + + const store = new FilesStore('project-1', 'workspace-1'); await store.tree.load(); - await store.revealFile('src/a/b.ts', expandedPaths); + const inserted = store.addOptimisticNodes([{ relPath: 'src/new.ts', type: 'file' }]); - expect(mocks.listFiles).toHaveBeenCalledWith('project-1', 'workspace-1', 'src', { - recursive: false, - includeHidden: true, - }); - expect(mocks.listFiles).toHaveBeenCalledWith('project-1', 'workspace-1', 'src/a', { - recursive: false, - includeHidden: true, - }); - expect([...expandedPaths]).toEqual(['src', 'src/a']); - expect(store.nodes.has('src/a/b.ts')).toBe(true); + expect(store.nodes.has('src/new.ts')).toBe(true); + + vi.advanceTimersByTime(15_000); + + expect(store.nodes.has('src/new.ts')).toBe(true); + + store.confirmOptimisticNodes(inserted); + vi.advanceTimersByTime(15_000); + + expect(store.nodes.has('src/new.ts')).toBe(false); store.dispose(); }); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.ts index 725c2d40e6..06f26ffbd2 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.ts @@ -1,420 +1,514 @@ +import type { FileNode as CoreFileNode, NodeId } from '@emdash/core/file-tree'; +import type { KeyedOp } from '@emdash/core/lib'; +import { computed, makeObservable, observable, runInAction } from 'mobx'; import { - isExcluded, - makeNode, normalizeFileTreePath, sortFileNodes, -} from '@renderer/features/tasks/editor/stores/files-store-utils'; + toRenderableFileNode, + type RenderableFileNode, +} from '@renderer/features/tasks/file-tree/tree-utils'; import { events, rpc } from '@renderer/lib/ipc'; -import { Resource } from '@renderer/lib/stores/resource'; -import { type FileNode, type FileWatchEvent } from '@shared/core/fs/fs'; -import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +import { + bindCollectionMirror, + coalesce, + CollectionMirror, + type CollectionMirrorChange, + type MirrorBinding, + type MirrorBindingStatus, +} from '@renderer/lib/stores/live'; +import type { FileTreeMutationResult, FileTreeSnapshotResult } from '@shared/core/fs/file-tree'; +import { + fileTreeOperationErrorMessage, + type FileTreeOperationError, +} from '@shared/core/fs/file-tree-errors'; +import { fileTreeUpdateChannel } from '@shared/core/fs/fsEvents'; export interface FilesData { - nodes: Map; - rootNodes: FileNode[]; + nodes: Map; + rootNodes: RenderableFileNode[]; + childrenById: Map; + loadedPaths: Set; } -// --------------------------------------------------------------------------- -// FilesStore -// --------------------------------------------------------------------------- +type FilesView = FilesData & { + pathToId: Map; +}; + +type OptimisticNode = { + node: CoreFileNode; + timer?: ReturnType; +}; + +const ROOT_SCOPE_PATH = ''; +const OPTIMISTIC_NODE_TTL_MS = 15_000; export class FilesStore { - // Non-observable imperative maps — tree.data drives reactive re-renders. - private readonly _nodes = new Map(); - private _rootNodes: FileNode[] = []; - private readonly _loadedPaths = new Set(); - private readonly _pendingPaths = new Set(); - private _bumpTimer: ReturnType | null = null; - - /** - * The reactive container for the file tree. Components observe `tree.data` - * (or access `nodes`/`rootNodes` getters which read through `tree.data`). - * The data object reference is replaced whenever the tree structure changes, - * triggering MobX re-renders — replacing the old `generation` counter. - */ - readonly tree: Resource; + private readonly mirror: CollectionMirror; + private readonly binding: MirrorBinding; + private readonly baseNodes = new Map(); + private readonly basePathToId = new Map(); + private readonly viewNodesById = new Map(); + private readonly viewData: FilesView = { + nodes: new Map(), + rootNodes: [], + childrenById: new Map(), + loadedPaths: new Set(), + pathToId: new Map(), + }; + private readonly optimisticNodes = observable.map(); + private readonly pendingPathSet = observable.set(); + private nextOptimisticId = -1; + private viewRevision = 0; + private started = false; + private syncError: string | null = null; constructor( private readonly projectId: string, private readonly workspaceId: string ) { - this.tree = new Resource( - () => this._fetchAll(), - [ - { - kind: 'event', - subscribe: (handler) => { - rpc.workspace.fs - .watchSetPaths(projectId, workspaceId, [''], 'filetree') - .catch(() => {}); - const unsub = events.on(fsWatchEventChannel, (data) => { - if (data.workspaceId !== workspaceId) return; - handler(data.events); - }); - return () => { - unsub(); - rpc.workspace.fs.watchStop(projectId, workspaceId, 'filetree').catch(() => {}); - }; - }, - onEvent: (watchEvents, ctx) => { - if (!ctx.data) { - ctx.reload(); - return; - } - const changed = this._applyWatchEventsInternal(watchEvents); - if (changed) ctx.set({ nodes: this._nodes, rootNodes: this._rootNodes }); - }, - }, - ], - { refData: true } - ); + this.mirror = new CollectionMirror({ + onApplied: (change) => this.applyMirrorChange(change), + }); + + const snapshot = coalesce(async (): Promise => { + const result = await rpc.workspace.fileTree.getSnapshot(this.projectId, this.workspaceId); + if (result.success) { + runInAction(() => { + this.syncError = null; + }); + } + return result; + }); + + this.binding = bindCollectionMirror({ + mirror: this.mirror, + subscribe: (push) => + events.on(fileTreeUpdateChannel, (payload) => { + if (payload.workspaceId !== this.workspaceId) return; + push(payload.update); + }), + snapshot, + onError: (error) => { + runInAction(() => { + this.syncError = fileTreeOperationErrorMessage(error); + }); + }, + onUnexpectedError: (error) => { + runInAction(() => { + this.syncError = error instanceof Error ? error.message : String(error); + }); + }, + }); + + makeObservable(this, { + syncError: observable, + viewRevision: observable, + pendingPaths: computed, + isLoading: computed, + error: computed, + }); } - // --------------------------------------------------------------------------- - // Public reactive getters - // --------------------------------------------------------------------------- + get nodes(): Map { + void this.viewRevision; + return this.viewData.nodes; + } - /** - * Reading `nodes` establishes a MobX dependency on `tree.data`. - * When the tree structure changes (`tree.data` gets a new object reference), - * observer components re-render. The `??` fallback covers the initial null - * state; once set, `tree.data.nodes` and `_nodes` are the same Map instance. - */ - get nodes(): Map { - return this.tree.data?.nodes ?? this._nodes; + get rootNodes(): RenderableFileNode[] { + void this.viewRevision; + return this.viewData.rootNodes; } - get rootNodes(): FileNode[] { - return this.tree.data?.rootNodes ?? this._rootNodes; + get childrenById(): Map { + void this.viewRevision; + return this.viewData.childrenById; } get loadedPaths(): Set { - return this._loadedPaths; + void this.viewRevision; + return this.viewData.loadedPaths; } get pendingPaths(): Set { - return this._pendingPaths; + return this.pendingPathSet; } get isLoading(): boolean { - return this.tree.loading; + return !this.mirror.hasSnapshot && this.binding.status !== 'error'; } get error(): string | undefined { - return this.tree.error; + if (!this.mirror.hasSnapshot && this.binding.status === 'error') { + return this.syncError ?? 'Failed to load file tree'; + } + return undefined; + } + + get syncStatus(): MirrorBindingStatus { + return this.binding.status; } - // --------------------------------------------------------------------------- - // Lifecycle - // --------------------------------------------------------------------------- + get tree(): { + data: FilesData | null; + loading: boolean; + error?: string; + load: () => Promise; + } { + return { + data: this.mirror.hasSnapshot ? this.view : null, + loading: this.isLoading, + error: this.error, + load: () => this.resync(), + }; + } - /** Start watching — triggers initial load and subscribes to FS events. */ startWatching(): void { - this.tree.start(); + if (this.started) return; + this.started = true; + this.binding.start(); + } + + async resync(): Promise { + this.started = true; + this.binding.start(); + await this.binding.resync(); } dispose(): void { - if (this._bumpTimer) { - clearTimeout(this._bumpTimer); - this._bumpTimer = null; + this.binding.dispose(); + this.mirror.dispose(); + for (const optimistic of this.optimisticNodes.values()) { + if (optimistic.timer) clearTimeout(optimistic.timer); } - this.tree.dispose(); + runInAction(() => { + this.baseNodes.clear(); + this.basePathToId.clear(); + this.clearViewData(); + this.optimisticNodes.clear(); + this.pendingPathSet.clear(); + this.syncError = null; + this.bumpView(); + }); + this.started = false; } - // --------------------------------------------------------------------------- - // Public incremental loading (called from UI on expand/reveal) - // --------------------------------------------------------------------------- - async loadDir(dirPath: string, force = false): Promise { - await this._loadDirInternal(normalizeFileTreePath(dirPath), force); - this._bumpTreeDebounced(); + const path = normalizeFileTreePath(dirPath); + if (!force && (this.loadedPaths.has(path) || this.pendingPathSet.has(path))) return; + + const dirId = this.idForPath(path); + if (path && dirId === undefined) return; + + runInAction(() => { + this.pendingPathSet.add(path); + }); + try { + const result = await rpc.workspace.fileTree.expandDir( + this.projectId, + this.workspaceId, + dirId ?? null + ); + await this.waitForTreeMutation(result); + } finally { + runInAction(() => { + this.pendingPathSet.delete(path); + }); + } } - /** Optimistically insert dropped nodes and bump the tree once. */ addOptimisticNodes(nodes: Array<{ relPath: string; type: 'file' | 'directory' }>): string[] { const inserted: string[] = []; - const affectedParents = new Set(); - for (const { relPath, type } of nodes) { - const path = normalizeFileTreePath(relPath); - if (!path || isExcluded(path) || this._nodes.has(path)) continue; + runInAction(() => { + for (const { relPath, type } of nodes) { + const path = normalizeFileTreePath(relPath); + if (!path || this.nodes.has(path) || this.optimisticNodeForPath(path)) continue; - const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; - if (!this._loadedPaths.has(parent)) continue; + const parentPath = parentPathFromPath(path) ?? ROOT_SCOPE_PATH; + if (!this.loadedPaths.has(parentPath)) continue; - const node = makeNode(path, type); - this._addNode(node); - affectedParents.add(node.parentPath); - inserted.push(path); - } + let parentId: NodeId | null = null; + if (parentPath) { + const resolvedParentId = this.idForPath(parentPath); + if (resolvedParentId === undefined) continue; + parentId = resolvedParentId; + } - if (inserted.length > 0) { - for (const parentPath of affectedParents) { - this._sortChildren(parentPath ? this._nodes.get(parentPath) : null); + const id = this.nextOptimisticId; + this.nextOptimisticId -= 1; + const node = { + id, + path, + name: basenameFromPath(path), + parentId, + type, + childrenLoaded: false, + }; + this.optimisticNodes.set(id, { + node, + }); + if (!this.basePathToId.has(path)) this.addNodeToView(node); + inserted.push(path); } - this._bumpTree(); - } + if (inserted.length > 0) this.bumpView(); + }); + return inserted; } + confirmOptimisticNodes(relPaths: string[]): void { + runInAction(() => { + for (const relPath of relPaths) { + const optimistic = this.optimisticNodeForPath(normalizeFileTreePath(relPath)); + if (optimistic !== undefined) this.armOptimisticNodeExpiry(optimistic); + } + }); + } + removeNode(relPath: string): void { const path = normalizeFileTreePath(relPath); - if (!this._nodes.has(path)) return; - this._removeNode(path); - this._bumpTree(); + const optimistic = this.optimisticNodeForPath(path); + if (optimistic === undefined) return; + runInAction(() => { + this.removeOptimisticNode(optimistic); + }); } async revealFile(filePath: string, expandedPaths: Set): Promise { - const parts = normalizeFileTreePath(filePath).split('/').filter(Boolean); - const dirs: string[] = []; - for (let i = 1; i < parts.length; i++) { - dirs.push(parts.slice(0, i).join('/')); - } + const path = normalizeFileTreePath(filePath); + if (!path) return; - for (const dir of dirs) { - await this._loadDirInternal(dir); - } + const result = await rpc.workspace.fileTree.revealPath(this.projectId, this.workspaceId, path); + const succeeded = await this.waitForTreeMutation(result); + if (!succeeded) return; - for (const dir of dirs) expandedPaths.add(dir); - this._bumpTree(); + const parts = path.split('/').filter(Boolean); + runInAction(() => { + for (let index = 1; index < parts.length; index += 1) { + expandedPaths.add(parts.slice(0, index).join('/')); + } + }); } - // --------------------------------------------------------------------------- - // Private helpers - // --------------------------------------------------------------------------- - - /** Initial load used as the Resource's fetch function. */ - private async _fetchAll(): Promise { - this._nodes.clear(); - this._rootNodes = []; - this._loadedPaths.clear(); - this._pendingPaths.clear(); - await this._loadDirInternal(''); - return { nodes: this._nodes, rootNodes: this._rootNodes }; + private get view(): FilesView { + void this.viewRevision; + return this.viewData; } - /** Load a single directory level into the backing Maps. No reactivity bump. */ - private async _loadDirInternal(dirPath: string, force = false): Promise { - dirPath = normalizeFileTreePath(dirPath); - if (!force && (this._loadedPaths.has(dirPath) || this._pendingPaths.has(dirPath))) return; - this._pendingPaths.add(dirPath); - - try { - const result = await rpc.workspace.fs.listFiles( - this.projectId, - this.workspaceId, - dirPath || '.', - { - recursive: false, - includeHidden: true, - } - ); - - if (!result.success) return; - - this._applyEntries(dirPath, result.data.entries); - - for (const entry of result.data.entries) { - const path = normalizeFileTreePath(entry.path); - if (entry.type === 'dir' && path && !isExcluded(path)) { - void this._loadDirInternal(path); - } - } + private idForPath(path: string): NodeId | undefined { + return this.view.pathToId.get(path); + } - this._bumpTreeDebounced(); - } catch { - // Silently ignore errors for individual directories - } finally { - this._pendingPaths.delete(dirPath); + private optimisticNodeForPath(path: string): NodeId | undefined { + for (const [id, optimistic] of this.optimisticNodes) { + if (optimistic.node.path === path) return id; } + return undefined; } - private _applyEntries( - dirPath: string, - entries: Array<{ path: string; type: 'file' | 'dir'; mtime?: Date }> - ): void { - const normalizedDirPath = normalizeFileTreePath(dirPath); - const parent = this._ensureDirectory(normalizedDirPath); - const nextChildren = new Set(); + private applyMirrorChange(change: CollectionMirrorChange): void { + if (change.kind === 'snapshot') { + this.applyMirrorSnapshot(change.snapshot.entries); + } else { + this.applyMirrorDelta(change.update.ops); + } + } - for (const entry of entries) { - const path = normalizeFileTreePath(entry.path); - if (!path || isExcluded(path)) continue; + private applyMirrorSnapshot(entries: Array<[NodeId, CoreFileNode]>): void { + this.baseNodes.clear(); + this.basePathToId.clear(); + this.clearViewData(); + this.viewData.loadedPaths.add(ROOT_SCOPE_PATH); - const node = makeNode(path, entry.type === 'dir' ? 'directory' : 'file', entry.mtime); - if ((node.parentPath ?? '') !== normalizedDirPath) continue; + for (const [id, node] of entries) { + this.baseNodes.set(id, node); + this.basePathToId.set(node.path, id); + this.addNodeToView(node, { sort: false }); + } - nextChildren.add(node.path); - this._addNode(node); + for (const [parentId, siblings] of this.viewData.childrenById) { + this.viewData.childrenById.set(parentId, sortFileNodes(siblings)); } + this.refreshRootNodes(); - const currentChildren = parent?.children ?? this._rootNodes; - for (const child of [...currentChildren]) { - if (!nextChildren.has(child.path)) { - this._removeNode(child.path); - } + const authoritativePaths = new Set(entries.map(([, node]) => node.path)); + this.pruneOptimisticPaths(authoritativePaths); + for (const optimistic of this.optimisticNodes.values()) { + if (!this.basePathToId.has(optimistic.node.path)) this.addNodeToView(optimistic.node); } - this._sortChildren(parent); - this._loadedPaths.add(normalizedDirPath); + this.bumpView(); } - private _ensureDirectory(path: string): FileNode | null { - if (!path) return null; - - const parts = path.split('/').filter(Boolean); - let currentPath = ''; - let current: FileNode | null = null; - - for (const part of parts) { - currentPath = currentPath ? `${currentPath}/${part}` : part; - const existing = this._nodes.get(currentPath); - if (existing) { - current = existing; - continue; - } - - current = makeNode(currentPath, 'directory'); - this._addNode(current); + private applyMirrorDelta(ops: Array>): void { + let changed = false; + for (const op of ops) { + changed = + op.op === 'put' + ? this.applyBasePut(op.key, op.value) || changed + : this.applyBaseDel(op.key) || changed; } - - return current; + if (changed) this.bumpView(); } - private _addNode(node: FileNode): void { - const existing = this._nodes.get(node.path); - if (existing) { - this._replaceExistingNode(existing, node); - return; + private applyBasePut(id: NodeId, node: CoreFileNode): boolean { + const previous = this.baseNodes.get(id); + if (previous) { + this.basePathToId.delete(previous.path); + this.removeNodeFromView(id); } - this._nodes.set(node.path, node); - const parent = node.parentPath ? this._nodes.get(node.parentPath) : null; - const siblings = parent?.children ?? this._rootNodes; - if (!siblings.some((child) => child.path === node.path)) siblings.push(node); - } + const optimistic = this.optimisticNodeForPath(node.path); + if (optimistic !== undefined) this.removeOptimisticNodeFromView(optimistic); + + this.baseNodes.set(id, node); + this.basePathToId.set(node.path, id); + this.addNodeToView(node); - private _replaceExistingNode(existing: FileNode, next: FileNode): void { - if (existing.type === 'directory' && next.type === 'file') { - this._replaceDirectoryWithFile(existing, next); - return; + if (previous && previous.path !== node.path) { + this.restoreOptimisticPath(previous.path); } - existing.type = next.type; - existing.mtime = next.mtime; - existing.extension = next.extension; - existing.isHidden = next.isHidden; + return true; } - private _replaceDirectoryWithFile(existing: FileNode, next: FileNode): void { - const parent = existing.parentPath ? this._nodes.get(existing.parentPath) : null; - const siblings = parent?.children ?? this._rootNodes; - const index = siblings.findIndex((child) => child.path === existing.path); + private applyBaseDel(id: NodeId): boolean { + const previous = this.baseNodes.get(id); + if (!previous) return false; - this._removeNodeFromMaps(existing); - this._nodes.set(next.path, next); + this.baseNodes.delete(id); + this.basePathToId.delete(previous.path); + this.removeNodeFromView(id); + this.restoreOptimisticPath(previous.path); + return true; + } - if (index === -1) { - siblings.push(next); - } else { - siblings[index] = next; - } + private pruneOptimisticPaths(paths: Set): void { + const ids = [...this.optimisticNodes] + .filter(([, optimistic]) => paths.has(optimistic.node.path)) + .map(([id]) => id); + for (const id of ids) this.removeOptimisticNodeFromView(id); } - private _removeNode(path: string): void { - const node = this._nodes.get(path); - if (!node) return; + private armOptimisticNodeExpiry(id: NodeId): void { + const optimistic = this.optimisticNodes.get(id); + if (!optimistic) return; + if (optimistic.timer) clearTimeout(optimistic.timer); + optimistic.timer = setTimeout(() => { + runInAction(() => { + this.removeOptimisticNode(id); + }); + }, OPTIMISTIC_NODE_TTL_MS); + } - const parent = node.parentPath ? this._nodes.get(node.parentPath) : null; - const siblings = parent?.children ?? this._rootNodes; - const index = siblings.findIndex((child) => child.path === path); - if (index !== -1) siblings.splice(index, 1); + private removeOptimisticNode(id: NodeId): void { + if (this.removeOptimisticNodeFromView(id)) this.bumpView(); + } - this._removeNodeFromMaps(node); + private removeOptimisticNodeFromView(id: NodeId): boolean { + const optimistic = this.optimisticNodes.get(id); + if (!optimistic) return false; + if (optimistic?.timer) clearTimeout(optimistic.timer); + this.optimisticNodes.delete(id); + return this.removeNodeFromView(id); } - private _removeNodeFromMaps(node: FileNode): void { - for (const child of [...node.children]) { - this._removeNodeFromMaps(child); - } - this._nodes.delete(node.path); - this._loadedPaths.delete(node.path); + private restoreOptimisticPath(path: string): boolean { + if (this.basePathToId.has(path)) return false; + const optimistic = this.optimisticNodeForPath(path); + if (optimistic === undefined) return false; + const entry = this.optimisticNodes.get(optimistic); + if (!entry) return false; + return this.addNodeToView(entry.node); } - private _sortChildren(parent: FileNode | null | undefined): void { - if (parent) { - parent.children = sortFileNodes(parent.children); - } else { - this._rootNodes = sortFileNodes(this._rootNodes); + private addNodeToView(node: CoreFileNode, opts: { sort?: boolean } = {}): boolean { + const renderNode = toRenderableFileNode(node); + this.viewNodesById.set(renderNode.id, renderNode); + this.viewData.nodes.set(renderNode.path, renderNode); + this.viewData.pathToId.set(renderNode.path, renderNode.id); + if (renderNode.type === 'directory' && renderNode.childrenLoaded) { + this.viewData.loadedPaths.add(renderNode.path); } + + const siblings = this.viewData.childrenById.get(renderNode.parentId) ?? []; + siblings.push(renderNode); + this.viewData.childrenById.set( + renderNode.parentId, + opts.sort === false ? siblings : sortFileNodes(siblings) + ); + if (renderNode.parentId === null) this.refreshRootNodes(); + return true; } - /** Mutate the backing maps for watch events. Returns true if anything changed. */ - private _applyWatchEventsInternal(watchEvents: FileWatchEvent[]): boolean { - let changed = false; - const affectedParents = new Set(); - - for (const evt of watchEvents) { - const path = normalizeFileTreePath(evt.path); - if (isExcluded(path)) continue; - - if (evt.type === 'create') { - const node = makeNode(path, evt.entryType); - const parentLoaded = this._loadedPaths.has(node.parentPath ?? ''); - if (parentLoaded && !this._nodes.has(node.path)) { - this._addNode(node); - affectedParents.add(node.parentPath); - changed = true; - } - } else if (evt.type === 'delete') { - const existing = this._nodes.get(path); - if (existing) { - affectedParents.add(existing.parentPath); - this._removeNode(path); - changed = true; - } - } else if (evt.type === 'modify') { - const existing = this._nodes.get(path); - if (existing) { - existing.mtime = new Date(); - changed = true; - } - } else if (evt.type === 'rename' && evt.oldPath) { - const oldPath = normalizeFileTreePath(evt.oldPath); - if (this._nodes.has(oldPath)) { - this._removeNode(oldPath); - changed = true; - } - const node = makeNode(path, evt.entryType); - const parentLoaded = this._loadedPaths.has(node.parentPath ?? ''); - if (parentLoaded) { - this._addNode(node); - affectedParents.add(node.parentPath); - changed = true; - } + private removeNodeFromView(id: NodeId): boolean { + const node = this.viewNodesById.get(id); + if (!node) return false; + + this.viewNodesById.delete(id); + this.viewData.nodes.delete(node.path); + this.viewData.pathToId.delete(node.path); + if (node.type === 'directory') this.viewData.loadedPaths.delete(node.path); + + const siblings = this.viewData.childrenById.get(node.parentId); + if (siblings) { + const next = siblings.filter((sibling) => sibling.id !== id); + if (next.length === 0) { + this.viewData.childrenById.delete(node.parentId); + } else { + this.viewData.childrenById.set(node.parentId, next); } } + if (node.parentId === null) this.refreshRootNodes(); + return true; + } - for (const parentPath of affectedParents) { - this._sortChildren(parentPath ? this._nodes.get(parentPath) : null); - } + private clearViewData(): void { + this.viewNodesById.clear(); + this.viewData.nodes.clear(); + this.viewData.rootNodes = []; + this.viewData.childrenById.clear(); + this.viewData.loadedPaths.clear(); + this.viewData.pathToId.clear(); + } - return changed; + private refreshRootNodes(): void { + this.viewData.rootNodes = this.viewData.childrenById.get(null) ?? []; } - private _bumpTree(): void { - this.tree.setValue({ nodes: this._nodes, rootNodes: this._rootNodes }); + private bumpView(): void { + this.viewRevision += 1; } - private _bumpTreeDebounced(): void { - if (this._bumpTimer) return; - this._bumpTimer = setTimeout(() => { - this._bumpTimer = null; - this._bumpTree(); - }, 50); + private async waitForTreeMutation(result: FileTreeMutationResult): Promise { + if (!result.success) { + runInAction(() => { + this.syncError = fileTreeOperationErrorMessage(result.error); + }); + return false; + } + + const sequence = result.data.sequences.tree; + if (sequence !== undefined) { + try { + await this.mirror.waitForSequence(sequence); + } catch { + await this.binding.resync(); + } + } + runInAction(() => { + this.syncError = null; + }); + return true; } } + +function parentPathFromPath(path: string): string | null { + const index = path.lastIndexOf('/'); + return index === -1 ? null : path.slice(0, index); +} + +function basenameFromPath(path: string): string { + const index = path.lastIndexOf('/'); + return index === -1 ? path : path.slice(index + 1); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store-utils.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.test.ts similarity index 82% rename from apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store-utils.test.ts rename to apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.test.ts index c9a5c448ef..8faf9c7cbd 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store-utils.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest'; -import type { FileNode } from '@shared/core/fs/fs'; -import { buildVisibleRows, makeNode, sortFileNodes } from './files-store-utils'; - -function attach(parent: FileNode, child: FileNode): FileNode { +import { + buildVisibleRows, + makeNode, + sortFileNodes, + toRenderableFileNode, + type NestedFileNode, + type RenderableFileNode, +} from './tree-utils'; + +function attach(parent: NestedFileNode, child: NestedFileNode): NestedFileNode { parent.children.push(child); return child; } @@ -190,4 +196,44 @@ describe('file tree utils', () => { expect(rows).toHaveLength(1); expect(rows[0].chain.map((n) => n.path)).toEqual(['a', 'a/b']); }); + + it('walks flat render nodes through a childrenById index', () => { + const src = toRenderableFileNode({ + id: 1, + path: 'src', + name: 'src', + parentId: null, + type: 'directory', + childrenLoaded: true, + }); + const components = toRenderableFileNode({ + id: 2, + path: 'src/components', + name: 'components', + parentId: 1, + type: 'directory', + childrenLoaded: true, + }); + const button = toRenderableFileNode({ + id: 3, + path: 'src/components/Button.tsx', + name: 'Button.tsx', + parentId: 2, + type: 'file', + childrenLoaded: false, + }); + const childrenById = new Map([ + [null, [src]], + [1, [components]], + [2, [button]], + ]); + + const rows = buildVisibleRows([src], new Set(['src/components']), childrenById); + + expect(rows.map((row) => row.node.path)).toEqual([ + 'src/components', + 'src/components/Button.tsx', + ]); + expect(rows[0].chain.map((node) => node.path)).toEqual(['src', 'src/components']); + }); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts new file mode 100644 index 0000000000..345755b8ba --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts @@ -0,0 +1,147 @@ +import type { FileNode as CoreFileNode, NodeId } from '@emdash/core/file-tree'; + +export interface RenderableFileNode { + id: NodeId; + path: string; + name: string; + parentId: NodeId | null; + parentPath: string | null; + depth: number; + type: 'file' | 'directory'; + childrenLoaded: boolean; + isHidden: boolean; + extension?: string; +} + +export interface NestedFileNode { + path: string; + name: string; + parentPath: string | null; + depth: number; + type: 'file' | 'directory'; + children: NestedFileNode[]; + isHidden: boolean; + extension?: string; +} + +export type VisibleFileNode = RenderableFileNode | NestedFileNode; + +export type ChildrenById = Map< + NodeId | null, + readonly T[] +>; + +export function normalizeFileTreePath(path: string): string { + return path.replace(/\\/g, '/').split('/').filter(Boolean).join('/'); +} + +export function makeNode(relPath: string, type: 'file' | 'directory'): NestedFileNode { + const path = normalizeFileTreePath(relPath); + const parts = path.split('/').filter(Boolean); + const name = parts[parts.length - 1] ?? path; + const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : null; + const depth = parts.length - 1; + const extension = type === 'file' && name.includes('.') ? name.split('.').pop() : undefined; + + return { + path, + name, + parentPath, + depth, + type, + children: [], + isHidden: name.startsWith('.'), + extension, + }; +} + +export function toRenderableFileNode(node: CoreFileNode): RenderableFileNode { + const path = normalizeFileTreePath(node.path); + const parts = path.split('/').filter(Boolean); + const name = node.name || parts[parts.length - 1] || path; + const extension = node.type === 'file' && name.includes('.') ? name.split('.').pop() : undefined; + return { + id: node.id, + path, + name, + parentId: node.parentId, + parentPath: parts.length > 1 ? parts.slice(0, -1).join('/') : null, + depth: parts.length - 1, + type: node.type, + childrenLoaded: node.childrenLoaded, + isHidden: name.startsWith('.'), + extension, + }; +} + +export function sortFileNodes(nodes: readonly T[]): T[] { + return [...nodes].sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} + +export interface TreeRow { + node: T; + chain: T[]; + renderDepth: number; +} + +function hasNestedChildren(node: VisibleFileNode): node is NestedFileNode { + return 'children' in node; +} + +function childrenFor( + node: T, + childrenById?: ChildrenById +): readonly T[] { + if (childrenById && 'id' in node) return childrenById.get(node.id) ?? []; + return hasNestedChildren(node) ? (node.children as unknown as readonly T[]) : []; +} + +function extendChain(node: T, childrenById?: ChildrenById): T[] { + const chain: T[] = [node]; + const visited = new Set([node.path]); + let current = node; + while (current.type === 'directory') { + const children = childrenFor(current, childrenById); + if (children.length !== 1 || children[0].type !== 'directory') break; + if (visited.has(children[0].path)) break; + current = children[0]; + visited.add(current.path); + chain.push(current); + } + return chain; +} + +export function isChainExpanded( + chain: readonly T[], + expandedPaths: Set +): boolean { + for (const segment of chain) { + if (expandedPaths.has(segment.path)) return true; + } + return false; +} + +export function buildVisibleRows( + rootNodes: readonly T[], + expandedPaths: Set, + childrenById?: ChildrenById +): Array> { + const rows: Array> = []; + + function walk(nodes: readonly T[], renderDepth: number) { + for (const node of nodes) { + const chain = node.type === 'directory' ? extendChain(node, childrenById) : [node]; + const terminus = chain[chain.length - 1]; + rows.push({ node: terminus, chain, renderDepth }); + if (terminus.type === 'directory' && isChainExpanded(chain, expandedPaths)) { + walk(childrenFor(terminus, childrenById), renderDepth + 1); + } + } + } + + walk(rootNodes, 0); + return rows; +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts index 992990c04c..8f689d01b4 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts @@ -31,8 +31,13 @@ vi.mock('@renderer/lib/ipc', () => ({ }, workspace: { gitWorktree: {}, + fileTree: { + getSnapshot: vi.fn().mockResolvedValue({ + success: true, + data: { entries: [], generation: 1, sequence: 0 }, + }), + }, fs: { - listFiles: vi.fn().mockResolvedValue({ success: true, data: [] }), watchSetPaths: vi.fn().mockResolvedValue(undefined), watchStop: vi.fn().mockResolvedValue(undefined), }, diff --git a/apps/emdash-desktop/src/shared/core/fs/fs.ts b/apps/emdash-desktop/src/shared/core/fs/fs.ts index 47ab69cef2..61d109eb7a 100644 --- a/apps/emdash-desktop/src/shared/core/fs/fs.ts +++ b/apps/emdash-desktop/src/shared/core/fs/fs.ts @@ -1,15 +1,3 @@ -export interface FileNode { - path: string; - name: string; - parentPath: string | null; - depth: number; - type: 'file' | 'directory'; - children: FileNode[]; - isHidden: boolean; - extension?: string; - mtime?: Date; -} - export type FileWatchEventType = 'create' | 'delete' | 'modify' | 'rename'; export interface FileWatchEvent { From c5b284318327195f9101cd0b7e51ec0fee1e13cc Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:30:49 -0700 Subject: [PATCH 05/37] fix(file-tree): normalize ssh listed paths --- .../src/main/core/fs/impl/ssh-fs.test.ts | 80 +++++++++++++++++++ .../src/main/core/fs/impl/ssh-fs.ts | 12 ++- .../core/runtime/legacy/ssh-file-tree.test.ts | 75 +++++++++++++++++ 3 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts index db4299bbe0..80738329be 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts @@ -3,6 +3,16 @@ import type { FileEntry, FileListResult } from '../types'; import { SshFileSystem } from './ssh-fs'; type SftpMkdirError = Error & { code?: number }; +type SftpItem = { + filename: string; + attrs: { + isDirectory: () => boolean; + size: number; + mtime: number; + atime: number; + mode: number; + }; +}; function listResult(entries: FileEntry[]): FileListResult { return { entries, total: entries.length }; @@ -39,6 +49,40 @@ function makeMkdirFs(errors: Array) { }; } +function makeListFs(rootPath: string, entriesByPath: Record) { + const sftp = { + on: vi.fn(), + readdir: vi.fn( + (dirPath: string, callback: (error: Error | null, items: SftpItem[]) => void) => { + callback(null, entriesByPath[dirPath] ?? []); + } + ), + }; + const proxy = { + sftp: vi.fn((callback: (error: Error | undefined, sftp: unknown) => void) => { + callback(undefined, sftp); + }), + }; + + return { + fs: new SshFileSystem(proxy as never, rootPath), + readdir: sftp.readdir, + }; +} + +function sftpItem(filename: string, type: 'file' | 'dir'): SftpItem { + return { + filename, + attrs: { + isDirectory: () => type === 'dir', + size: type === 'dir' ? 0 : 1, + mtime: 1, + atime: 1, + mode: type === 'dir' ? 0o040755 : 0o100644, + }, + }; +} + describe('SshFileSystem.mkdir', () => { afterEach(() => { vi.restoreAllMocks(); @@ -70,6 +114,42 @@ describe('SshFileSystem.mkdir', () => { }); }); +describe('SshFileSystem.list', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns relative paths when the remote root is /', async () => { + const { fs } = makeListFs('/', { + '/': [sftpItem('repo', 'dir')], + }); + + await expect(fs.list('', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'repo', type: 'dir' }], + }); + }); + + it('returns relative nested paths when the remote root is /', async () => { + const { fs } = makeListFs('/', { + '/repo': [sftpItem('src', 'dir')], + }); + + await expect(fs.list('repo', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'repo/src', type: 'dir' }], + }); + }); + + it('returns relative paths under a trailing-slash remote root', async () => { + const { fs } = makeListFs('/repo/', { + '/repo/src': [sftpItem('index.ts', 'file')], + }); + + await expect(fs.list('src', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'src/index.ts', type: 'file' }], + }); + }); +}); + describe('SshFileSystem.watch', () => { afterEach(() => { vi.useRealTimers(); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts index a5544a46c0..737e1a8086 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts @@ -853,14 +853,14 @@ export class SshFileSystem implements FileSystemProvider { * Get relative path from full remote path */ private relativePath(fullPath: string): string { - const normalized = fullPath.replace(/\\/g, '/'); - const normalizedBase = this.remotePath.replace(/\\/g, '/'); + const normalized = fullPath.replace(/\\/g, '/').replace(/\/+/g, '/'); + const normalizedBase = normalizeRemoteBasePath(this.remotePath); if (normalized === normalizedBase) { return ''; } - const prefix = `${normalizedBase}/`; + const prefix = normalizedBase === '/' ? '/' : `${normalizedBase}/`; if (normalized.startsWith(prefix)) { return normalized.substring(prefix.length); } @@ -1036,3 +1036,9 @@ export class SshFileSystem implements FileSystemProvider { }; } } + +function normalizeRemoteBasePath(path: string): string { + const normalized = path.replace(/\\/g, '/').replace(/\/+/g, '/'); + if (normalized === '/') return '/'; + return normalized.replace(/\/$/, ''); +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts new file mode 100644 index 0000000000..1df2cbd921 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import type { FileEntry, FileListResult } from '@main/core/fs/types'; +import { LegacySshFileTreeRuntime } from './ssh-file-tree'; + +function listResult(entries: FileEntry[]): FileListResult { + return { entries, total: entries.length }; +} + +function fileEntry(path: string): FileEntry { + return { + path, + type: 'file', + size: 1, + mtime: new Date(1_000), + mode: 0o100644, + }; +} + +function dirEntry(path: string): FileEntry { + return { + path, + type: 'dir', + size: 0, + mtime: new Date(1_000), + mode: 0o040755, + }; +} + +describe('LegacySshFileTreeRuntime', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads children for expanded remote directory scopes', async () => { + vi.spyOn(SshFileSystem.prototype, 'list').mockImplementation(async (dirPath = '') => { + if (dirPath === '') return listResult([dirEntry('src')]); + if (dirPath === 'src') return listResult([fileEntry('src/index.ts')]); + return listResult([]); + }); + + const runtime = new LegacySshFileTreeRuntime({} as never); + const opened = await runtime.open('/repo'); + expect(opened.success).toBe(true); + if (!opened.success) return; + + const tree = opened.data.value; + const rootSnapshot = await tree.getSnapshot(); + expect(rootSnapshot.success).toBe(true); + if (!rootSnapshot.success) return; + + const src = rootSnapshot.data.entries.find(([, node]) => node.path === 'src')?.[1]; + expect(src).toMatchObject({ path: 'src', type: 'directory', parentId: null }); + expect(src).toBeDefined(); + if (!src) return; + + const expanded = await tree.expandDir(src.id); + expect(expanded.success).toBe(true); + + const expandedSnapshot = await tree.getSnapshot(); + expect(expandedSnapshot.success).toBe(true); + if (!expandedSnapshot.success) return; + + expect(expandedSnapshot.data.entries.map(([, node]) => node.path).sort()).toEqual([ + 'src', + 'src/index.ts', + ]); + expect( + expandedSnapshot.data.entries.find(([, node]) => node.path === 'src/index.ts')?.[1] + ).toMatchObject({ parentId: src.id }); + + opened.data.release(); + await runtime.dispose(); + }); +}); From 694057422fa251ac8d62a51a0331238e9ad4c617 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:30:58 -0700 Subject: [PATCH 06/37] perf(file-tree): stat listed children concurrently --- packages/core/src/file-tree/list.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/core/src/file-tree/list.ts b/packages/core/src/file-tree/list.ts index d4ae577538..4be538899b 100644 --- a/packages/core/src/file-tree/list.ts +++ b/packages/core/src/file-tree/list.ts @@ -28,22 +28,29 @@ export async function listChildren( return err(classifyFileTreeFsError(error, resolved.data.relPath)); } - const listed: ListedEntry[] = []; + const candidates: Array & { absPath: string }> = []; for (const entry of entries) { if (!entry.isFile() && !entry.isDirectory()) continue; const relPath = resolved.data.relPath ? `${resolved.data.relPath}/${entry.name}` : entry.name; if (isExcludedPath(relPath)) continue; const childResolved = resolveInsideRoot(rootPath, relPath); if (!childResolved.success) return childResolved; - const devIno = await statDevIno(childResolved.data.absPath); - listed.push({ + candidates.push({ path: relPath, name: basenameFromRelPath(relPath), type: entry.isDirectory() ? 'directory' : 'file', - devIno, + absPath: childResolved.data.absPath, }); } + const devInos = await Promise.all(candidates.map((entry) => statDevIno(entry.absPath))); + const listed: ListedEntry[] = candidates.map((entry, index) => ({ + path: entry.path, + name: entry.name, + type: entry.type, + devIno: devInos[index], + })); + listed.sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; return a.name.localeCompare(b.name); From 81c31eecddc766a2574724e64faa2dd4b4e352dd Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:31:05 -0700 Subject: [PATCH 07/37] fix(file-tree): load restored expanded directories --- .../tasks/file-tree/tree-utils.test.ts | 49 +++++++++++++++++++ .../features/tasks/file-tree/tree-utils.ts | 16 ++++++ .../tasks/stores/workspace-view-model.tsx | 28 +++++++++++ 3 files changed, 93 insertions(+) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.test.ts index 8faf9c7cbd..f17a1bf93c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildVisibleRows, + expandedDirectoryPathsNeedingLoad, makeNode, sortFileNodes, toRenderableFileNode, @@ -236,4 +237,52 @@ describe('file tree utils', () => { ]); expect(rows[0].chain.map((node) => node.path)).toEqual(['src', 'src/components']); }); + + it('finds expanded visible directories that still need loading', () => { + const src = toRenderableFileNode({ + id: 1, + path: 'src', + name: 'src', + parentId: null, + type: 'directory', + childrenLoaded: false, + }); + const docs = toRenderableFileNode({ + id: 2, + path: 'docs', + name: 'docs', + parentId: null, + type: 'directory', + childrenLoaded: true, + }); + const pending = toRenderableFileNode({ + id: 3, + path: 'pending', + name: 'pending', + parentId: null, + type: 'directory', + childrenLoaded: false, + }); + const readme = toRenderableFileNode({ + id: 4, + path: 'README.md', + name: 'README.md', + parentId: null, + type: 'file', + childrenLoaded: false, + }); + const rows = buildVisibleRows( + [src, docs, pending, readme], + new Set(['src', 'docs', 'pending']) + ); + + expect( + expandedDirectoryPathsNeedingLoad( + rows, + new Set(['src', 'docs', 'pending']), + new Set(['', 'docs']), + new Set(['pending']) + ) + ).toEqual(['src']); + }); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts index 345755b8ba..935405eeac 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts @@ -145,3 +145,19 @@ export function buildVisibleRows( walk(rootNodes, 0); return rows; } + +export function expandedDirectoryPathsNeedingLoad( + rows: readonly TreeRow[], + expandedPaths: Set, + loadedPaths: Set, + pendingPaths: Set +): string[] { + const paths: string[] = []; + for (const row of rows) { + if (row.node.type !== 'directory') continue; + if (!isChainExpanded(row.chain, expandedPaths)) continue; + if (loadedPaths.has(row.node.path) || pendingPaths.has(row.node.path)) continue; + paths.push(row.node.path); + } + return paths; +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx index 4d4292bc69..9816389954 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx @@ -3,6 +3,10 @@ import { computed, makeAutoObservable, observable, reaction, runInAction } from import { DiffTabLifecycleStore } from '@renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store'; import { DiffViewStore } from '@renderer/features/tasks/diff-view/stores/diff-view-store'; import { FileModelLifecycleStore } from '@renderer/features/tasks/editor/stores/file-model-lifecycle-store'; +import { + buildVisibleRows, + expandedDirectoryPathsNeedingLoad, +} from '@renderer/features/tasks/file-tree/tree-utils'; import { PreviewServerStore } from '@renderer/features/tasks/stores/preview-server-store'; import { TabGroupManagerStore } from '@renderer/features/tasks/tabs/tab-group-manager-store'; import type { TabManagerStore } from '@renderer/features/tasks/tabs/tab-manager-store'; @@ -360,6 +364,30 @@ export class WorkspaceViewModel implements ILifecycle { { fireImmediately: true } ); this._sessionDisposers.push(closeEmptyTerminalDrawerDisposer); + + const loadExpandedDirectoriesDisposer = reaction( + () => { + const rows = buildVisibleRows( + workspace.files.rootNodes, + this.editorView.expandedPaths, + workspace.files.childrenById + ); + return expandedDirectoryPathsNeedingLoad( + rows, + this.editorView.expandedPaths, + workspace.files.loadedPaths, + workspace.files.pendingPaths + ).join('\0'); + }, + (key) => { + if (!key) return; + for (const path of key.split('\0')) { + void workspace.files.loadDir(path); + } + }, + { fireImmediately: true } + ); + this._sessionDisposers.push(loadExpandedDirectoriesDisposer); } /** From a274024592359483e3a1d971af81f6792e3039c3 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:19:48 -0700 Subject: [PATCH 08/37] fix(file-tree): serialize tree mutations --- packages/core/src/file-tree/file-tree.test.ts | 73 +++++++++++++++++++ packages/core/src/file-tree/file-tree.ts | 67 ++++++++++------- 2 files changed, 114 insertions(+), 26 deletions(-) diff --git a/packages/core/src/file-tree/file-tree.test.ts b/packages/core/src/file-tree/file-tree.test.ts index d42a224de4..1f9674bea3 100644 --- a/packages/core/src/file-tree/file-tree.test.ts +++ b/packages/core/src/file-tree/file-tree.test.ts @@ -237,6 +237,79 @@ describe('FileTree', () => { tree.dispose(); }); + it('serializes refreshes and watch event mutations', async () => { + const root = await makeRoot(); + await writeFile(path.join(root, 'a.txt'), 'x', 'utf8'); + const watcher = new ManualWatchService(); + const tree = new FileTree({ rootPath: root, watcher }); + unwrap(await tree.ready()); + + const patched = tree as unknown as { + refreshLoadedScopes(): Promise>; + applyWatchEvents(events: RawFileEvent[]): Promise; + }; + const originalRefreshLoadedScopes = patched.refreshLoadedScopes; + const originalApplyWatchEvents = patched.applyWatchEvents; + + let activeMutations = 0; + let maxActiveMutations = 0; + const enterMutation = () => { + activeMutations += 1; + maxActiveMutations = Math.max(maxActiveMutations, activeMutations); + }; + const exitMutation = () => { + activeMutations -= 1; + }; + + let releaseRefresh: () => void = () => {}; + const refreshEntered = new Promise((resolve) => { + patched.refreshLoadedScopes = async () => { + enterMutation(); + resolve(); + await new Promise((release) => { + releaseRefresh = release; + }); + try { + return await originalRefreshLoadedScopes.call(tree); + } finally { + exitMutation(); + } + }; + }); + + let resolveWatchApplied: () => void = () => {}; + const watchApplied = new Promise((resolve) => { + resolveWatchApplied = resolve; + }); + patched.applyWatchEvents = async (events) => { + enterMutation(); + try { + await originalApplyWatchEvents.call(tree, events); + } finally { + exitMutation(); + resolveWatchApplied(); + } + }; + + const refresh = tree.refresh(); + await refreshEntered; + + await writeFile(path.join(root, 'created.txt'), 'x', 'utf8'); + watcher.emit([{ kind: 'create', path: path.join(root, 'created.txt') }]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(maxActiveMutations).toBe(1); + + releaseRefresh(); + await refresh; + await watchApplied; + + expect(maxActiveMutations).toBe(1); + patched.refreshLoadedScopes = originalRefreshLoadedScopes; + patched.applyWatchEvents = originalApplyWatchEvents; + tree.dispose(); + }); + it('runtime leases share one tree per resolved root', async () => { const root = await makeRoot(); const watcher = new ManualWatchService(); diff --git a/packages/core/src/file-tree/file-tree.ts b/packages/core/src/file-tree/file-tree.ts index 31f8bb2229..b320b513b0 100644 --- a/packages/core/src/file-tree/file-tree.ts +++ b/packages/core/src/file-tree/file-tree.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; import type { IFileWatchService, RawFileEvent, WatchHandle } from '../fs'; -import { LiveCollection, type KeyedOp } from '../lib'; +import { KeyedMutex, LiveCollection, type KeyedOp } from '../lib'; import { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './errors'; import { isExcludedPath, watchIgnoreGlobs } from './ignores'; import { listChildren } from './list'; @@ -33,6 +33,7 @@ export class FileTree implements IFileTree { }); private readonly ids = new NodeIdAssigner(); private readonly onError: FileTreeOnError; + private readonly mutationMutex = new KeyedMutex(); private readonly revalidateTimer: ReturnType | null; private readonly watch: WatchHandle; private readonly scopeLoads = new Map< @@ -48,7 +49,7 @@ export class FileTree implements IFileTree { this.watch = options.watcher.watch( this.rootPath, (events) => { - void this.applyWatchEvents(events).catch((error) => + void this.runMutation(() => this.applyWatchEvents(events)).catch((error) => this.onError(`file-tree watch ${this.rootPath}`, error) ); }, @@ -56,7 +57,7 @@ export class FileTree implements IFileTree { debounceMs: WATCH_DEBOUNCE_MS, ignore: watchIgnoreGlobs(), onResync: () => { - void this.resync().catch((error) => + void this.runMutation(() => this.resync()).catch((error) => this.onError(`file-tree resync ${this.rootPath}`, error) ); }, @@ -67,7 +68,12 @@ export class FileTree implements IFileTree { interval > 0 ? setInterval(() => { if (this.collection.subscriberCount === 0) return; - void this.refreshLoadedScopes().then( + void this.runMutation(() => { + if (this.disposed || this.collection.subscriberCount === 0) { + return Promise.resolve(ok({})); + } + return this.refreshLoadedScopes(); + }).then( (result) => { if (!result.success) this.onError(`file-tree refresh ${this.rootPath}`, result.error); @@ -87,7 +93,7 @@ export class FileTree implements IFileTree { } catch (error) { return err(classifyFileTreeFsError(error, '')); } - const loaded = await this.loadDirectoryScope(null); + const loaded = await this.runMutation(() => this.loadDirectoryScope(null)); if (!loaded.success) return err(loaded.error); return ok(); })().catch((error): Result => { @@ -125,37 +131,41 @@ export class FileTree implements IFileTree { async expandDir(dirId: NodeId | null): Promise> { const ready = await this.ready(); if (!ready.success) return err(ready.error); - return this.loadDirectoryScope(dirId); + return this.runMutation(() => this.loadDirectoryScope(dirId)); } async revealPath(pathToReveal: string): Promise> { const ready = await this.ready(); if (!ready.success) return err(ready.error); - const normalized = resolveInsideRoot(this.rootPath, pathToReveal); - if (!normalized.success) return normalized; + return this.runMutation(async () => { + const normalized = resolveInsideRoot(this.rootPath, pathToReveal); + if (!normalized.success) return normalized; - const parts = normalized.data.relPath.split('/').filter(Boolean); - let sequences: FileTreeSequences = {}; - for (let index = 0; index < parts.length; index += 1) { - const relPath = parts.slice(0, index + 1).join('/'); - const node = this.ids.getByPath(relPath); - if (!node) return err({ type: 'not-found', path: relPath }); - const shouldExpand = index < parts.length - 1 || node.type === 'directory'; - if (!shouldExpand) continue; - if (node.type !== 'directory') { - return err({ type: 'not-directory', id: node.id, path: node.path }); + const parts = normalized.data.relPath.split('/').filter(Boolean); + let sequences: FileTreeSequences = {}; + for (let index = 0; index < parts.length; index += 1) { + const relPath = parts.slice(0, index + 1).join('/'); + const node = this.ids.getByPath(relPath); + if (!node) return err({ type: 'not-found', path: relPath }); + const shouldExpand = index < parts.length - 1 || node.type === 'directory'; + if (!shouldExpand) continue; + if (node.type !== 'directory') { + return err({ type: 'not-directory', id: node.id, path: node.path }); + } + const expanded = await this.loadDirectoryScope(node.id); + if (!expanded.success) return expanded; + sequences = mergeSequences(sequences, expanded.data); } - const expanded = await this.loadDirectoryScope(node.id); - if (!expanded.success) return expanded; - sequences = mergeSequences(sequences, expanded.data); - } - return ok(sequences); + return ok(sequences); + }); } async refresh(): Promise> { - const refreshed = await this.refreshLoadedScopes(); - if (!refreshed.success) return err(refreshed.error); - return ok(this.collection.getCached()); + return this.runMutation(async () => { + const refreshed = await this.refreshLoadedScopes(); + if (!refreshed.success) return err(refreshed.error); + return ok(this.collection.getCached()); + }); } private async refreshLoadedScopes(): Promise> { @@ -281,6 +291,7 @@ export class FileTree implements IFileTree { } private async resync(): Promise { + if (this.disposed) return; const refreshed = await this.refreshLoadedScopes(); if (!refreshed.success) { this.onError(`file-tree resync ${this.rootPath}`, refreshed.error); @@ -290,6 +301,10 @@ export class FileTree implements IFileTree { this.ids.entries().map((node) => [node.id, node] as const) ); } + + private runMutation(fn: () => Promise): Promise { + return this.mutationMutex.runExclusive('tree', fn); + } } function mergeSequences(left: FileTreeSequences, right: FileTreeSequences): FileTreeSequences { From ed2ad962b355e55f9b3759eede6d46885a6ba869 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:19:58 -0700 Subject: [PATCH 09/37] fix(file-tree): ignore symlinks consistently --- packages/core/src/file-tree/file-tree.test.ts | 53 ++++++++++++++++++- packages/core/src/file-tree/list.ts | 6 +-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/core/src/file-tree/file-tree.test.ts b/packages/core/src/file-tree/file-tree.test.ts index 1f9674bea3..dd92aa8196 100644 --- a/packages/core/src/file-tree/file-tree.test.ts +++ b/packages/core/src/file-tree/file-tree.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { Result } from '@emdash/shared'; @@ -93,6 +93,43 @@ describe('FileTree', () => { tree.dispose(); }); + it('ignores symlinks consistently in snapshots and watch-created entries', async () => { + const root = await makeRoot(); + await writeFile(path.join(root, 'target.txt'), 'x', 'utf8'); + const symlinkSupported = await trySymlink('target.txt', path.join(root, 'link.txt')); + if (!symlinkSupported) return; + + const watcher = new ManualWatchService(); + const tree = new FileTree({ rootPath: root, watcher }); + unwrap(await tree.ready()); + + expect(paths(await nodes(tree))).toEqual(['target.txt']); + + const patched = tree as unknown as { + applyWatchEvents(events: RawFileEvent[]): Promise; + }; + const originalApplyWatchEvents = patched.applyWatchEvents; + let resolveApplied: () => void = () => {}; + const applied = new Promise((resolve) => { + resolveApplied = resolve; + }); + patched.applyWatchEvents = async (events) => { + try { + await originalApplyWatchEvents.call(tree, events); + } finally { + resolveApplied(); + } + }; + + await symlink('target.txt', path.join(root, 'watch-link.txt'), 'file'); + watcher.emit([{ kind: 'create', path: path.join(root, 'watch-link.txt') }]); + await applied; + + expect(paths(await nodes(tree))).toEqual(['target.txt']); + patched.applyWatchEvents = originalApplyWatchEvents; + tree.dispose(); + }); + it('reveals a nested path by loading each parent scope', async () => { const root = await makeRoot(); await mkdir(path.join(root, 'src', 'a'), { recursive: true }); @@ -398,6 +435,20 @@ async function waitFor(check: () => Promise, timeoutMs = 500): Promise< throw new Error('Timed out waiting for file tree condition'); } +async function trySymlink(target: string, linkPath: string): Promise { + try { + await symlink(target, linkPath, 'file'); + return true; + } catch (error) { + const code = + typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code === 'EPERM' || code === 'EACCES') return false; + throw error; + } +} + function unwrap(result: Result): T { if (!result.success) throw new Error(`Expected ok result: ${JSON.stringify(result.error)}`); return result.data; diff --git a/packages/core/src/file-tree/list.ts b/packages/core/src/file-tree/list.ts index 4be538899b..5a04afde4f 100644 --- a/packages/core/src/file-tree/list.ts +++ b/packages/core/src/file-tree/list.ts @@ -1,4 +1,4 @@ -import { readdir, stat } from 'node:fs/promises'; +import { lstat, readdir } from 'node:fs/promises'; import { err, ok, type Result } from '@emdash/shared'; import { classifyFileTreeFsError, type FileTreeError } from './errors'; import { isExcludedPath } from './ignores'; @@ -65,7 +65,7 @@ export async function statEntry( const resolved = resolveInsideRoot(rootPath, relPath); if (!resolved.success) return resolved; try { - const stats = await stat(resolved.data.absPath); + const stats = await lstat(resolved.data.absPath); if (!stats.isFile() && !stats.isDirectory()) { return err({ type: 'not-found', path: relPath }); } @@ -82,7 +82,7 @@ export async function statEntry( async function statDevIno(absPath: string): Promise { try { - const stats = await stat(absPath); + const stats = await lstat(absPath); return toDevIno(stats.dev, stats.ino); } catch { return undefined; From ba5bee1d5fcf84a34d33900e77c73824896063e1 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:22:06 -0700 Subject: [PATCH 10/37] refactor(file-tree): centralize ignore and path safety --- .../src/main/core/fs/impl/local-fs.test.ts | 13 +- .../src/main/core/fs/impl/local-fs.ts | 112 +++++------------- .../main/core/runtime/legacy/ssh-file-tree.ts | 104 +++------------- .../search/workspace-file-index-service.ts | 37 +----- packages/core/src/file-tree/file-tree.ts | 4 +- packages/core/src/file-tree/ignores.ts | 16 ++- packages/core/src/file-tree/index.test.ts | 7 +- packages/core/src/file-tree/index.ts | 2 + packages/core/src/file-tree/list.ts | 4 +- .../core/src/file-tree/watch/classifier.ts | 4 +- 10 files changed, 78 insertions(+), 225 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts index 1f6709bf4f..1d687bf609 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts @@ -480,14 +480,21 @@ describe('LocalFileSystem', () => { await expect(fsService.read('subdir/../../../etc/passwd')).rejects.toThrow(); }); - it('should normalize paths with double slashes', async () => { - fs.writeFileSync(path.join(tempDir, 'test.txt'), 'content'); + it('should normalize repeated separators inside relative paths', async () => { + fs.mkdirSync(path.join(tempDir, 'nested')); + fs.writeFileSync(path.join(tempDir, 'nested/test.txt'), 'content'); - const result = await fsService.read('//test.txt'); + const result = await fsService.read('nested//test.txt'); expect(result.content).toBe('content'); }); + it('should block leading double slash paths as absolute paths', async () => { + fs.writeFileSync(path.join(tempDir, 'test.txt'), 'content'); + + await expect(fsService.read('//test.txt')).rejects.toThrow('Absolute paths are not allowed'); + }); + it('should allow valid subpaths', async () => { fs.mkdirSync(path.join(tempDir, 'valid')); fs.mkdirSync(path.join(tempDir, 'valid/nested')); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts index 1e1200f407..df3b90e7d1 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts @@ -1,6 +1,7 @@ import { createReadStream, promises as fs, statSync, type Stats } from 'node:fs'; -import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, extname, join, relative, resolve } from 'node:path'; import { createInterface } from 'node:readline'; +import { isIgnored, resolveInsideRoot, watchIgnoreGlobs } from '@emdash/core/file-tree'; import parcelWatcher from '@parcel/watcher'; import { glob } from 'glob'; import ignore from 'ignore'; @@ -68,54 +69,6 @@ const BINARY_EXTENSIONS = new Set([ '.a', ]); -// Directories to skip during search -const SEARCH_IGNORES = new Set([ - 'node_modules', - '.git', - '.svn', - '.hg', - 'dist', - 'build', - '.next', - '.nuxt', - 'coverage', - '.cache', - '.parcel-cache', -]); - -const WATCH_IGNORED_NAMES = [ - '.svn', - '.hg', - '.git', - 'node_modules', - 'dist', - 'build', - '.next', - '.nuxt', - 'coverage', - '__pycache__', - '.pytest_cache', - 'venv', - '.venv', - 'target', - '.terraform', - '.serverless', - 'worktrees', - '.emdash', - '.conductor', - '.cursor', - '.claude', - '.amp', - '.codex', - '.aider', - '.continue', - '.cody', - '.windsurf', -]; - -// Glob patterns for parcel/watcher ignore option, derived from WATCH_IGNORED_NAMES. -const WATCH_IGNORE_GLOBS = WATCH_IGNORED_NAMES.map((n) => `**/${n}/**`); - // Allowed image extensions for readImage const ALLOWED_IMAGE_EXTENSIONS = new Set([ '.png', @@ -167,29 +120,14 @@ export class LocalFileSystem implements FileSystemProvider { } } - /** - * Resolve and validate a relative path, ensuring it doesn't escape the project root - */ - private resolvePath(relPath: string): string { - // Normalize the path and resolve it against project root - const normalizedRelPath = relPath.replace(/\\/g, '/').replace(/^\//, ''); - const fullPath = resolve(join(this.projectPath, normalizedRelPath)); - - // Security: ensure path is within projectPath (handle trailing separator edge cases) - const projectPathWithSep = this.projectPath.endsWith(sep) - ? this.projectPath - : this.projectPath + sep; - const fullPathWithSep = fullPath.endsWith(sep) ? fullPath : fullPath + sep; - - if (!fullPathWithSep.startsWith(projectPathWithSep) && fullPath !== this.projectPath) { - throw new FileSystemError( - `Path traversal detected: ${relPath}`, - FileSystemErrorCodes.PATH_ESCAPE, - relPath - ); + private resolveWorkspacePath(relPath: string, options: { allowEmpty?: boolean } = {}): string { + const resolved = resolveInsideRoot(this.projectPath, relPath, options); + if (!resolved.success) { + const message = + resolved.error.type === 'invalid-path' ? resolved.error.message : 'Invalid file path'; + throw new FileSystemError(message, FileSystemErrorCodes.INVALID_PATH, relPath); } - - return fullPath; + return resolved.data.absPath; } /** @@ -203,7 +141,7 @@ export class LocalFileSystem implements FileSystemProvider { * Check if a path should be ignored during search */ private shouldIgnore(name: string): boolean { - return SEARCH_IGNORES.has(name); + return isIgnored(name); } /** @@ -231,7 +169,7 @@ export class LocalFileSystem implements FileSystemProvider { async list(path: string = '', options: ListOptions = {}): Promise { const startTime = Date.now(); - const fullPath = this.resolvePath(path); + const fullPath = this.resolveWorkspacePath(path, { allowEmpty: true }); const entries: FileEntry[] = []; const maxEntries = options.maxEntries || 10000; const timeBudgetMs = options.timeBudgetMs || 30000; @@ -334,7 +272,7 @@ export class LocalFileSystem implements FileSystemProvider { } async read(path: string, maxBytes: number = 200 * 1024): Promise { - const fullPath = this.resolvePath(path); + const fullPath = this.resolveWorkspacePath(path); let stat; try { @@ -378,7 +316,7 @@ export class LocalFileSystem implements FileSystemProvider { } async write(path: string, content: string): Promise { - const fullPath = this.resolvePath(path); + const fullPath = this.resolveWorkspacePath(path); // Ensure directory exists const dir = dirname(fullPath); @@ -413,7 +351,7 @@ export class LocalFileSystem implements FileSystemProvider { async exists(path: string): Promise { try { - await fs.access(this.resolvePath(path)); + await fs.access(this.resolveWorkspacePath(path)); return true; } catch { return false; @@ -422,7 +360,7 @@ export class LocalFileSystem implements FileSystemProvider { async stat(path: string): Promise { try { - const fullPath = this.resolvePath(path); + const fullPath = this.resolveWorkspacePath(path); const stat = await fs.stat(fullPath); return this.statToEntry(fullPath, stat); } catch { @@ -563,7 +501,7 @@ export class LocalFileSystem implements FileSystemProvider { path: string, options?: { recursive?: boolean } ): Promise<{ success: boolean; error?: string }> { - const fullPath = this.resolvePath(path); + const fullPath = this.resolveWorkspacePath(path); let stat; try { @@ -672,7 +610,7 @@ export class LocalFileSystem implements FileSystemProvider { size?: number; error?: string; }> { - const fullPath = this.resolvePath(path); + const fullPath = this.resolveWorkspacePath(path); // Check file extension const ext = extname(path).toLowerCase(); @@ -721,24 +659,28 @@ export class LocalFileSystem implements FileSystemProvider { } async mkdir(dirPath: string, options?: { recursive?: boolean }): Promise { - await fs.mkdir(this.resolvePath(dirPath), { recursive: options?.recursive ?? false }); + await fs.mkdir(this.resolveWorkspacePath(dirPath, { allowEmpty: true }), { + recursive: options?.recursive ?? false, + }); } async realPath(path: string): Promise { - return fs.realpath(this.resolvePath(path)); + return fs.realpath(this.resolveWorkspacePath(path, { allowEmpty: true })); } async glob(pattern: string, options?: { cwd?: string; dot?: boolean }): Promise { - const cwd = options?.cwd ? this.resolvePath(options.cwd) : this.projectPath; + const cwd = options?.cwd + ? this.resolveWorkspacePath(options.cwd, { allowEmpty: true }) + : this.projectPath; return glob(pattern, { cwd, dot: options?.dot ?? false, absolute: false }); } async copyFile(src: string, dest: string): Promise { - await fs.copyFile(this.resolvePath(src), this.resolvePath(dest)); + await fs.copyFile(this.resolveWorkspacePath(src), this.resolveWorkspacePath(dest)); } async copyLocalFile(localAbsPath: string, destRelPath: string): Promise { - await fs.copyFile(localAbsPath, this.resolvePath(destRelPath)); + await fs.copyFile(localAbsPath, this.resolveWorkspacePath(destRelPath)); } watch( @@ -789,7 +731,7 @@ export class LocalFileSystem implements FileSystemProvider { enqueue({ type, entryType, path: rel }); } }, - { ignore: WATCH_IGNORE_GLOBS } + { ignore: watchIgnoreGlobs() } ) .then((sub) => { if (closed) { diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts index 51c8f2bd0d..59e33964f2 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts @@ -1,15 +1,17 @@ import path from 'node:path'; -import type { - FileNode, - FileTreeError, - FileTreeLease, - FileTreeSequences, - FileTreeSnapshot, - FileTreeUpdate, - IFileTree, - IFileTreeRuntime, - NodeId, - SubscribedSnapshot, +import { + isIgnored, + normalizeRelPath, + type FileNode, + type FileTreeError, + type FileTreeLease, + type FileTreeSequences, + type FileTreeSnapshot, + type FileTreeUpdate, + type IFileTree, + type IFileTreeRuntime, + type NodeId, + type SubscribedSnapshot, } from '@emdash/core/file-tree'; import { LiveCollection, ResourceMap, type KeyedOp } from '@emdash/core/lib'; import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; @@ -20,46 +22,6 @@ import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import { log } from '@main/lib/logger'; const SSH_FILE_TREE_POLL_MS = 4_000; -const SSH_FILE_TREE_EXCLUDED_NAMES = new Set([ - '.git', - 'dist', - 'build', - '.next', - 'out', - '.turbo', - 'coverage', - '.nyc_output', - '.cache', - 'tmp', - 'temp', - '.DS_Store', - 'Thumbs.db', - '.vscode-test', - '.idea', - '__pycache__', - '.pytest_cache', - 'venv', - '.venv', - 'target', - '.terraform', - '.serverless', - '.checkouts', - 'checkouts', - '.conductor', - '.cursor', - '.claude', - '.devin', - '.amp', - '.codex', - '.aider', - '.continue', - '.cody', - '.windsurf', - 'worktrees', - '.worktrees', - '.emdash', - 'node_modules', -]); type LegacyListedEntry = { path: string; @@ -205,7 +167,7 @@ class LegacySshFileTree implements IFileTree { async revealPath(pathToReveal: string): Promise> { const ready = await this.ready(); if (!ready.success) return err(ready.error); - const normalized = normalizeRemoteRelPath(pathToReveal); + const normalized = normalizeRelPath(pathToReveal); if (!normalized.success) return normalized; const parts = normalized.data.split('/').filter(Boolean); @@ -304,7 +266,7 @@ class LegacySshFileTree implements IFileTree { } private async listChildren(dirPath: string): Promise> { - const normalized = normalizeRemoteRelPath(dirPath, { allowEmpty: true }); + const normalized = normalizeRelPath(dirPath, { allowEmpty: true }); if (!normalized.success) return normalized; try { @@ -312,7 +274,7 @@ class LegacySshFileTree implements IFileTree { const entries: LegacyListedEntry[] = []; for (const entry of result.entries) { const relPath = entry.path.replace(/\\/g, '/'); - if (isLegacySshExcludedPath(relPath)) continue; + if (isIgnored(relPath)) continue; if (entry.type !== 'dir' && entry.type !== 'file') continue; entries.push(toListedEntry(entry)); } @@ -479,40 +441,6 @@ function normalizeRemoteRootPath(rootPath: string): string { return path.posix.isAbsolute(normalized) ? normalized : path.posix.resolve('/', normalized); } -function normalizeRemoteRelPath( - input: string, - options: { allowEmpty?: boolean } = {} -): Result { - if (input.includes('\0')) { - return err({ type: 'invalid-path', path: input, message: 'Path contains a null byte' }); - } - if (path.posix.isAbsolute(input) || path.win32.isAbsolute(input)) { - return err({ type: 'invalid-path', path: input, message: 'Absolute paths are not allowed' }); - } - - const parts = input - .replace(/\\/g, '/') - .split('/') - .filter((part) => part.length > 0 && part !== '.'); - if (parts.includes('..')) { - return err({ - type: 'invalid-path', - path: input, - message: 'Parent path segments are not allowed', - }); - } - - const normalized = parts.join('/'); - if (!normalized && !options.allowEmpty) { - return err({ type: 'invalid-path', path: input, message: 'Path must not be empty' }); - } - return ok(normalized); -} - -function isLegacySshExcludedPath(relPath: string): boolean { - return relPath.split('/').some((segment) => SSH_FILE_TREE_EXCLUDED_NAMES.has(segment)); -} - function mergeSequences(left: FileTreeSequences, right: FileTreeSequences): FileTreeSequences { return { tree: Math.max(left.tree ?? 0, right.tree ?? 0) || undefined }; } diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts index 6c4683405e..724d8de129 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts @@ -1,4 +1,5 @@ import { basename } from 'node:path'; +import { isIgnored } from '@emdash/core/file-tree'; import { fsEvents } from '@main/core/fs/fs-events'; import type { Workspace } from '@main/core/workspaces/workspace'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; @@ -10,38 +11,6 @@ const MAX_FILES = 50_000; const CRAWL_TIMEOUT_MS = 30_000; const REINDEX_DEBOUNCE_MS = 3_000; -const CRAWL_IGNORED_DIRS = new Set([ - 'node_modules', - '.git', - '.svn', - '.hg', - 'dist', - 'build', - '.next', - '.nuxt', - 'coverage', - '.cache', - '.parcel-cache', - '__pycache__', - '.pytest_cache', - 'venv', - '.venv', - 'target', - '.terraform', - '.serverless', - 'worktrees', - '.emdash', - '.conductor', - '.cursor', - '.claude', - '.amp', - '.codex', - '.aider', - '.continue', - '.cody', - '.windsurf', -]); - type FileHit = { path: string; filename: string }; class WorkspaceFileIndexService { @@ -126,9 +95,7 @@ class WorkspaceFileIndexService { timeBudgetMs: CRAWL_TIMEOUT_MS, }); - const files = result.entries.filter( - (e) => e.type === 'file' && !e.path.split('/').some((seg) => CRAWL_IGNORED_DIRS.has(seg)) - ); + const files = result.entries.filter((e) => e.type === 'file' && !isIgnored(e.path)); sqlite.transaction(() => { sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`).run(workspaceId); diff --git a/packages/core/src/file-tree/file-tree.ts b/packages/core/src/file-tree/file-tree.ts index b320b513b0..6ccb6aafe2 100644 --- a/packages/core/src/file-tree/file-tree.ts +++ b/packages/core/src/file-tree/file-tree.ts @@ -3,7 +3,7 @@ import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; import type { IFileWatchService, RawFileEvent, WatchHandle } from '../fs'; import { KeyedMutex, LiveCollection, type KeyedOp } from '../lib'; import { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './errors'; -import { isExcludedPath, watchIgnoreGlobs } from './ignores'; +import { isIgnored, watchIgnoreGlobs } from './ignores'; import { listChildren } from './list'; import type { FileNode, NodeId } from './models/tree'; import { NodeIdAssigner } from './node-id'; @@ -220,7 +220,7 @@ export class FileTree implements IFileTree { const listed = await listChildren(this.rootPath, dirPath); if (!listed.success) return listed; - const listedEntries = listed.data.filter((entry) => !isExcludedPath(entry.path)); + const listedEntries = listed.data.filter((entry) => !isIgnored(entry.path)); const listedPaths = new Set(listedEntries.map((entry) => entry.path)); let sequence = this.removeMissingChildren(scope, listedPaths); diff --git a/packages/core/src/file-tree/ignores.ts b/packages/core/src/file-tree/ignores.ts index 4eb6769106..d964fa4cdd 100644 --- a/packages/core/src/file-tree/ignores.ts +++ b/packages/core/src/file-tree/ignores.ts @@ -1,13 +1,17 @@ -const EXCLUDED_NAMES = new Set([ +export const IGNORED_PATH_SEGMENTS = [ '.git', + '.svn', + '.hg', 'dist', 'build', '.next', + '.nuxt', 'out', '.turbo', 'coverage', '.nyc_output', '.cache', + '.parcel-cache', 'tmp', 'temp', '.DS_Store', @@ -37,13 +41,15 @@ const EXCLUDED_NAMES = new Set([ '.worktrees', '.emdash', 'node_modules', -]); +] as const; -export function isExcludedPath(relPath: string): boolean { +const IGNORED_PATH_SEGMENT_SET = new Set(IGNORED_PATH_SEGMENTS); + +export function isIgnored(relPath: string): boolean { if (!relPath) return false; - return relPath.split('/').some((segment) => EXCLUDED_NAMES.has(segment)); + return relPath.split('/').some((segment) => IGNORED_PATH_SEGMENT_SET.has(segment)); } export function watchIgnoreGlobs(): string[] { - return [...EXCLUDED_NAMES].flatMap((name) => [`**/${name}`, `**/${name}/**`]); + return IGNORED_PATH_SEGMENTS.flatMap((name) => [`**/${name}`, `**/${name}/**`]); } diff --git a/packages/core/src/file-tree/index.test.ts b/packages/core/src/file-tree/index.test.ts index 74e4030f07..65fe8a073a 100644 --- a/packages/core/src/file-tree/index.test.ts +++ b/packages/core/src/file-tree/index.test.ts @@ -2,15 +2,16 @@ import { describe, expect, it } from 'vitest'; import * as fileTree from './index'; describe('@emdash/core/file-tree public exports', () => { - it('exports the runtime but not concrete tree or internal helpers', () => { + it('exports the runtime and shared file-domain foundation but not concrete tree internals', () => { const exported = fileTree as Record; expect(exported.FileTreeRuntime).toBeTypeOf('function'); + expect(exported.isIgnored).toBeTypeOf('function'); + expect(exported.watchIgnoreGlobs).toBeTypeOf('function'); + expect(exported.resolveInsideRoot).toBeTypeOf('function'); expect(exported.FileTree).toBeUndefined(); expect(exported.NodeIdAssigner).toBeUndefined(); expect(exported.listChildren).toBeUndefined(); expect(exported.statEntry).toBeUndefined(); - expect(exported.resolveInsideRoot).toBeUndefined(); - expect(exported.watchIgnoreGlobs).toBeUndefined(); }); }); diff --git a/packages/core/src/file-tree/index.ts b/packages/core/src/file-tree/index.ts index b30ce09619..8d26ad3db8 100644 --- a/packages/core/src/file-tree/index.ts +++ b/packages/core/src/file-tree/index.ts @@ -1,5 +1,7 @@ export { FileTreeRuntime, type FileTreeRuntimeOptions } from './file-tree-runtime'; export { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './errors'; +export { IGNORED_PATH_SEGMENTS, isIgnored, watchIgnoreGlobs } from './ignores'; +export { normalizeRelPath, resolveInsideRoot, type RelPath, type ResolvedPath } from './paths'; export type { FileNode, FileNodeType, FileTreeScope, NodeId } from './models/tree'; export type { FileTreeLease, diff --git a/packages/core/src/file-tree/list.ts b/packages/core/src/file-tree/list.ts index 5a04afde4f..c68bc56b7f 100644 --- a/packages/core/src/file-tree/list.ts +++ b/packages/core/src/file-tree/list.ts @@ -1,7 +1,7 @@ import { lstat, readdir } from 'node:fs/promises'; import { err, ok, type Result } from '@emdash/shared'; import { classifyFileTreeFsError, type FileTreeError } from './errors'; -import { isExcludedPath } from './ignores'; +import { isIgnored } from './ignores'; import type { FileNodeType } from './models/tree'; import { basenameFromRelPath, resolveInsideRoot } from './paths'; @@ -32,7 +32,7 @@ export async function listChildren( for (const entry of entries) { if (!entry.isFile() && !entry.isDirectory()) continue; const relPath = resolved.data.relPath ? `${resolved.data.relPath}/${entry.name}` : entry.name; - if (isExcludedPath(relPath)) continue; + if (isIgnored(relPath)) continue; const childResolved = resolveInsideRoot(rootPath, relPath); if (!childResolved.success) return childResolved; candidates.push({ diff --git a/packages/core/src/file-tree/watch/classifier.ts b/packages/core/src/file-tree/watch/classifier.ts index 4ca1bbedca..1429a0f74d 100644 --- a/packages/core/src/file-tree/watch/classifier.ts +++ b/packages/core/src/file-tree/watch/classifier.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import type { RawFileEvent } from '../../fs'; import type { KeyedOp } from '../../lib'; -import { isExcludedPath } from '../ignores'; +import { isIgnored } from '../ignores'; import { statEntry as statFileTreeEntry, type ListedEntry } from '../list'; import type { FileNode, NodeId } from '../models/tree'; import type { NodeIdAssigner, Tombstone } from '../node-id'; @@ -29,7 +29,7 @@ export async function classifyFileTreeWatchEvents( for (const event of events) { const relPath = relPathFromWatchEvent(options.rootPath, event); if (!relPath) continue; - if (isExcludedPath(relPath)) continue; + if (isIgnored(relPath)) continue; if (event.kind === 'update') continue; if (event.kind === 'delete') { From 45ffa265221f1c12a6a8cdbfb45b1b12adbc59d9 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:22:18 -0700 Subject: [PATCH 11/37] fix(markdown): resolve root-anchored workspace images --- .../lib/editor/markdown-image-path.test.ts | 24 +++++++++++++++++ .../lib/editor/markdown-image-path.ts | 26 +++++++++++++++++++ .../renderer/lib/editor/markdown-renderer.tsx | 7 ++--- 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 apps/emdash-desktop/src/renderer/lib/editor/markdown-image-path.test.ts create mode 100644 apps/emdash-desktop/src/renderer/lib/editor/markdown-image-path.ts diff --git a/apps/emdash-desktop/src/renderer/lib/editor/markdown-image-path.test.ts b/apps/emdash-desktop/src/renderer/lib/editor/markdown-image-path.test.ts new file mode 100644 index 0000000000..74ac759159 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/lib/editor/markdown-image-path.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { resolveMarkdownImagePath } from './markdown-image-path'; + +describe('resolveMarkdownImagePath', () => { + it('resolves relative image paths from the markdown file directory', () => { + expect(resolveMarkdownImagePath('docs/readme.md', 'images/logo.png')).toBe( + 'docs/images/logo.png' + ); + }); + + it('resolves root-anchored image paths from the workspace root', () => { + expect(resolveMarkdownImagePath('docs/readme.md', '/assets/logo.png')).toBe('assets/logo.png'); + }); + + it('rejects paths that escape the workspace root', () => { + expect(resolveMarkdownImagePath('readme.md', '../logo.png')).toBeNull(); + }); + + it('ignores external or special image sources', () => { + expect(resolveMarkdownImagePath('readme.md', 'https://example.com/logo.png')).toBeNull(); + expect(resolveMarkdownImagePath('readme.md', '//cdn.example.com/logo.png')).toBeNull(); + expect(resolveMarkdownImagePath('readme.md', '#local-anchor')).toBeNull(); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/lib/editor/markdown-image-path.ts b/apps/emdash-desktop/src/renderer/lib/editor/markdown-image-path.ts new file mode 100644 index 0000000000..dfa80166e8 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/lib/editor/markdown-image-path.ts @@ -0,0 +1,26 @@ +export function resolveMarkdownImagePath(markdownFilePath: string, src: string): string | null { + const cleanSrc = src.trim().replace(/\\/g, '/').split('#')[0]?.split('?')[0] ?? ''; + if (!cleanSrc) return null; + if (/^[a-z][a-z0-9+.-]*:/i.test(cleanSrc)) return null; + if (cleanSrc.startsWith('//') || cleanSrc.startsWith('#')) return null; + + const fileDir = markdownFilePath.includes('/') + ? markdownFilePath.substring(0, markdownFilePath.lastIndexOf('/')) + : ''; + const parts = cleanSrc.startsWith('/') + ? cleanSrc.slice(1).split('/') + : [...(fileDir ? fileDir.split('/') : []), ...cleanSrc.split('/')]; + + const normalized: string[] = []; + for (const part of parts) { + if (!part || part === '.') continue; + if (part === '..') { + if (normalized.length === 0) return null; + normalized.pop(); + continue; + } + normalized.push(part); + } + + return normalized.length > 0 ? normalized.join('/') : null; +} diff --git a/apps/emdash-desktop/src/renderer/lib/editor/markdown-renderer.tsx b/apps/emdash-desktop/src/renderer/lib/editor/markdown-renderer.tsx index 2b51ea0911..6a26e61556 100644 --- a/apps/emdash-desktop/src/renderer/lib/editor/markdown-renderer.tsx +++ b/apps/emdash-desktop/src/renderer/lib/editor/markdown-renderer.tsx @@ -14,6 +14,7 @@ import { buildMonacoModelPath } from '@renderer/lib/monaco/monacoModelPath'; import { MarkdownRenderer } from '@renderer/lib/ui/markdown-renderer'; import { Spinner } from '@renderer/lib/ui/spinner'; import { ToggleGroup, ToggleGroupItem } from '@renderer/lib/ui/toggle-group'; +import { resolveMarkdownImagePath } from './markdown-image-path'; interface MarkdownEditorRendererProps { filePath: string; @@ -40,15 +41,15 @@ export const MarkdownEditorRenderer = observer(function MarkdownEditorRenderer({ const _version = bufferUri ? modelRegistry.bufferVersions.get(bufferUri) : undefined; const content = tab?.isExternal ? tab.content : (modelRegistry.getValue(bufferUri) ?? ''); - const fileDir = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : ''; const resolveImage = useCallback( async (src: string): Promise => { - const imagePath = fileDir ? `${fileDir}/${src}` : src; + const imagePath = resolveMarkdownImagePath(filePath, src); + if (!imagePath) return null; const result = await rpc.workspace.fs.readImage(projectId, workspaceId, imagePath); return result.success ? (result.data?.dataUrl ?? null) : null; }, - [projectId, workspaceId, fileDir] + [projectId, workspaceId, filePath] ); return ( From af3f1a07ada1f7ee6fe4e42abb3303e5231dd534 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:14:27 -0700 Subject: [PATCH 12/37] refactor(core): introduce files and watch domains --- packages/core/package.json | 12 +- .../core/src/file-tree/file-tree-runtime.ts | 66 -------- packages/core/src/file-tree/index.test.ts | 17 -- packages/core/src/file-tree/index.ts | 14 -- .../core/src/files/changes/changes.test.ts | 148 ++++++++++++++++++ packages/core/src/files/changes/changes.ts | 138 ++++++++++++++++ packages/core/src/files/changes/types.ts | 37 +++++ packages/core/src/files/errors.ts | 9 ++ packages/core/src/files/files-runtime.test.ts | 65 ++++++++ packages/core/src/files/files-runtime.ts | 110 +++++++++++++ .../core/src/{file-tree => files}/ignores.ts | 0 packages/core/src/files/index.test.ts | 14 ++ packages/core/src/files/index.ts | 15 ++ .../core/src/{file-tree => files}/paths.ts | 23 ++- .../src/{file-tree => files/tree}/errors.ts | 0 .../tree}/file-tree.test.ts | 32 ++-- .../{file-tree => files/tree}/file-tree.ts | 44 +++--- .../src/{file-tree => files/tree}/list.ts | 4 +- .../{file-tree => files/tree}/models/tree.ts | 0 .../src/{file-tree => files/tree}/node-id.ts | 0 .../src/{file-tree => files/tree}/types.ts | 7 +- .../tree}/watch/classifier.test.ts | 4 +- .../tree}/watch/classifier.ts | 12 +- packages/core/src/files/types.ts | 38 +++++ packages/core/src/fs/index.ts | 9 -- packages/core/src/git/errors.ts | 9 ++ packages/core/src/git/git-repository.test.ts | 6 +- packages/core/src/git/git-repository.ts | 6 +- packages/core/src/git/git-runtime.test.ts | 6 +- packages/core/src/git/git-runtime.ts | 23 ++- packages/core/src/git/git-worktree.test.ts | 16 +- packages/core/src/git/git-worktree.ts | 4 +- packages/core/src/git/watch/classifier.ts | 4 +- packages/core/src/watch/index.ts | 3 + .../core/src/{fs => watch}/native-watch.ts | 10 +- packages/core/src/{fs => watch}/paths.ts | 0 packages/core/src/{fs => watch}/types.ts | 14 +- .../watch-service.test.ts} | 18 +-- .../watch-service.ts} | 22 +-- packages/core/tsdown.config.ts | 4 +- 40 files changed, 725 insertions(+), 238 deletions(-) delete mode 100644 packages/core/src/file-tree/file-tree-runtime.ts delete mode 100644 packages/core/src/file-tree/index.test.ts delete mode 100644 packages/core/src/file-tree/index.ts create mode 100644 packages/core/src/files/changes/changes.test.ts create mode 100644 packages/core/src/files/changes/changes.ts create mode 100644 packages/core/src/files/changes/types.ts create mode 100644 packages/core/src/files/errors.ts create mode 100644 packages/core/src/files/files-runtime.test.ts create mode 100644 packages/core/src/files/files-runtime.ts rename packages/core/src/{file-tree => files}/ignores.ts (100%) create mode 100644 packages/core/src/files/index.test.ts create mode 100644 packages/core/src/files/index.ts rename packages/core/src/{file-tree => files}/paths.ts (75%) rename packages/core/src/{file-tree => files/tree}/errors.ts (100%) rename packages/core/src/{file-tree => files/tree}/file-tree.test.ts (94%) rename packages/core/src/{file-tree => files/tree}/file-tree.ts (96%) rename packages/core/src/{file-tree => files/tree}/list.ts (96%) rename packages/core/src/{file-tree => files/tree}/models/tree.ts (100%) rename packages/core/src/{file-tree => files/tree}/node-id.ts (100%) rename packages/core/src/{file-tree => files/tree}/types.ts (84%) rename packages/core/src/{file-tree => files/tree}/watch/classifier.test.ts (99%) rename packages/core/src/{file-tree => files/tree}/watch/classifier.ts (90%) create mode 100644 packages/core/src/files/types.ts delete mode 100644 packages/core/src/fs/index.ts create mode 100644 packages/core/src/watch/index.ts rename packages/core/src/{fs => watch}/native-watch.ts (91%) rename packages/core/src/{fs => watch}/paths.ts (100%) rename packages/core/src/{fs => watch}/types.ts (74%) rename packages/core/src/{fs/file-watch-service.test.ts => watch/watch-service.test.ts} (91%) rename packages/core/src/{fs/file-watch-service.ts => watch/watch-service.ts} (86%) diff --git a/packages/core/package.json b/packages/core/package.json index c8228209e5..6a0cf9924d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,13 +25,13 @@ "types": "./dist/exec.d.mts", "default": "./dist/exec.mjs" }, - "./file-tree": { - "types": "./dist/file-tree.d.mts", - "default": "./dist/file-tree.mjs" + "./files": { + "types": "./dist/files.d.mts", + "default": "./dist/files.mjs" }, - "./fs": { - "types": "./dist/fs.d.mts", - "default": "./dist/fs.mjs" + "./watch": { + "types": "./dist/watch.d.mts", + "default": "./dist/watch.mjs" }, "./git": { "types": "./dist/git.d.mts", diff --git a/packages/core/src/file-tree/file-tree-runtime.ts b/packages/core/src/file-tree/file-tree-runtime.ts deleted file mode 100644 index 0803cc9842..0000000000 --- a/packages/core/src/file-tree/file-tree-runtime.ts +++ /dev/null @@ -1,66 +0,0 @@ -import path from 'node:path'; -import { err, ok, type Result } from '@emdash/shared'; -import { FileWatchService, realpathOrResolve, type IFileWatchService } from '../fs'; -import { ResourceMap } from '../lib'; -import type { FileTreeError, FileTreeOnError } from './errors'; -import { FileTree } from './file-tree'; -import type { FileTreeLease, IFileTreeRuntime } from './types'; - -export type FileTreeRuntimeOptions = { - watcher?: IFileWatchService; - onError?: FileTreeOnError; -}; - -export class FileTreeRuntime implements IFileTreeRuntime { - private readonly trees: ResourceMap; - private readonly watcher: IFileWatchService; - private readonly ownsWatcher: boolean; - private disposeRequested = false; - - constructor(private readonly options: FileTreeRuntimeOptions = {}) { - this.ownsWatcher = !options.watcher; - this.watcher = options.watcher ?? new FileWatchService({ onError: options.onError }); - this.trees = new ResourceMap({ - teardown: (_key, tree) => tree.dispose(), - onError: options.onError, - onEmpty: () => { - void this.disposeIfIdle(); - }, - }); - } - - async open(rootPath: string): Promise> { - if (this.disposeRequested) throw new Error('FileTreeRuntime disposed'); - const resolvedRoot = realpathOrResolve(path.resolve(rootPath)); - const lease = await this.trees.acquire(resolvedRoot, async () => { - const tree = new FileTree({ - rootPath: resolvedRoot, - watcher: this.watcher, - onError: this.options.onError, - }); - return tree; - }); - try { - const ready = await lease.value.ready(); - if (!ready.success) { - await lease.release(); - return err(ready.error); - } - return ok(lease); - } catch (error) { - await lease.release(); - throw error; - } - } - - async dispose(): Promise { - this.disposeRequested = true; - await this.trees.dispose(); - await this.disposeIfIdle(); - } - - private async disposeIfIdle(): Promise { - if (!this.disposeRequested || !this.trees.idle || !this.ownsWatcher) return; - await this.watcher.dispose(); - } -} diff --git a/packages/core/src/file-tree/index.test.ts b/packages/core/src/file-tree/index.test.ts deleted file mode 100644 index 65fe8a073a..0000000000 --- a/packages/core/src/file-tree/index.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as fileTree from './index'; - -describe('@emdash/core/file-tree public exports', () => { - it('exports the runtime and shared file-domain foundation but not concrete tree internals', () => { - const exported = fileTree as Record; - - expect(exported.FileTreeRuntime).toBeTypeOf('function'); - expect(exported.isIgnored).toBeTypeOf('function'); - expect(exported.watchIgnoreGlobs).toBeTypeOf('function'); - expect(exported.resolveInsideRoot).toBeTypeOf('function'); - expect(exported.FileTree).toBeUndefined(); - expect(exported.NodeIdAssigner).toBeUndefined(); - expect(exported.listChildren).toBeUndefined(); - expect(exported.statEntry).toBeUndefined(); - }); -}); diff --git a/packages/core/src/file-tree/index.ts b/packages/core/src/file-tree/index.ts deleted file mode 100644 index 8d26ad3db8..0000000000 --- a/packages/core/src/file-tree/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { FileTreeRuntime, type FileTreeRuntimeOptions } from './file-tree-runtime'; -export { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './errors'; -export { IGNORED_PATH_SEGMENTS, isIgnored, watchIgnoreGlobs } from './ignores'; -export { normalizeRelPath, resolveInsideRoot, type RelPath, type ResolvedPath } from './paths'; -export type { FileNode, FileNodeType, FileTreeScope, NodeId } from './models/tree'; -export type { - FileTreeLease, - FileTreeSequences, - FileTreeSnapshot, - FileTreeUpdate, - IFileTree, - IFileTreeRuntime, - SubscribedSnapshot, -} from './types'; diff --git a/packages/core/src/files/changes/changes.test.ts b/packages/core/src/files/changes/changes.test.ts new file mode 100644 index 0000000000..c1ce698b0d --- /dev/null +++ b/packages/core/src/files/changes/changes.test.ts @@ -0,0 +1,148 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { WatchOptions, IWatchService, WatchEvent, WatchHandle } from '../../watch'; +import { FileChanges } from './changes'; +import type { FileChangeUpdate } from './types'; + +class ManualWatchService implements IWatchService { + private consumers: Array<{ + onEvents: (events: WatchEvent[]) => void; + options: WatchOptions; + }> = []; + + get watchCount(): number { + return this.consumers.length; + } + + watch( + _root: string, + onEvents: (events: WatchEvent[]) => void, + options: WatchOptions = {} + ): WatchHandle { + this.consumers.push({ onEvents, options }); + return { + ready: async () => {}, + release: async () => {}, + }; + } + + emit(events: WatchEvent[]): void { + for (const consumer of this.consumers) consumer.onEvents(events); + } + + resync(): void { + for (const consumer of this.consumers) consumer.options.onResync?.(); + } + + async dispose(): Promise {} +} + +describe('FileChanges feed', () => { + let root: string | undefined; + + afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; + }); + + async function createFiles() { + root = await mkdtemp(path.join(tmpdir(), 'emdash-files-')); + const watcher = new ManualWatchService(); + const files = new FileChanges({ rootPath: root, watcher }); + return { files, watcher }; + } + + it('maps raw watch events to neutral file changes', async () => { + const { files, watcher } = await createFiles(); + await mkdir(path.join(root!, 'src')); + await writeFile(path.join(root!, 'src/index.ts'), 'content'); + await writeFile(path.join(root!, '..valid-name'), 'content'); + const updates: FileChangeUpdate[] = []; + + const subscription = files.watch((update) => updates.push(update)); + expect(subscription.success).toBe(true); + + watcher.emit([ + { kind: 'update', path: path.join(root!, 'src/index.ts') }, + { kind: 'create', path: path.join(root!, 'src') }, + { kind: 'update', path: path.join(root!, '..valid-name') }, + { kind: 'delete', path: path.join(root!, 'missing.ts') }, + ]); + + expect(updates).toEqual([ + { + kind: 'changes', + changes: [ + { kind: 'update', path: 'src/index.ts', entryType: 'file' }, + { kind: 'create', path: 'src', entryType: 'directory' }, + { kind: 'update', path: '..valid-name', entryType: 'file' }, + { kind: 'delete', path: 'missing.ts', entryType: 'unknown' }, + ], + }, + ]); + }); + + it('filters ignored paths and optional watched paths', async () => { + const { files, watcher } = await createFiles(); + await mkdir(path.join(root!, 'src')); + await mkdir(path.join(root!, 'other')); + await mkdir(path.join(root!, 'node_modules'), { recursive: true }); + await writeFile(path.join(root!, 'src/index.ts'), 'content'); + await writeFile(path.join(root!, 'other/index.ts'), 'content'); + await writeFile(path.join(root!, 'node_modules/pkg.js'), 'content'); + const updates: FileChangeUpdate[] = []; + + const subscription = files.watch((update) => updates.push(update), { paths: ['src'] }); + expect(subscription.success).toBe(true); + + watcher.emit([ + { kind: 'update', path: path.join(root!, 'src/index.ts') }, + { kind: 'update', path: path.join(root!, 'other/index.ts') }, + { kind: 'update', path: path.join(root!, 'node_modules/pkg.js') }, + ]); + + expect(updates).toEqual([ + { + kind: 'changes', + changes: [{ kind: 'update', path: 'src/index.ts', entryType: 'file' }], + }, + ]); + }); + + it('emits resync updates when the native watcher recovers from a gap', async () => { + const { files, watcher } = await createFiles(); + const updates: FileChangeUpdate[] = []; + + const subscription = files.watch((update) => updates.push(update)); + expect(subscription.success).toBe(true); + watcher.resync(); + + expect(updates).toEqual([{ kind: 'resync' }]); + }); + + it('rejects invalid watched paths', async () => { + const { files } = await createFiles(); + + const subscription = files.watch(() => {}, { paths: ['../outside'] }); + + expect(subscription.success).toBe(false); + if (!subscription.success) expect(subscription.error.type).toBe('invalid-path'); + }); + + it('returns a disposed error when watched after disposal', async () => { + const { files } = await createFiles(); + files.dispose(); + + const subscription = files.watch(() => {}); + + expect(subscription.success).toBe(false); + if (!subscription.success) { + expect(subscription.error).toMatchObject({ + type: 'fs-error', + message: 'FileChanges disposed', + }); + } + }); +}); diff --git a/packages/core/src/files/changes/changes.ts b/packages/core/src/files/changes/changes.ts new file mode 100644 index 0000000000..85459e74d2 --- /dev/null +++ b/packages/core/src/files/changes/changes.ts @@ -0,0 +1,138 @@ +import { lstatSync } from 'node:fs'; +import path from 'node:path'; +import { err, ok, type Result } from '@emdash/shared'; +import type { IWatchService, WatchEvent, WatchHandle } from '../../watch'; +import { classifyFileError, type FileError, type FilesOnError } from '../errors'; +import { isIgnored, watchIgnoreGlobs } from '../ignores'; +import { isRelPathWithinScope, normalizeRelPaths } from '../paths'; +import type { + FileChange, + FileChangeSubscription, + FileChangeUpdate, + FileChangeWatchOptions, + FileEntryType, + IFileChanges, +} from './types'; + +const DEFAULT_CHANGE_DEBOUNCE_MS = 100; + +export type FileChangesOptions = { + rootPath: string; + watcher: IWatchService; + onError?: FilesOnError; +}; + +export class FileChanges implements IFileChanges { + readonly rootPath: string; + private readonly watcher: IWatchService; + private readonly subscriptions = new Set(); + private disposed = false; + + constructor(options: FileChangesOptions) { + this.rootPath = options.rootPath; + this.watcher = options.watcher; + } + + watch( + cb: (update: FileChangeUpdate) => void, + options: FileChangeWatchOptions = {} + ): Result { + if (this.disposed) { + return err({ + type: 'fs-error', + path: this.rootPath, + message: 'FileChanges disposed', + }); + } + + const watchedPaths = normalizeWatchedPaths(options.paths); + if (!watchedPaths.success) return watchedPaths; + + const handle = this.watcher.watch( + this.rootPath, + (events) => { + const changes = rawEventsToChanges(this.rootPath, events, watchedPaths.data); + if (changes.length > 0) cb({ kind: 'changes', changes }); + }, + { + ignore: watchIgnoreGlobs(), + debounceMs: options.debounceMs ?? DEFAULT_CHANGE_DEBOUNCE_MS, + onResync: () => cb({ kind: 'resync' }), + } + ); + this.subscriptions.add(handle); + + let unsubscribed = false; + const unsubscribe = () => { + if (unsubscribed) return; + unsubscribed = true; + this.subscriptions.delete(handle); + handle.release(); + }; + + return ok({ + ready: async () => { + try { + await handle.ready(); + return ok(); + } catch (error) { + return err(classifyFileError(error, this.rootPath)); + } + }, + unsubscribe, + }); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const handle of this.subscriptions) handle.release(); + this.subscriptions.clear(); + } +} + +function normalizeWatchedPaths(paths: string[] | undefined): Result { + if (!paths || paths.length === 0) return ok(['']); + return normalizeRelPaths(paths, { allowEmpty: true }); +} + +function rawEventsToChanges( + rootPath: string, + events: WatchEvent[], + watchedPaths: string[] +): FileChange[] { + const changes: FileChange[] = []; + for (const event of events) { + const relPath = relativeFromRawEvent(rootPath, event); + if (!relPath) continue; + if (isIgnored(relPath)) continue; + if (!isWatchedPath(relPath, watchedPaths)) continue; + changes.push({ + kind: event.kind, + path: relPath, + entryType: entryTypeForRawEvent(event), + }); + } + return changes; +} + +function relativeFromRawEvent(rootPath: string, event: WatchEvent): string | null { + const relPath = path.relative(rootPath, event.path).replace(/\\/g, '/'); + if (!relPath || relPath === '..' || relPath.startsWith('../') || path.isAbsolute(relPath)) { + return null; + } + return relPath; +} + +function isWatchedPath(relPath: string, watchedPaths: string[]): boolean { + return watchedPaths.some((watchedPath) => isRelPathWithinScope(relPath, watchedPath)); +} + +function entryTypeForRawEvent(event: WatchEvent): FileEntryType { + if (event.kind === 'delete') return 'unknown'; + try { + return lstatSync(event.path).isDirectory() ? 'directory' : 'file'; + } catch { + return 'unknown'; + } +} diff --git a/packages/core/src/files/changes/types.ts b/packages/core/src/files/changes/types.ts new file mode 100644 index 0000000000..a8d57c49cd --- /dev/null +++ b/packages/core/src/files/changes/types.ts @@ -0,0 +1,37 @@ +import type { Result, Unsubscribe } from '@emdash/shared'; +import type { FileError } from '../errors'; + +export type FileEntryType = 'file' | 'directory' | 'unknown'; +export type FileChangeKind = 'create' | 'update' | 'delete'; + +export type FileChange = { + kind: FileChangeKind; + path: string; + entryType: FileEntryType; +}; + +export type FileChangeUpdate = { kind: 'changes'; changes: FileChange[] } | { kind: 'resync' }; + +export type FileChangeWatchOptions = { + /** + * Paths relative to the watched root. An empty path includes the whole root. + * Implementations may apply this at the underlying watch layer or as a + * consumer-side filter; the emitted paths are always root-relative. + */ + paths?: string[]; + debounceMs?: number; +}; + +export type FileChangeSubscription = { + ready(): Promise>; + unsubscribe: Unsubscribe; +}; + +export interface IFileChanges { + readonly rootPath: string; + watch( + cb: (update: FileChangeUpdate) => void, + options?: FileChangeWatchOptions + ): Result; + dispose(): void; +} diff --git a/packages/core/src/files/errors.ts b/packages/core/src/files/errors.ts new file mode 100644 index 0000000000..1c180b23e2 --- /dev/null +++ b/packages/core/src/files/errors.ts @@ -0,0 +1,9 @@ +export type FilesOnError = (context: string, error: unknown) => void; + +export type FileError = + | { type: 'invalid-path'; path: string; message: string } + | { type: 'fs-error'; path: string; message: string }; + +export function classifyFileError(error: unknown, path: string): FileError { + return { type: 'fs-error', path, message: String(error) }; +} diff --git a/packages/core/src/files/files-runtime.test.ts b/packages/core/src/files/files-runtime.test.ts new file mode 100644 index 0000000000..03b90f632c --- /dev/null +++ b/packages/core/src/files/files-runtime.test.ts @@ -0,0 +1,65 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { IWatchService, WatchEvent, WatchHandle, WatchOptions } from '../watch'; +import { FilesRuntime } from './files-runtime'; + +class RecordingWatchService implements IWatchService { + readonly watches: Array<{ + root: string; + options: WatchOptions; + }> = []; + + watch( + root: string, + _onEvents: (events: WatchEvent[]) => void, + options: WatchOptions = {} + ): WatchHandle { + this.watches.push({ root, options }); + return { + ready: async () => {}, + release: async () => {}, + }; + } + + async dispose(): Promise {} +} + +const roots: string[] = []; + +async function makeRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'emdash-files-runtime-')); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('FilesRuntime', () => { + it('wires file tree and change feeds through the same watch root and ignore set', async () => { + const root = await makeRoot(); + await mkdir(path.join(root, 'src')); + await writeFile(path.join(root, 'src/index.ts'), 'content'); + const watcher = new RecordingWatchService(); + const runtime = new FilesRuntime({ watcher }); + + const fileTree = await runtime.openTree(root); + expect(fileTree.success).toBe(true); + if (!fileTree.success) return; + + const changes = runtime.watchChanges(root, () => {}); + expect(changes.success).toBe(true); + if (!changes.success) return; + + expect(watcher.watches).toHaveLength(2); + expect(watcher.watches[0].root).toBe(watcher.watches[1].root); + expect(watcher.watches[0].options.ignore).toEqual(watcher.watches[1].options.ignore); + + changes.data.unsubscribe(); + await fileTree.data.release(); + await runtime.dispose(); + }); +}); diff --git a/packages/core/src/files/files-runtime.ts b/packages/core/src/files/files-runtime.ts new file mode 100644 index 0000000000..a36b4d7c84 --- /dev/null +++ b/packages/core/src/files/files-runtime.ts @@ -0,0 +1,110 @@ +import path from 'node:path'; +import { err, ok, type Result } from '@emdash/shared'; +import { ResourceMap } from '../lib'; +import { WatchService, realpathOrResolve, type IWatchService } from '../watch'; +import { FileChanges } from './changes/changes'; +import type { FileError, FilesOnError } from './errors'; +import type { FileTreeError, FileTreeOnError } from './tree/errors'; +import { FileTree } from './tree/file-tree'; +import type { FileTreeLease } from './tree/types'; +import type { + FileChangeSubscription, + FileChangeUpdate, + FileChangeWatchOptions, + IFilesRuntime, +} from './types'; + +export type FilesRuntimeOptions = { + watcher?: IWatchService; + onError?: FilesOnError & FileTreeOnError; +}; + +export class FilesRuntime implements IFilesRuntime { + private readonly trees: ResourceMap; + private readonly watcher: IWatchService; + private readonly ownsWatcher: boolean; + private disposeRequested = false; + + constructor(private readonly options: FilesRuntimeOptions = {}) { + this.ownsWatcher = !options.watcher; + this.watcher = options.watcher ?? new WatchService({ onError: options.onError }); + this.trees = new ResourceMap({ + teardown: (_key, tree) => tree.dispose(), + onError: options.onError, + onEmpty: () => { + void this.disposeIfIdle(); + }, + }); + } + + async openTree(rootPath: string): Promise> { + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: '', + message: 'FilesRuntime disposed', + }); + } + const resolvedRoot = realpathOrResolve(path.resolve(rootPath)); + const lease = await this.trees.acquire(resolvedRoot, async () => { + return new FileTree({ + rootPath: resolvedRoot, + watcher: this.watcher, + onError: this.options.onError, + }); + }); + try { + const ready = await lease.value.ready(); + if (!ready.success) { + await lease.release(); + return err(ready.error); + } + return ok(lease); + } catch (error) { + await lease.release(); + throw error; + } + } + + watchChanges( + rootPath: string, + cb: (update: FileChangeUpdate) => void, + options?: FileChangeWatchOptions + ): Result { + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: '', + message: 'FilesRuntime disposed', + }); + } + const changes = new FileChanges({ + rootPath: realpathOrResolve(path.resolve(rootPath)), + watcher: this.watcher, + onError: this.options.onError, + }); + const subscription = changes.watch(cb, options); + if (!subscription.success) { + changes.dispose(); + return subscription; + } + return ok({ + ready: subscription.data.ready, + unsubscribe: () => { + subscription.data.unsubscribe(); + changes.dispose(); + }, + }); + } + + async dispose(): Promise { + this.disposeRequested = true; + await this.trees.dispose(); + await this.disposeIfIdle(); + } + + private async disposeIfIdle(): Promise { + if (!this.disposeRequested || !this.trees.idle || !this.ownsWatcher) return; + await this.watcher.dispose(); + } +} diff --git a/packages/core/src/file-tree/ignores.ts b/packages/core/src/files/ignores.ts similarity index 100% rename from packages/core/src/file-tree/ignores.ts rename to packages/core/src/files/ignores.ts diff --git a/packages/core/src/files/index.test.ts b/packages/core/src/files/index.test.ts new file mode 100644 index 0000000000..05d8488d99 --- /dev/null +++ b/packages/core/src/files/index.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import * as files from './index'; + +describe('@emdash/core/files public exports', () => { + it('exports the files runtime and shared file-domain primitives', () => { + const exported = files as Record; + + expect(exported.FilesRuntime).toBeTypeOf('function'); + expect(exported.isIgnored).toBeTypeOf('function'); + expect(exported.watchIgnoreGlobs).toBeTypeOf('function'); + expect(exported.normalizeRelPath).toBeTypeOf('function'); + expect(exported.resolveInsideRoot).toBeTypeOf('function'); + }); +}); diff --git a/packages/core/src/files/index.ts b/packages/core/src/files/index.ts new file mode 100644 index 0000000000..84b00ab5a5 --- /dev/null +++ b/packages/core/src/files/index.ts @@ -0,0 +1,15 @@ +export { FilesRuntime, type FilesRuntimeOptions } from './files-runtime'; +export { classifyFileError, type FileError, type FilesOnError } from './errors'; +export { IGNORED_PATH_SEGMENTS, isIgnored, watchIgnoreGlobs } from './ignores'; +export { + basenameFromRelPath, + isRelPathWithinScope, + normalizeRelPath, + normalizeRelPaths, + parentRelPath, + resolveInsideRoot, + type RelPath, + type ResolvedPath, +} from './paths'; +export { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './tree/errors'; +export type * from './types'; diff --git a/packages/core/src/file-tree/paths.ts b/packages/core/src/files/paths.ts similarity index 75% rename from packages/core/src/file-tree/paths.ts rename to packages/core/src/files/paths.ts index 5590c30c85..a5f0d17c78 100644 --- a/packages/core/src/file-tree/paths.ts +++ b/packages/core/src/files/paths.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { err, ok, type Result } from '@emdash/shared'; -import type { FileTreeError } from './errors'; +import type { FileError } from './errors'; export type RelPath = string & { readonly __relPath: unique symbol }; @@ -12,7 +12,7 @@ export type ResolvedPath = { export function normalizeRelPath( input: string, options: { allowEmpty?: boolean } = {} -): Result { +): Result { if (input.includes('\0')) { return err({ type: 'invalid-path', path: input, message: 'Path contains a null byte' }); } @@ -39,11 +39,24 @@ export function normalizeRelPath( return ok(normalized as RelPath); } +export function normalizeRelPaths( + inputs: readonly string[], + options: { allowEmpty?: boolean } = {} +): Result { + const normalized = new Set(); + for (const input of inputs) { + const result = normalizeRelPath(input, options); + if (!result.success) return result; + normalized.add(result.data); + } + return ok([...normalized]); +} + export function resolveInsideRoot( rootPath: string, input: string, options: { allowEmpty?: boolean } = {} -): Result { +): Result { const normalized = normalizeRelPath(input, options); if (!normalized.success) return normalized; @@ -66,3 +79,7 @@ export function basenameFromRelPath(relPath: string): string { const index = relPath.lastIndexOf('/'); return index === -1 ? relPath : relPath.slice(index + 1); } + +export function isRelPathWithinScope(relPath: string, scopePath: string): boolean { + return scopePath === '' || relPath === scopePath || relPath.startsWith(`${scopePath}/`); +} diff --git a/packages/core/src/file-tree/errors.ts b/packages/core/src/files/tree/errors.ts similarity index 100% rename from packages/core/src/file-tree/errors.ts rename to packages/core/src/files/tree/errors.ts diff --git a/packages/core/src/file-tree/file-tree.test.ts b/packages/core/src/files/tree/file-tree.test.ts similarity index 94% rename from packages/core/src/file-tree/file-tree.test.ts rename to packages/core/src/files/tree/file-tree.test.ts index 2433879197..437b21f788 100644 --- a/packages/core/src/file-tree/file-tree.test.ts +++ b/packages/core/src/files/tree/file-tree.test.ts @@ -3,14 +3,14 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import type { Result } from '@emdash/shared'; import { afterEach, describe, expect, it } from 'vitest'; -import type { IFileWatchService, RawFileEvent, WatchHandle } from '../fs'; +import type { IWatchService, WatchEvent, WatchHandle } from '../../watch'; +import { FilesRuntime } from '../files-runtime'; +import { resolveInsideRoot } from '../paths'; import { FileTree } from './file-tree'; -import { FileTreeRuntime } from './file-tree-runtime'; import type { FileNode } from './models/tree'; -import { resolveInsideRoot } from './paths'; -class ManualWatchService implements IFileWatchService { - private consumers: Array<(events: RawFileEvent[]) => void> = []; +class ManualWatchService implements IWatchService { + private consumers: Array<(events: WatchEvent[]) => void> = []; private readyCalls = 0; private releaseCalls = 0; @@ -22,7 +22,7 @@ class ManualWatchService implements IFileWatchService { return this.releaseCalls; } - watch(_root: string, onEvents: (events: RawFileEvent[]) => void): WatchHandle { + watch(_root: string, onEvents: (events: WatchEvent[]) => void): WatchHandle { this.consumers.push(onEvents); this.readyCalls += 1; return { @@ -34,7 +34,7 @@ class ManualWatchService implements IFileWatchService { }; } - emit(events: RawFileEvent[]): void { + emit(events: WatchEvent[]): void { for (const consumer of this.consumers) consumer(events); } @@ -106,7 +106,7 @@ describe('FileTree', () => { expect(paths(await nodes(tree))).toEqual(['target.txt']); const patched = tree as unknown as { - applyWatchEvents(events: RawFileEvent[]): Promise; + applyWatchEvents(events: WatchEvent[]): Promise; }; const originalApplyWatchEvents = patched.applyWatchEvents; let resolveApplied: () => void = () => {}; @@ -283,7 +283,7 @@ describe('FileTree', () => { const patched = tree as unknown as { refreshLoadedScopes(): Promise>; - applyWatchEvents(events: RawFileEvent[]): Promise; + applyWatchEvents(events: WatchEvent[]): Promise; }; const originalRefreshLoadedScopes = patched.refreshLoadedScopes; const originalApplyWatchEvents = patched.applyWatchEvents; @@ -350,10 +350,10 @@ describe('FileTree', () => { it('runtime leases share one tree per resolved root', async () => { const root = await makeRoot(); const watcher = new ManualWatchService(); - const runtime = new FileTreeRuntime({ watcher }); + const runtime = new FilesRuntime({ watcher }); - const first = await runtime.open(root); - const second = await runtime.open(root); + const first = await runtime.openTree(root); + const second = await runtime.openTree(root); const firstLease = unwrap(first); const secondLease = unwrap(second); @@ -367,9 +367,9 @@ describe('FileTree', () => { it('returns a typed error when opening a missing root', async () => { const root = path.join(await makeRoot(), 'missing'); - const runtime = new FileTreeRuntime({ watcher: new ManualWatchService() }); + const runtime = new FilesRuntime({ watcher: new ManualWatchService() }); - await expect(runtime.open(root)).resolves.toMatchObject({ + await expect(runtime.openTree(root)).resolves.toMatchObject({ success: false, error: { type: 'not-found', path: '' }, }); @@ -379,14 +379,14 @@ describe('FileTree', () => { it('releases the runtime lease when ready rejects unexpectedly', async () => { const root = await makeRoot(); const watcher = new ManualWatchService(); - const runtime = new FileTreeRuntime({ watcher }); + const runtime = new FilesRuntime({ watcher }); const originalReady = FileTree.prototype.ready; FileTree.prototype.ready = async () => { throw new Error('boom'); }; try { - await expect(runtime.open(root)).rejects.toThrow('boom'); + await expect(runtime.openTree(root)).rejects.toThrow('boom'); await waitFor(async () => watcher.releaseCount === 1); } finally { FileTree.prototype.ready = originalReady; diff --git a/packages/core/src/file-tree/file-tree.ts b/packages/core/src/files/tree/file-tree.ts similarity index 96% rename from packages/core/src/file-tree/file-tree.ts rename to packages/core/src/files/tree/file-tree.ts index 28df73ac54..80122467ff 100644 --- a/packages/core/src/file-tree/file-tree.ts +++ b/packages/core/src/files/tree/file-tree.ts @@ -1,13 +1,13 @@ import path from 'node:path'; import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; -import type { IFileWatchService, RawFileEvent, WatchHandle } from '../fs'; -import { KeyedMutex, LiveCollection, type KeyedOp } from '../lib'; +import { KeyedMutex, LiveCollection, type KeyedOp } from '../../lib'; +import type { IWatchService, WatchEvent, WatchHandle } from '../../watch'; +import { isIgnored, watchIgnoreGlobs } from '../ignores'; +import { resolveInsideRoot } from '../paths'; import { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './errors'; -import { isIgnored, watchIgnoreGlobs } from './ignores'; import { listChildren } from './list'; import type { FileNode, NodeId } from './models/tree'; import { NodeIdAssigner } from './node-id'; -import { resolveInsideRoot } from './paths'; import type { FileTreeSequences, FileTreeSnapshot, @@ -22,7 +22,7 @@ const REVALIDATE_INTERVAL_MS = 5 * 60_000; export type FileTreeOptions = { rootPath: string; - watcher: IFileWatchService; + watcher: IWatchService; onError?: FileTreeOnError; }; @@ -128,6 +128,22 @@ export class FileTree implements IFileTree { return ok({ snapshot: snapshot.data, unsubscribe }); } + async refresh(): Promise> { + return this.runMutation(async () => { + const refreshed = await this.refreshLoadedScopes(); + if (!refreshed.success) return err(refreshed.error); + return ok(this.collection.getCached()); + }); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + if (this.revalidateTimer) clearInterval(this.revalidateTimer); + await this.watch.release(); + this.collection.dispose(); + } + async expandDir(dirId: NodeId | null): Promise> { const ready = await this.ready(); if (!ready.success) return err(ready.error); @@ -160,14 +176,6 @@ export class FileTree implements IFileTree { }); } - async refresh(): Promise> { - return this.runMutation(async () => { - const refreshed = await this.refreshLoadedScopes(); - if (!refreshed.success) return err(refreshed.error); - return ok(this.collection.getCached()); - }); - } - private async refreshLoadedScopes(): Promise> { const scopes = this.collection.loadedScopes(); let sequences: FileTreeSequences = {}; @@ -185,14 +193,6 @@ export class FileTree implements IFileTree { return ok(sequences); } - async dispose(): Promise { - if (this.disposed) return; - this.disposed = true; - if (this.revalidateTimer) clearInterval(this.revalidateTimer); - await this.watch.release(); - this.collection.dispose(); - } - private async loadDirectoryScope( scope: NodeId | null ): Promise> { @@ -279,7 +279,7 @@ export class FileTree implements IFileTree { return ok(sequence === 0 ? {} : { tree: sequence }); } - private async applyWatchEvents(events: RawFileEvent[]): Promise { + private async applyWatchEvents(events: WatchEvent[]): Promise { if (this.disposed) return; const classification = await classifyFileTreeWatchEvents(events, { rootPath: this.rootPath, diff --git a/packages/core/src/file-tree/list.ts b/packages/core/src/files/tree/list.ts similarity index 96% rename from packages/core/src/file-tree/list.ts rename to packages/core/src/files/tree/list.ts index c68bc56b7f..d2530c125e 100644 --- a/packages/core/src/file-tree/list.ts +++ b/packages/core/src/files/tree/list.ts @@ -1,9 +1,9 @@ import { lstat, readdir } from 'node:fs/promises'; import { err, ok, type Result } from '@emdash/shared'; +import { isIgnored } from '../ignores'; +import { basenameFromRelPath, resolveInsideRoot } from '../paths'; import { classifyFileTreeFsError, type FileTreeError } from './errors'; -import { isIgnored } from './ignores'; import type { FileNodeType } from './models/tree'; -import { basenameFromRelPath, resolveInsideRoot } from './paths'; export type DevIno = `${number}:${number}`; diff --git a/packages/core/src/file-tree/models/tree.ts b/packages/core/src/files/tree/models/tree.ts similarity index 100% rename from packages/core/src/file-tree/models/tree.ts rename to packages/core/src/files/tree/models/tree.ts diff --git a/packages/core/src/file-tree/node-id.ts b/packages/core/src/files/tree/node-id.ts similarity index 100% rename from packages/core/src/file-tree/node-id.ts rename to packages/core/src/files/tree/node-id.ts diff --git a/packages/core/src/file-tree/types.ts b/packages/core/src/files/tree/types.ts similarity index 84% rename from packages/core/src/file-tree/types.ts rename to packages/core/src/files/tree/types.ts index 930b3d9a1d..162eeb14ca 100644 --- a/packages/core/src/file-tree/types.ts +++ b/packages/core/src/files/tree/types.ts @@ -1,5 +1,5 @@ import type { Lease, Result, Unsubscribe } from '@emdash/shared'; -import type { CollectionSnapshot, CollectionUpdate } from '../lib'; +import type { CollectionSnapshot, CollectionUpdate } from '../../lib'; import type { FileTreeError } from './errors'; import type { FileNode, NodeId } from './models/tree'; @@ -28,8 +28,3 @@ export interface IFileTree { } export type FileTreeLease = Lease; - -export interface IFileTreeRuntime { - open(rootPath: string): Promise>; - dispose(): Promise; -} diff --git a/packages/core/src/file-tree/watch/classifier.test.ts b/packages/core/src/files/tree/watch/classifier.test.ts similarity index 99% rename from packages/core/src/file-tree/watch/classifier.test.ts rename to packages/core/src/files/tree/watch/classifier.test.ts index 1818b447a7..199fd37099 100644 --- a/packages/core/src/file-tree/watch/classifier.test.ts +++ b/packages/core/src/files/tree/watch/classifier.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it, afterEach } from 'vitest'; -import type { RawFileEvent } from '../../fs'; +import type { WatchEvent } from '../../../watch'; import { statEntry, type DevIno, type ListedEntry } from '../list'; import type { FileNodeType, NodeId } from '../models/tree'; import { NodeIdAssigner } from '../node-id'; @@ -193,7 +193,7 @@ async function makeRoot(): Promise { async function classify( rootPath: string, ids: NodeIdAssigner, - events: RawFileEvent[], + events: WatchEvent[], options: { loadedScopes?: Set; isScopeLoaded?: (scope: NodeId | null) => boolean; diff --git a/packages/core/src/file-tree/watch/classifier.ts b/packages/core/src/files/tree/watch/classifier.ts similarity index 90% rename from packages/core/src/file-tree/watch/classifier.ts rename to packages/core/src/files/tree/watch/classifier.ts index 1429a0f74d..6e6cbad075 100644 --- a/packages/core/src/file-tree/watch/classifier.ts +++ b/packages/core/src/files/tree/watch/classifier.ts @@ -1,11 +1,11 @@ import path from 'node:path'; -import type { RawFileEvent } from '../../fs'; -import type { KeyedOp } from '../../lib'; -import { isIgnored } from '../ignores'; +import type { KeyedOp } from '../../../lib'; +import type { WatchEvent } from '../../../watch'; +import { isIgnored } from '../../ignores'; +import { parentRelPath, resolveInsideRoot } from '../../paths'; import { statEntry as statFileTreeEntry, type ListedEntry } from '../list'; import type { FileNode, NodeId } from '../models/tree'; import type { NodeIdAssigner, Tombstone } from '../node-id'; -import { parentRelPath, resolveInsideRoot } from '../paths'; export type FileTreeWatchClassifierOptions = { rootPath: string; @@ -19,7 +19,7 @@ export type FileTreeWatchClassification = { }; export async function classifyFileTreeWatchEvents( - events: RawFileEvent[], + events: WatchEvent[], options: FileTreeWatchClassifierOptions ): Promise { const tombstones: Tombstone[] = []; @@ -80,7 +80,7 @@ function parentScopeFor(entry: ListedEntry, ids: NodeIdAssigner): NodeId | null return parent?.type === 'directory' ? parent.id : undefined; } -function relPathFromWatchEvent(rootPath: string, event: RawFileEvent): string | null { +function relPathFromWatchEvent(rootPath: string, event: WatchEvent): string | null { const relative = path.relative(rootPath, event.path).replace(/\\/g, '/'); const resolved = resolveInsideRoot(rootPath, relative, { allowEmpty: true }); if (!resolved.success || !resolved.data.relPath) return null; diff --git a/packages/core/src/files/types.ts b/packages/core/src/files/types.ts new file mode 100644 index 0000000000..1453138b0c --- /dev/null +++ b/packages/core/src/files/types.ts @@ -0,0 +1,38 @@ +import type { Result } from '@emdash/shared'; +import type { + FileChangeSubscription, + FileChangeUpdate, + FileChangeWatchOptions, +} from './changes/types'; +import type { FileError } from './errors'; +import type { FileTreeError } from './tree/errors'; +import type { FileTreeLease } from './tree/types'; + +export interface IFilesRuntime { + openTree(rootPath: string): Promise>; + watchChanges( + rootPath: string, + cb: (update: FileChangeUpdate) => void, + options?: FileChangeWatchOptions + ): Result; + dispose(): Promise; +} + +export type { + FileChange, + FileChangeKind, + FileChangeSubscription, + FileChangeUpdate, + FileChangeWatchOptions, + FileEntryType, + IFileChanges, +} from './changes/types'; +export type { FileNode, FileNodeType, FileTreeScope, NodeId } from './tree/models/tree'; +export type { + FileTreeLease, + FileTreeSequences, + FileTreeSnapshot, + FileTreeUpdate, + IFileTree, + SubscribedSnapshot, +} from './tree/types'; diff --git a/packages/core/src/fs/index.ts b/packages/core/src/fs/index.ts deleted file mode 100644 index 910b5df2d6..0000000000 --- a/packages/core/src/fs/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { FileWatchService, type FileWatchServiceOptions } from './file-watch-service'; -export { realpathOrResolve } from './paths'; -export type { - FileChangeKind, - FileWatchOptions, - IFileWatchService, - RawFileEvent, - WatchHandle, -} from './types'; diff --git a/packages/core/src/git/errors.ts b/packages/core/src/git/errors.ts index 609dcb29d7..d3f0d66dfd 100644 --- a/packages/core/src/git/errors.ts +++ b/packages/core/src/git/errors.ts @@ -281,3 +281,12 @@ export function classifyDeleteBranchError(error: unknown, branch: string): Delet } return commandError; } + +export function isNotRepositoryInspectionError(error: unknown): boolean { + const message = gitErrorMessage(error).toLowerCase(); + return ( + message.includes('not a git repository') || + message.includes('not a git directory') || + message.includes('must be run in a work tree') + ); +} diff --git a/packages/core/src/git/git-repository.test.ts b/packages/core/src/git/git-repository.test.ts index 2fc4beba4e..7a815d54ca 100644 --- a/packages/core/src/git/git-repository.test.ts +++ b/packages/core/src/git/git-repository.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; -import { FileWatchService } from '../fs'; +import { WatchService } from '../watch'; import { GitRuntime, type GitRefsModel, type GitRepoUpdate } from './index'; const execFileAsync = promisify(execFile); @@ -63,7 +63,7 @@ async function eventually( describe('GitRepository', () => { it('reads repository facts and emits updates after real git mutations', async () => { const { repo, remote } = await makeRepoWithRemote(); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher }); const updates: GitRepoUpdate[] = []; @@ -138,7 +138,7 @@ describe('GitRepository', () => { it('emits refs updates for external git mutations under the common dir', async () => { const { repo } = await makeRepoWithRemote(); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher }); const updates: GitRepoUpdate[] = []; diff --git a/packages/core/src/git/git-repository.ts b/packages/core/src/git/git-repository.ts index 4354c22a95..e0c105fd55 100644 --- a/packages/core/src/git/git-repository.ts +++ b/packages/core/src/git/git-repository.ts @@ -1,9 +1,9 @@ import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; import type { BoundExec } from '../exec'; -import type { IFileWatchService, WatchHandle } from '../fs'; -import { realpathOrResolve } from '../fs'; import { LiveModel } from '../lib'; import type { KeyedMutex } from '../lib'; +import type { IWatchService, WatchHandle } from '../watch'; +import { realpathOrResolve } from '../watch'; import { CatFileBatch } from './cat-file-batch'; import { classifyCreateBranchError, @@ -52,7 +52,7 @@ export type GitRepositoryOptions = { objectStoreDir: string; exec: BoundExec; /** Injected file-watch service; disposed by the injector, not this class. */ - watcher: IFileWatchService; + watcher: IWatchService; /** Serializes concurrent fetch operations on the same object store directory. */ objectStoreMutex: KeyedMutex; onError?: GitOnError; diff --git a/packages/core/src/git/git-runtime.test.ts b/packages/core/src/git/git-runtime.test.ts index a6acd00f92..56aea4e4ad 100644 --- a/packages/core/src/git/git-runtime.test.ts +++ b/packages/core/src/git/git-runtime.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; import { ExecError, type BoundExec } from '../exec'; -import type { IFileWatchService } from '../fs'; +import type { IWatchService } from '../watch'; import { GitRuntime } from './index'; const execFileAsync = promisify(execFile); @@ -46,7 +46,7 @@ function deferred() { return { promise, resolve, reject }; } -function createNoopWatcher(): IFileWatchService { +function createNoopWatcher(): IWatchService { return { watch: () => ({ ready: async () => {}, @@ -235,7 +235,7 @@ describe('GitRuntime', () => { const repo = await makeRepo(); const releaseGate = deferred(); let releaseStarted = 0; - const watcher: IFileWatchService = { + const watcher: IWatchService = { watch: () => ({ ready: async () => {}, release: async () => { diff --git a/packages/core/src/git/git-runtime.ts b/packages/core/src/git/git-runtime.ts index eaca408fdf..5e12bfc9b2 100644 --- a/packages/core/src/git/git-runtime.ts +++ b/packages/core/src/git/git-runtime.ts @@ -1,9 +1,13 @@ import path from 'node:path'; import { err, ok, type Lease, type Result } from '@emdash/shared'; import type { BoundExec } from '../exec'; -import { FileWatchService, realpathOrResolve, type IFileWatchService } from '../fs'; import { KeyedMutex, ResourceMap } from '../lib'; -import { classifyCloneRepositoryError, gitErrorMessage } from './errors'; +import { WatchService, realpathOrResolve, type IWatchService } from '../watch'; +import { + classifyCloneRepositoryError, + gitErrorMessage, + isNotRepositoryInspectionError, +} from './errors'; import type { CloneRepositoryError } from './errors'; import { createGitExec } from './git-env'; import { GitRepository, type GitOnError } from './git-repository'; @@ -36,7 +40,7 @@ export type GitRuntimeOptions = { * File-watch service to use. Injected services are disposed by the injector; * when omitted, the runtime creates and disposes its own service. */ - watcher?: IFileWatchService; + watcher?: IWatchService; executable?: string; env?: NodeJS.ProcessEnv; exec?: BoundExec; @@ -48,7 +52,7 @@ export class GitRuntime implements IGitRuntime { private readonly worktrees: ResourceMap; private readonly mutex: KeyedMutex; private readonly exec: BoundExec; - private readonly watcher: IFileWatchService; + private readonly watcher: IWatchService; private readonly ownsWatcher: boolean; private readonly onError: GitOnError; private disposeRequested = false; @@ -56,7 +60,7 @@ export class GitRuntime implements IGitRuntime { constructor(options: GitRuntimeOptions = {}) { this.onError = options.onError ?? (() => {}); this.ownsWatcher = !options.watcher; - this.watcher = options.watcher ?? new FileWatchService({ onError: this.onError }); + this.watcher = options.watcher ?? new WatchService({ onError: this.onError }); this.mutex = new KeyedMutex(); this.exec = options.exec ?? @@ -305,12 +309,3 @@ export class GitRuntime implements IGitRuntime { if (this.ownsWatcher) await this.watcher.dispose(); } } - -function isNotRepositoryInspectionError(error: unknown): boolean { - const message = gitErrorMessage(error).toLowerCase(); - return ( - message.includes('not a git repository') || - message.includes('not a git directory') || - message.includes('must be run in a work tree') - ); -} diff --git a/packages/core/src/git/git-worktree.test.ts b/packages/core/src/git/git-worktree.test.ts index 17efaa2a46..ff32f31a13 100644 --- a/packages/core/src/git/git-worktree.test.ts +++ b/packages/core/src/git/git-worktree.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; -import { FileWatchService } from '../fs'; +import { WatchService } from '../watch'; import { GitRuntime, type GitRepoUpdate, type GitWorktreeUpdate } from './index'; const execFileAsync = promisify(execFile); @@ -69,7 +69,7 @@ function expectSuccess( describe('GitWorktree', () => { it('refreshes and emits worktree facts for real file and git mutations', async () => { const repo = await makeRepo(); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher }); const updates: GitWorktreeUpdate[] = []; const repoUpdates: GitRepoUpdate[] = []; @@ -196,7 +196,7 @@ describe('GitWorktree', () => { it('refreshes staged status when an external commit advances the branch ref', async () => { const repo = await makeRepo(); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher }); const updates: GitWorktreeUpdate[] = []; @@ -257,7 +257,7 @@ describe('GitWorktree', () => { await execFileAsync('git', ['commit', '-am', 'main edit'], { cwd: repo }); await execFileAsync('git', ['checkout', 'feature'], { cwd: repo }); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher }); const updates: GitWorktreeUpdate[] = []; @@ -317,7 +317,7 @@ describe('GitWorktree', () => { await writeFile(path.join(repo, 'tracked.txt'), 'pushed\n', 'utf8'); await execFileAsync('git', ['add', 'tracked.txt'], { cwd: repo }); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher }); const repoUpdates: string[] = []; @@ -383,7 +383,7 @@ describe('GitWorktree', () => { await execFileAsync('git', ['add', 'pixel.png'], { cwd: repo }); await execFileAsync('git', ['commit', '-m', 'add pixel'], { cwd: repo }); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher }); try { const lease = await runtime.openWorktree(repo); @@ -413,7 +413,7 @@ describe('GitWorktree', () => { await execFileAsync('git', ['commit', '-m', 'add pixel'], { cwd: repo }); const { executable, logPath } = await makeRecordingGitExecutable(); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher, executable }); try { const lease = await runtime.openWorktree(repo); @@ -484,7 +484,7 @@ describe('GitWorktree', () => { it('stageAll, unstageAll, and revertAll mutate the full worktree state', async () => { const repo = await makeRepo(); - const watcher = new FileWatchService(); + const watcher = new WatchService(); const runtime = new GitRuntime({ watcher }); try { diff --git a/packages/core/src/git/git-worktree.ts b/packages/core/src/git/git-worktree.ts index 7dd23281aa..933b32901a 100644 --- a/packages/core/src/git/git-worktree.ts +++ b/packages/core/src/git/git-worktree.ts @@ -2,8 +2,8 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; import { ExecError, type BoundExec } from '../exec'; -import type { IFileWatchService, WatchHandle } from '../fs'; import { LiveModel } from '../lib'; +import type { IWatchService, WatchHandle } from '../watch'; import { classifyCommitError, classifyPullError, @@ -71,7 +71,7 @@ export type GitWorktreeOptions = { gitDir: string; repository: GitRepository; exec: BoundExec; - watcher: IFileWatchService; + watcher: IWatchService; onError?: GitOnError; }; diff --git a/packages/core/src/git/watch/classifier.ts b/packages/core/src/git/watch/classifier.ts index b321e13bca..b4cf999257 100644 --- a/packages/core/src/git/watch/classifier.ts +++ b/packages/core/src/git/watch/classifier.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import type { RawFileEvent } from '../../fs'; +import type { WatchEvent } from '../../watch'; export type RepoWatchEffects = { refs: boolean; @@ -22,7 +22,7 @@ export type GitWatchClassification = { }; export function classifyGitWatchEvents( - events: RawFileEvent[], + events: WatchEvent[], layout: GitLayout ): GitWatchClassification { const repo: RepoWatchEffects = { refs: false, remotes: false }; diff --git a/packages/core/src/watch/index.ts b/packages/core/src/watch/index.ts new file mode 100644 index 0000000000..100347de2b --- /dev/null +++ b/packages/core/src/watch/index.ts @@ -0,0 +1,3 @@ +export { WatchService, type WatchServiceOptions } from './watch-service'; +export { realpathOrResolve } from './paths'; +export type { WatchEventKind, WatchOptions, IWatchService, WatchEvent, WatchHandle } from './types'; diff --git a/packages/core/src/fs/native-watch.ts b/packages/core/src/watch/native-watch.ts similarity index 91% rename from packages/core/src/fs/native-watch.ts rename to packages/core/src/watch/native-watch.ts index e93097c9d8..dd0132b900 100644 --- a/packages/core/src/fs/native-watch.ts +++ b/packages/core/src/watch/native-watch.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import type { IDisposable } from '@emdash/shared'; import parcelWatcher from '@parcel/watcher'; -import type { RawFileEvent } from './types'; +import type { WatchEvent } from './types'; const RESUBSCRIBE_DELAY_MS = 250; const MAX_RESUBSCRIBE_DELAY_MS = 30_000; @@ -14,7 +14,7 @@ const MAX_RESUBSCRIBE_DELAY_MS = 30_000; export class NativeWatch implements IDisposable { readonly root: string; readonly ignore: string[]; - private readonly deliver: (events: RawFileEvent[]) => void; + private readonly deliver: (events: WatchEvent[]) => void; private readonly resync: () => void; private readonly onError: (context: string, error: unknown) => void; private subscription: Promise | null = null; @@ -25,7 +25,7 @@ export class NativeWatch implements IDisposable { constructor( root: string, ignore: string[], - deliver: (events: RawFileEvent[]) => void, + deliver: (events: WatchEvent[]) => void, resync: () => void, onError: (context: string, error: unknown) => void ) { @@ -63,7 +63,7 @@ export class NativeWatch implements IDisposable { return; } if (events.length === 0) return; - this.deliver(events.map(toRawFileEvent)); + this.deliver(events.map(toWatchEvent)); }, { ignore: this.ignore } ); @@ -96,7 +96,7 @@ export class NativeWatch implements IDisposable { } } -function toRawFileEvent(event: parcelWatcher.Event): RawFileEvent { +function toWatchEvent(event: parcelWatcher.Event): WatchEvent { return { kind: event.type, path: event.path, diff --git a/packages/core/src/fs/paths.ts b/packages/core/src/watch/paths.ts similarity index 100% rename from packages/core/src/fs/paths.ts rename to packages/core/src/watch/paths.ts diff --git a/packages/core/src/fs/types.ts b/packages/core/src/watch/types.ts similarity index 74% rename from packages/core/src/fs/types.ts rename to packages/core/src/watch/types.ts index d2f1eceace..ffd1d746a7 100644 --- a/packages/core/src/fs/types.ts +++ b/packages/core/src/watch/types.ts @@ -1,11 +1,11 @@ -export type FileChangeKind = 'create' | 'update' | 'delete'; +export type WatchEventKind = 'create' | 'update' | 'delete'; -export type RawFileEvent = { - kind: FileChangeKind; +export type WatchEvent = { + kind: WatchEventKind; path: string; }; -export type FileWatchOptions = { +export type WatchOptions = { /** * Native-level ignore globs, a property of the shared root subscription: consumers watching * the same root should agree on the ignore set to share one native watcher (different sets @@ -25,11 +25,11 @@ export type WatchHandle = { release(): Promise; }; -export type IFileWatchService = { +export type IWatchService = { watch( root: string, - onEvents: (events: RawFileEvent[]) => void, - options?: FileWatchOptions + onEvents: (events: WatchEvent[]) => void, + options?: WatchOptions ): WatchHandle; dispose(): Promise; }; diff --git a/packages/core/src/fs/file-watch-service.test.ts b/packages/core/src/watch/watch-service.test.ts similarity index 91% rename from packages/core/src/fs/file-watch-service.test.ts rename to packages/core/src/watch/watch-service.test.ts index 24e6415409..29a3b363aa 100644 --- a/packages/core/src/fs/file-watch-service.test.ts +++ b/packages/core/src/watch/watch-service.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { FileWatchService, type RawFileEvent } from './index'; +import { WatchService, type WatchEvent } from './index'; async function eventually( read: () => T | undefined, @@ -18,12 +18,12 @@ async function eventually( throw new Error('Timed out waiting for condition'); } -describe('FileWatchService', () => { +describe('WatchService', () => { it('emits real file events through ref-counted leases', async () => { const root = await mkdtemp(path.join(tmpdir(), 'emdash-shared-watch-')); - const watch = new FileWatchService(); - const firstEvents: RawFileEvent[] = []; - const secondEvents: RawFileEvent[] = []; + const watch = new WatchService(); + const firstEvents: WatchEvent[] = []; + const secondEvents: WatchEvent[] = []; try { const first = watch.watch(root, (events) => firstEvents.push(...events)); @@ -66,8 +66,8 @@ describe('FileWatchService', () => { it('keeps the shared subscription alive across concurrent release/re-watch', async () => { const root = await mkdtemp(path.join(tmpdir(), 'emdash-shared-watch-relock-')); - const watch = new FileWatchService(); - const events: RawFileEvent[] = []; + const watch = new WatchService(); + const events: WatchEvent[] = []; try { const first = watch.watch(root, () => {}); @@ -90,7 +90,7 @@ describe('FileWatchService', () => { it('surfaces watcher subscription failures through ready()', async () => { const root = path.join(tmpdir(), `emdash-shared-watch-missing-${Date.now()}`); - const watch = new FileWatchService(); + const watch = new WatchService(); try { const handle = watch.watch(root, () => {}); @@ -110,7 +110,7 @@ describe('FileWatchService', () => { it('disposes active handles by releasing their shared native subscription', async () => { const root = await mkdtemp(path.join(tmpdir(), 'emdash-shared-watch-dispose-')); - const watch = new FileWatchService(); + const watch = new WatchService(); const handle = watch.watch(root, () => {}); await handle.ready(); diff --git a/packages/core/src/fs/file-watch-service.ts b/packages/core/src/watch/watch-service.ts similarity index 86% rename from packages/core/src/fs/file-watch-service.ts rename to packages/core/src/watch/watch-service.ts index e7bec7fe73..1f006c1ab7 100644 --- a/packages/core/src/fs/file-watch-service.ts +++ b/packages/core/src/watch/watch-service.ts @@ -2,19 +2,19 @@ import { toPendingLease, type IDisposable } from '@emdash/shared'; import { ResourceMap } from '../lib'; import { NativeWatch } from './native-watch'; import { realpathOrResolve } from './paths'; -import type { FileWatchOptions, IFileWatchService, RawFileEvent, WatchHandle } from './types'; +import type { WatchOptions, IWatchService, WatchEvent, WatchHandle } from './types'; -export type FileWatchServiceOptions = { +export type WatchServiceOptions = { /** Receives background failures (resubscribe attempts, teardown). */ onError?: (context: string, error: unknown) => void; }; type WatchConsumer = { - onEvents: (events: RawFileEvent[]) => void; + onEvents: (events: WatchEvent[]) => void; onResync?: () => void; releaseResource: () => Promise; debounceMs: number; - pending: RawFileEvent[]; + pending: WatchEvent[]; timer: ReturnType | null; }; @@ -26,14 +26,14 @@ function watchKey(root: string, ignore: string[]): string { return JSON.stringify({ root, ignore }); } -export class FileWatchService implements IFileWatchService, IDisposable { +export class WatchService implements IWatchService, IDisposable { private readonly consumers = new Map>(); private readonly natives = new Map(); private readonly subscriptions: ResourceMap; private readonly onError: (context: string, error: unknown) => void; private disposed = false; - constructor(options: FileWatchServiceOptions = {}) { + constructor(options: WatchServiceOptions = {}) { this.onError = options.onError ?? (() => {}); this.subscriptions = new ResourceMap({ teardown: async (key, native) => { @@ -46,10 +46,10 @@ export class FileWatchService implements IFileWatchService, IDisposable { watch( root: string, - onEvents: (events: RawFileEvent[]) => void, - options: FileWatchOptions = {} + onEvents: (events: WatchEvent[]) => void, + options: WatchOptions = {} ): WatchHandle { - if (this.disposed) throw new Error('FileWatchService disposed'); + if (this.disposed) throw new Error('WatchService disposed'); const normalizedRoot = realpathOrResolve(root); const ignore = normalizeIgnore(options.ignore); const key = watchKey(normalizedRoot, ignore); @@ -114,7 +114,7 @@ export class FileWatchService implements IFileWatchService, IDisposable { this.natives.clear(); } - private deliver(key: string, events: RawFileEvent[]): void { + private deliver(key: string, events: WatchEvent[]): void { const consumerSet = this.consumers.get(key); if (!consumerSet) return; for (const consumer of consumerSet) { @@ -139,7 +139,7 @@ export class FileWatchService implements IFileWatchService, IDisposable { } } -function deliverEvents(consumer: WatchConsumer, events: RawFileEvent[]): void { +function deliverEvents(consumer: WatchConsumer, events: WatchEvent[]): void { if (consumer.debounceMs <= 0) { consumer.onEvents(events); return; diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 9efa85192f..c2ab6f2604 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -5,8 +5,8 @@ export default defineConfig({ deps: 'src/host-dependencies/capability.ts', 'deps-runtime': 'src/host-dependencies/runtime/index.ts', exec: 'src/exec/index.ts', - 'file-tree': 'src/file-tree/index.ts', - fs: 'src/fs/index.ts', + files: 'src/files/index.ts', + watch: 'src/watch/index.ts', git: 'src/git/index.ts', lib: 'src/lib/index.ts', 'agents-plugins': 'src/agents/plugins/index.ts', From d5f4710a80bfc002eba4ebb776415e0b236204ef Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:14:37 -0700 Subject: [PATCH 13/37] feat(ssh): add legacy files runtime adapter --- .../core/runtime/legacy/ssh-files.test.ts | 158 ++++ .../src/main/core/runtime/legacy/ssh-files.ts | 826 ++++++++++++++++++ 2 files changed, 984 insertions(+) create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts new file mode 100644 index 0000000000..773b6bc522 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts @@ -0,0 +1,158 @@ +import { EventEmitter } from 'node:events'; +import type { ClientChannel } from 'ssh2'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import type { FileWatchEvent } from '@shared/core/fs/fs'; +import { LegacySshFilesRuntime } from './ssh-files'; + +type SnapshotRecord = { + kind: 'file' | 'directory'; + path: string; + size?: string; + mtime?: string; +}; + +class FakeExecChannel extends EventEmitter { + readonly stderr = new EventEmitter(); +} + +describe('LegacySshFilesRuntime', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('keeps scoped watches on the existing SSH polling watcher', async () => { + let emitLegacyEvents: ((events: FileWatchEvent[]) => void) | undefined; + const update = vi.fn(); + const close = vi.fn(); + vi.spyOn(SshFileSystem.prototype, 'watch').mockImplementation((cb) => { + emitLegacyEvents = cb; + return { update, close }; + }); + + const runtime = new LegacySshFilesRuntime({} as never); + const updates: unknown[] = []; + const subscription = runtime.watchChanges('/repo', (update) => updates.push(update), { + paths: ['src'], + }); + expect(subscription.success).toBe(true); + expect(update).toHaveBeenCalledWith(['src']); + + emitLegacyEvents?.([ + { type: 'modify', entryType: 'file', path: 'src/notes.md' }, + { type: 'modify', entryType: 'file', path: 'src/node_modules/pkg/index.js' }, + ]); + + expect(updates).toEqual([ + { + kind: 'changes', + changes: [{ kind: 'update', entryType: 'file', path: 'src/notes.md' }], + }, + ]); + + if (subscription.success) subscription.data.unsubscribe(); + expect(close).toHaveBeenCalledTimes(1); + + await runtime.dispose(); + }); + + it('uses recursive snapshot polling for root watches', async () => { + vi.useFakeTimers(); + const watchSpy = vi.spyOn(SshFileSystem.prototype, 'watch'); + const { proxy, exec } = makeSnapshotProxy([ + snapshot([ + { kind: 'file', path: 'README.md', size: '1', mtime: '1' }, + { kind: 'file', path: 'src/a.ts', size: '1', mtime: '1' }, + ]), + snapshot([ + { kind: 'file', path: 'src/a.ts', size: '2', mtime: '2' }, + { kind: 'file', path: 'src/b.ts', size: '1', mtime: '1' }, + { kind: 'file', path: 'node_modules/pkg/index.js', size: '1', mtime: '1' }, + ]), + ]); + + const runtime = new LegacySshFilesRuntime(proxy); + const updates: unknown[] = []; + const subscription = runtime.watchChanges('/repo', (update) => updates.push(update), { + paths: [''], + debounceMs: 100, + }); + + expect(subscription.success).toBe(true); + if (!subscription.success) return; + + await expect(subscription.data.ready()).resolves.toEqual({ success: true, data: undefined }); + expect(updates).toEqual([]); + expect(watchSpy).not.toHaveBeenCalled(); + expect(exec).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + + expect(exec).toHaveBeenCalledTimes(2); + expect(updates).toEqual([ + { + kind: 'changes', + changes: [ + { kind: 'update', path: 'src/a.ts', entryType: 'file' }, + { kind: 'create', path: 'src/b.ts', entryType: 'file' }, + { kind: 'delete', path: 'README.md', entryType: 'file' }, + ], + }, + ]); + + subscription.data.unsubscribe(); + await runtime.dispose(); + }); + + it('returns a disposed error when watched after disposal', async () => { + const runtime = new LegacySshFilesRuntime({} as never); + await runtime.dispose(); + + const subscription = runtime.watchChanges('/repo', () => {}); + + expect(subscription.success).toBe(false); + if (!subscription.success) { + expect(subscription.error).toMatchObject({ + type: 'fs-error', + message: 'LegacySshFilesRuntime disposed', + }); + } + }); +}); + +function snapshot(records: SnapshotRecord[]): Buffer { + const fields = records.flatMap((record) => [ + record.kind, + record.size ?? '1', + record.mtime ?? '1', + record.path, + ]); + return Buffer.from(`${fields.join('\0')}\0`); +} + +function makeSnapshotProxy(snapshots: Buffer[]): { + proxy: SshClientProxy; + exec: ReturnType; +} { + const exec = vi.fn( + (command: string, cb: (err: Error | undefined, stream: ClientChannel) => void) => { + const stream = new FakeExecChannel(); + const stdout = snapshots.shift() ?? Buffer.alloc(0); + cb(undefined, stream as unknown as ClientChannel); + queueMicrotask(() => { + stream.emit('data', stdout); + stream.emit('close', 0); + }); + } + ); + + return { + proxy: { + getRemoteShellProfile: vi.fn().mockResolvedValue({ shell: '/bin/sh', env: {} }), + exec, + } as unknown as SshClientProxy, + exec, + }; +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts new file mode 100644 index 0000000000..068f884f7e --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts @@ -0,0 +1,826 @@ +import path from 'node:path'; +import { + IGNORED_PATH_SEGMENTS, + isIgnored, + normalizeRelPath, + normalizeRelPaths, + type FileChange, + type FileChangeSubscription, + type FileChangeUpdate, + type FileChangeWatchOptions, + type FileEntryType, + type FileError, + type FileNode, + type FileTreeError, + type FileTreeLease, + type FileTreeSequences, + type FileTreeSnapshot, + type FileTreeUpdate, + type IFileTree, + type IFilesRuntime, + type NodeId, + type SubscribedSnapshot, +} from '@emdash/core/files'; +import { LiveCollection, ResourceMap, type KeyedOp } from '@emdash/core/lib'; +import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; +import type { ClientChannel } from 'ssh2'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import { FileSystemError, FileSystemErrorCodes } from '@main/core/fs/types'; +import type { FileEntry } from '@main/core/fs/types'; +import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { log } from '@main/lib/logger'; +import { quoteShellArg } from '@main/utils/shellEscape'; +import type { FileWatchEvent } from '@shared/core/fs/fs'; + +const SSH_FILE_TREE_POLL_MS = 4_000; +const SSH_FILE_CHANGE_POLL_MS = 4_000; + +type LegacyListedEntry = { + path: string; + name: string; + type: 'file' | 'directory'; +}; + +type ChangeFeedHandle = { + close(): void; +}; + +type LegacySshSnapshotEntry = { + entryType: Exclude; + size: string; + mtime: string; +}; + +/** + * Transitional SSH file-domain adapter. + * + * It preserves the existing SFTP-backed tree listing and path-scoped polling + * behavior until the core runtime can run inside the remote workspace server. + */ +export class LegacySshFilesRuntime implements IFilesRuntime { + private readonly trees: ResourceMap; + private readonly changeFeeds = new Set(); + private disposeRequested = false; + + constructor(private readonly proxy: SshClientProxy) { + this.trees = new ResourceMap({ + teardown: (_rootPath, tree) => tree.dispose(), + onError: (context, error) => + log.warn('LegacySshFilesRuntime: teardown failed', { + context, + error: String(error), + }), + }); + } + + async openTree(rootPath: string): Promise> { + const normalizedRoot = normalizeRemoteRootPath(rootPath); + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: '', + message: 'LegacySshFilesRuntime disposed', + }); + } + + const lease = await this.trees.acquire(normalizedRoot, async () => { + return new LegacySshFileTree(this.proxy, normalizedRoot, (context, error) => + log.warn('LegacySshFilesRuntime: background error', { + context, + error: String(error), + }) + ); + }); + + try { + const ready = await lease.value.ready(); + if (!ready.success) { + await lease.release(); + return err(ready.error); + } + return ok(lease); + } catch (error) { + await lease.release(); + throw error; + } + } + + watchChanges( + rootPath: string, + cb: (update: FileChangeUpdate) => void, + options: FileChangeWatchOptions = {} + ): Result { + const normalizedRoot = normalizeRemoteRootPath(rootPath); + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: normalizedRoot, + message: 'LegacySshFilesRuntime disposed', + }); + } + + const paths = normalizeWatchedPaths(options.paths); + if (!paths.success) return paths; + + if (watchesWholeRoot(paths.data)) { + const feed = new LegacySshRecursiveChangeFeed( + this.proxy, + normalizedRoot, + cb, + (context, error) => + log.warn('LegacySshFilesRuntime: background error', { + context, + error: String(error), + }), + options.debounceMs ?? SSH_FILE_CHANGE_POLL_MS + ); + this.changeFeeds.add(feed); + + let unsubscribed = false; + const unsubscribe = () => { + if (unsubscribed) return; + unsubscribed = true; + this.changeFeeds.delete(feed); + feed.close(); + }; + + return ok({ + ready: () => feed.ready(), + unsubscribe, + }); + } + + const fs = new SshFileSystem(this.proxy, normalizedRoot); + const watcher = fs.watch( + (events) => { + const changes = eventsToChanges(events); + if (changes.length > 0) cb({ kind: 'changes', changes }); + }, + { debounceMs: options.debounceMs } + ); + watcher.update(paths.data); + this.changeFeeds.add(watcher); + + let unsubscribed = false; + const unsubscribe = () => { + if (unsubscribed) return; + unsubscribed = true; + this.changeFeeds.delete(watcher); + watcher.close(); + }; + + return ok({ + ready: async () => ok(), + unsubscribe, + }); + } + + async dispose(): Promise { + if (this.disposeRequested) return; + this.disposeRequested = true; + for (const feed of this.changeFeeds) feed.close(); + this.changeFeeds.clear(); + await this.trees.dispose(); + } +} + +class LegacySshRecursiveChangeFeed implements ChangeFeedHandle { + private snapshot: Map | null = null; + private timer: ReturnType | undefined; + private pollInFlight = false; + private closed = false; + private readonly readyPromise: Promise>; + + constructor( + private readonly proxy: SshClientProxy, + private readonly rootPath: string, + private readonly cb: (update: FileChangeUpdate) => void, + private readonly onError: (context: string, error: unknown) => void, + private readonly pollIntervalMs: number + ) { + this.readyPromise = this.initialize(); + } + + ready(): Promise> { + return this.readyPromise; + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.timer) clearInterval(this.timer); + } + + private async initialize(): Promise> { + const scanned = await this.scan(); + if (!scanned.success) return err(scanned.error); + if (this.closed) return ok(); + + this.snapshot = scanned.data; + this.timer = setInterval(() => { + void this.poll(); + }, this.pollIntervalMs); + return ok(); + } + + private async poll(): Promise { + if (this.closed || this.pollInFlight) return; + + this.pollInFlight = true; + try { + const scanned = await this.scan(); + if (this.closed) return; + if (!scanned.success) { + this.onError(`ssh file changes scan ${this.rootPath}`, scanned.error); + this.cb({ kind: 'resync' }); + return; + } + + if (!this.snapshot) { + this.snapshot = scanned.data; + return; + } + + const changes = diffRecursiveSnapshots(this.snapshot, scanned.data); + this.snapshot = scanned.data; + if (changes.length > 0) this.cb({ kind: 'changes', changes }); + } finally { + this.pollInFlight = false; + } + } + + private async scan(): Promise, FileError>> { + try { + const result = await execRemoteBuffer( + this.proxy, + buildRecursiveSnapshotCommand(this.rootPath) + ); + if (result.exitCode !== 0) { + return err({ + type: 'fs-error', + path: this.rootPath, + message: result.stderr || `Remote file snapshot exited with code ${result.exitCode}`, + }); + } + return ok(parseRecursiveSnapshot(result.stdout)); + } catch (error) { + return err(toFileError(error, this.rootPath)); + } + } +} + +class LegacySshFileTree implements IFileTree { + readonly rootPath: string; + private readonly collection = new LiveCollection({ + scopeOf: (node) => node.parentId, + }); + private readonly fs: SshFileSystem; + private readonly pathToId = new Map(); + private readonly nodes = new Map(); + private readonly childrenByParent = new Map>(); + private readonly scopeLoads = new Map< + NodeId | null, + Promise> + >(); + private readonly pollTimer: ReturnType; + private nextId = 1; + private disposed = false; + private readyPromise: Promise> | null = null; + + constructor( + proxy: SshClientProxy, + rootPath: string, + private readonly onError: (context: string, error: unknown) => void + ) { + this.rootPath = rootPath; + this.fs = new SshFileSystem(proxy, rootPath); + this.pollTimer = setInterval(() => { + if (this.collection.subscriberCount === 0) return; + void this.refreshLoadedScopes().then( + (result) => { + if (!result.success) this.onError(`ssh file-tree refresh ${this.rootPath}`, result.error); + }, + (error) => this.onError(`ssh file-tree refresh ${this.rootPath}`, error) + ); + }, SSH_FILE_TREE_POLL_MS); + } + + async ready(): Promise> { + if (this.readyPromise) return this.readyPromise; + + const readyPromise = (async (): Promise> => { + const loaded = await this.loadDirectoryScope(null); + if (!loaded.success) return err(loaded.error); + return ok(); + })().catch((error): Result => { + if (this.readyPromise === readyPromise) { + this.readyPromise = null; + } + throw error; + }); + this.readyPromise = readyPromise; + return readyPromise; + } + + async getSnapshot(): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + return ok(this.collection.getCached()); + } + + subscribe(cb: (update: FileTreeUpdate) => void): Unsubscribe { + return this.collection.subscribe(cb); + } + + async subscribeWithSnapshot( + cb: (update: FileTreeUpdate) => void + ): Promise, FileTreeError>> { + const unsubscribe = this.subscribe(cb); + const snapshot = await this.getSnapshot(); + if (!snapshot.success) { + unsubscribe(); + return err(snapshot.error); + } + return ok({ snapshot: snapshot.data, unsubscribe }); + } + + async expandDir(dirId: NodeId | null): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + return this.loadDirectoryScope(dirId); + } + + async revealPath(pathToReveal: string): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + const normalized = normalizeRelPath(pathToReveal); + if (!normalized.success) return normalized; + + const parts = normalized.data.split('/').filter(Boolean); + let sequences: FileTreeSequences = {}; + for (let index = 0; index < parts.length; index += 1) { + const relPath = parts.slice(0, index + 1).join('/'); + const node = this.getByPath(relPath); + if (!node) return err({ type: 'not-found', path: relPath }); + const shouldExpand = index < parts.length - 1 || node.type === 'directory'; + if (!shouldExpand) continue; + if (node.type !== 'directory') { + return err({ type: 'not-directory', id: node.id, path: node.path }); + } + const expanded = await this.loadDirectoryScope(node.id); + if (!expanded.success) return expanded; + sequences = mergeSequences(sequences, expanded.data); + } + return ok(sequences); + } + + async refresh(): Promise> { + const refreshed = await this.refreshLoadedScopes(); + if (!refreshed.success) return err(refreshed.error); + return ok(this.collection.getCached()); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + clearInterval(this.pollTimer); + this.collection.dispose(); + } + + private async refreshLoadedScopes(): Promise> { + const scopes = this.collection.loadedScopes(); + let sequences: FileTreeSequences = {}; + for (const scope of scopes) { + if (scope !== null && !this.nodes.has(scope)) continue; + const refreshed = await this.loadDirectoryScope(scope); + if (!refreshed.success) { + const recovered = this.recoverMissingLoadedScope(scope, refreshed.error); + if (!recovered.success) return err(recovered.error); + sequences = mergeSequences(sequences, recovered.data); + continue; + } + sequences = mergeSequences(sequences, refreshed.data); + } + return ok(sequences); + } + + private async loadDirectoryScope( + scope: NodeId | null + ): Promise> { + const existing = this.scopeLoads.get(scope); + if (existing) return existing; + + const loading = this.loadDirectoryScopeInternal(scope); + this.scopeLoads.set(scope, loading); + void loading.finally(() => { + if (this.scopeLoads.get(scope) === loading) this.scopeLoads.delete(scope); + }); + return loading; + } + + private async loadDirectoryScopeInternal( + scope: NodeId | null + ): Promise> { + const dirNode = scope === null ? null : this.nodes.get(scope); + if (scope !== null && !dirNode) return err({ type: 'not-found', id: scope }); + if (dirNode && dirNode.type !== 'directory') { + return err({ type: 'not-directory', id: dirNode.id, path: dirNode.path }); + } + + const dirPath = dirNode?.path ?? ''; + const listed = await this.listChildren(dirPath); + if (!listed.success) return listed; + + const listedPaths = new Set(listed.data.map((entry) => entry.path)); + let sequence = this.removeMissingChildren(scope, listedPaths); + const nodes = listed.data.map((entry) => + this.upsertNode(entry, scope, this.getByPath(entry.path)?.childrenLoaded) + ); + const loaded = await this.collection.loadScope(scope, async () => + ok(nodes.map((node) => [node.id, node] as const)) + ); + if (!loaded.success) return loaded; + sequence = Math.max(sequence, loaded.data); + + if (dirNode && !dirNode.childrenLoaded) { + const updated = { ...dirNode, childrenLoaded: true }; + this.setNode(updated); + sequence = Math.max(sequence, this.collection.put(updated.id, updated)); + } + + return ok(sequence === 0 ? {} : { tree: sequence }); + } + + private async listChildren(dirPath: string): Promise> { + const normalized = normalizeRelPath(dirPath, { allowEmpty: true }); + if (!normalized.success) return normalized; + + try { + const result = await this.fs.list(normalized.data, { includeHidden: true }); + const entries: LegacyListedEntry[] = []; + for (const entry of result.entries) { + const relPath = entry.path.replace(/\\/g, '/'); + if (isIgnored(relPath)) continue; + if (entry.type !== 'dir' && entry.type !== 'file') continue; + entries.push(toListedEntry(entry)); + } + entries.sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return ok(entries); + } catch (error) { + return err(toFileTreeError(error, normalized.data)); + } + } + + private removeMissingChildren(parentId: NodeId | null, listedPaths: Set): number { + const missing = this.childrenOf(parentId) + .filter((node) => !listedPaths.has(node.path)) + .map((node) => node.id); + return this.removeSubtrees(missing); + } + + private removeSubtrees(rootIds: NodeId[]): number { + const ops: Array> = []; + const removedScopes: NodeId[] = []; + for (const rootId of rootIds) { + const removed = this.removeSubtree(rootId); + for (const node of removed) { + ops.push({ op: 'del', key: node.id }); + if (node.type === 'directory') removedScopes.push(node.id); + } + } + + let sequence = this.collection.apply(ops); + for (const scope of removedScopes) + sequence = Math.max(sequence, this.collection.unloadScope(scope)); + return sequence; + } + + private recoverMissingLoadedScope( + scope: NodeId | null, + error: FileTreeError + ): Result { + if (scope === null || (error.type !== 'not-found' && error.type !== 'not-directory')) { + return err(error); + } + + const sequence = this.removeSubtrees([scope]); + return ok(sequence === 0 ? {} : { tree: sequence }); + } + + private getByPath(path: string): FileNode | undefined { + const id = this.pathToId.get(path); + return id === undefined ? undefined : this.nodes.get(id); + } + + private upsertNode( + entry: LegacyListedEntry, + parentId: NodeId | null, + childrenLoaded?: boolean + ): FileNode { + const existingId = this.pathToId.get(entry.path); + const id = existingId ?? this.nextId++; + const previous = this.nodes.get(id); + const node: FileNode = { + id, + path: entry.path, + name: entry.name, + parentId, + type: entry.type, + childrenLoaded: + entry.type === 'directory' ? (childrenLoaded ?? previous?.childrenLoaded ?? false) : false, + }; + this.setNode(node); + return node; + } + + private setNode(node: FileNode): void { + const previous = this.nodes.get(node.id); + if (previous) { + this.pathToId.delete(previous.path); + this.removeChild(previous.parentId, node.id); + } + this.pathToId.set(node.path, node.id); + this.addChild(node.parentId, node.id); + this.nodes.set(node.id, node); + } + + private removeSubtree(rootId: NodeId): FileNode[] { + const removed: FileNode[] = []; + const visit = (id: NodeId) => { + const node = this.nodes.get(id); + if (!node) return; + for (const child of this.childrenOf(id)) visit(child.id); + this.removeNode(id); + removed.push(node); + }; + visit(rootId); + return removed; + } + + private removeNode(id: NodeId): void { + const node = this.nodes.get(id); + if (!node) return; + this.pathToId.delete(node.path); + this.removeChild(node.parentId, id); + this.nodes.delete(id); + } + + private childrenOf(parentId: NodeId | null): FileNode[] { + const ids = this.childrenByParent.get(parentId); + if (!ids) return []; + const children: FileNode[] = []; + for (const id of ids) { + const node = this.nodes.get(id); + if (node) children.push(node); + } + return children; + } + + private addChild(parentId: NodeId | null, id: NodeId): void { + let children = this.childrenByParent.get(parentId); + if (!children) { + children = new Set(); + this.childrenByParent.set(parentId, children); + } + children.add(id); + } + + private removeChild(parentId: NodeId | null, id: NodeId): void { + const children = this.childrenByParent.get(parentId); + if (!children) return; + children.delete(id); + if (children.size === 0) this.childrenByParent.delete(parentId); + } +} + +function normalizeWatchedPaths(paths: string[] | undefined): Result { + if (!paths || paths.length === 0) return ok(['']); + return normalizeRelPaths(paths, { allowEmpty: true }); +} + +function watchesWholeRoot(paths: string[]): boolean { + return paths.includes(''); +} + +function eventsToChanges(events: FileWatchEvent[]): FileChange[] { + const changes: FileChange[] = []; + for (const event of events) { + if (isIgnored(event.path)) continue; + if (event.type === 'rename') { + if (event.oldPath && !isIgnored(event.oldPath)) { + changes.push({ kind: 'delete', path: event.oldPath, entryType: event.entryType }); + } + changes.push({ kind: 'create', path: event.path, entryType: event.entryType }); + continue; + } + changes.push({ + kind: event.type === 'modify' ? 'update' : event.type, + path: event.path, + entryType: event.entryType, + }); + } + return changes; +} + +function diffRecursiveSnapshots( + previous: Map, + next: Map +): FileChange[] { + const changes: FileChange[] = []; + + for (const [entryPath, entry] of next) { + const previousEntry = previous.get(entryPath); + if (!previousEntry) { + changes.push({ kind: 'create', path: entryPath, entryType: entry.entryType }); + continue; + } + if (snapshotEntryChanged(previousEntry, entry)) { + changes.push({ kind: 'update', path: entryPath, entryType: entry.entryType }); + } + } + + for (const [entryPath, entry] of previous) { + if (!next.has(entryPath)) { + changes.push({ kind: 'delete', path: entryPath, entryType: entry.entryType }); + } + } + + return changes; +} + +function snapshotEntryChanged( + previous: LegacySshSnapshotEntry, + next: LegacySshSnapshotEntry +): boolean { + return ( + previous.entryType !== next.entryType || + previous.size !== next.size || + previous.mtime !== next.mtime + ); +} + +async function execRemoteBuffer( + proxy: SshClientProxy, + command: string +): Promise<{ stdout: Buffer; stderr: string; exitCode: number }> { + const profile = await proxy.getRemoteShellProfile(); + const fullCommand = buildRemoteShellCommand(profile, command); + + return new Promise((resolve, reject) => { + proxy.exec(fullCommand, (err, stream) => { + if (err) { + reject(err); + return; + } + + readExecStream(stream, resolve, reject); + }); + }); +} + +function readExecStream( + stream: ClientChannel, + resolve: (value: { stdout: Buffer; stderr: string; exitCode: number }) => void, + reject: (reason?: unknown) => void +): void { + const stdout: Buffer[] = []; + let stderr = ''; + let settled = false; + + stream.on('data', (chunk: Buffer) => { + stdout.push(Buffer.from(chunk)); + }); + stream.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + stream.on('close', (exitCode: number | null) => { + if (settled) return; + settled = true; + resolve({ + stdout: Buffer.concat(stdout), + stderr: stderr.trim(), + exitCode: exitCode ?? -1, + }); + }); + stream.on('error', (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }); +} + +function buildRecursiveSnapshotCommand(rootPath: string): string { + const pruneExpression = buildFindPruneExpression(); + const snapshotScript = ` +stat_style=$1 +shift +for p do + rel=\${p#./} + [ "$rel" = "." ] && continue + if [ -d "$p" ]; then kind=directory; else kind=file; fi + if [ "$stat_style" = gnu ]; then + meta=$(stat -c '%s %Y' -- "$p" 2>/dev/null) || continue + else + meta=$(stat -f '%z %m' -- "$p" 2>/dev/null) || continue + fi + size=\${meta%% *} + mtime=\${meta#* } + printf '%s\\0%s\\0%s\\0%s\\0' "$kind" "$size" "$mtime" "$rel" +done +`.trim(); + + return [ + `cd ${quoteShellArg(rootPath)} || exit 1`, + "if stat -c '%s %Y' . >/dev/null 2>&1; then stat_style=gnu; elif stat -f '%z %m' . >/dev/null 2>&1; then stat_style=bsd; else exit 2; fi", + `find . ${pruneExpression}\\( -type f -o -type d \\) -exec sh -c ${quoteShellArg( + snapshotScript + )} sh "$stat_style" {} +`, + ].join('\n'); +} + +function buildFindPruneExpression(): string { + const ignoredNames = IGNORED_PATH_SEGMENTS.map((name) => `-name ${quoteShellArg(name)}`).join( + ' -o ' + ); + return ignoredNames ? `\\( ${ignoredNames} \\) -prune -o ` : ''; +} + +function parseRecursiveSnapshot(stdout: Buffer): Map { + const entries = new Map(); + const fields = stdout.toString('utf8').split('\0'); + + for (let index = 0; index + 3 < fields.length; index += 4) { + const entryType = parseSnapshotEntryType(fields[index]); + if (!entryType) continue; + + const size = fields[index + 1]; + const mtime = fields[index + 2]; + const normalized = normalizeRelPath(fields[index + 3]); + if (!normalized.success || isIgnored(normalized.data)) continue; + + entries.set(normalized.data, { + entryType, + size, + mtime, + }); + } + + return entries; +} + +function parseSnapshotEntryType(raw: string): Exclude | null { + if (raw === 'file' || raw === 'directory') return raw; + return null; +} + +function toListedEntry(entry: FileEntry): LegacyListedEntry { + const relPath = entry.path.replace(/\\/g, '/'); + return { + path: relPath, + name: path.posix.basename(relPath), + type: entry.type === 'dir' ? 'directory' : 'file', + }; +} + +function toFileTreeError(error: unknown, relPath: string): FileTreeError { + if (error instanceof FileSystemError) { + if (error.code === FileSystemErrorCodes.NOT_FOUND) return { type: 'not-found', path: relPath }; + if (error.code === FileSystemErrorCodes.NOT_DIRECTORY) { + return { type: 'not-directory', path: relPath }; + } + if ( + error.code === FileSystemErrorCodes.INVALID_PATH || + error.code === FileSystemErrorCodes.PATH_ESCAPE + ) { + return { type: 'invalid-path', path: relPath, message: error.message }; + } + return { type: 'fs-error', path: relPath, message: error.message }; + } + return { type: 'fs-error', path: relPath, message: String(error) }; +} + +function toFileError(error: unknown, path: string): FileError { + if (error instanceof FileSystemError) { + if ( + error.code === FileSystemErrorCodes.INVALID_PATH || + error.code === FileSystemErrorCodes.PATH_ESCAPE + ) { + return { type: 'invalid-path', path, message: error.message }; + } + return { type: 'fs-error', path, message: error.message }; + } + return { type: 'fs-error', path, message: String(error) }; +} + +function normalizeRemoteRootPath(rootPath: string): string { + const normalized = path.posix.normalize(rootPath.replace(/\\/g, '/')); + return path.posix.isAbsolute(normalized) ? normalized : path.posix.resolve('/', normalized); +} + +function mergeSequences(left: FileTreeSequences, right: FileTreeSequences): FileTreeSequences { + return { tree: Math.max(left.tree ?? 0, right.tree ?? 0) || undefined }; +} From eddfe9e5307d1d69ea0c65b8a2bffb91f74ae4a3 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:14:49 -0700 Subject: [PATCH 14/37] refactor(desktop): consume files runtime updates --- .../src/main/core/fs/controller.ts | 78 +-- .../src/main/core/fs/file-tree/controller.ts | 2 +- .../src/main/core/fs/fs-events.ts | 31 -- .../src/main/core/fs/impl/local-fs.ts | 81 +--- .../src/main/core/fs/impl/ssh-fs.ts | 2 +- .../main/core/fs/test-helpers/memory-fs.ts | 7 - apps/emdash-desktop/src/main/core/fs/types.ts | 24 +- .../core/runtime/legacy/ssh-file-tree.test.ts | 10 +- .../main/core/runtime/legacy/ssh-file-tree.ts | 446 ------------------ .../src/main/core/runtime/runtime-manager.ts | 16 +- .../src/main/core/runtime/types.ts | 4 +- .../search/workspace-file-index-service.ts | 9 +- .../main/core/workspaces/workspace-factory.ts | 38 +- .../src/main/core/workspaces/workspace.ts | 2 +- .../projects/stores/project-settings-store.ts | 9 +- .../tasks/editor/stores/files-store.test.ts | 2 +- .../tasks/editor/stores/files-store.ts | 2 +- .../features/tasks/file-tree/tree-utils.ts | 2 +- .../tasks/stores/lifecycle-scripts.test.ts | 29 +- .../tasks/stores/lifecycle-scripts.ts | 34 +- .../tasks/stores/workspace-view-model.test.ts | 4 - .../lib/monaco/invalidation-bridges.ts | 38 +- .../lib/monaco/monaco-model-registry.ts | 4 +- .../src/shared/core/fs/file-tree-errors.ts | 2 +- .../src/shared/core/fs/file-tree.ts | 2 +- .../src/shared/core/fs/fsEvents.ts | 12 +- 26 files changed, 114 insertions(+), 776 deletions(-) delete mode 100644 apps/emdash-desktop/src/main/core/fs/fs-events.ts delete mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts diff --git a/apps/emdash-desktop/src/main/core/fs/controller.ts b/apps/emdash-desktop/src/main/core/fs/controller.ts index 6192fc7828..e7d749a199 100644 --- a/apps/emdash-desktop/src/main/core/fs/controller.ts +++ b/apps/emdash-desktop/src/main/core/fs/controller.ts @@ -1,36 +1,20 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { err, ok } from '@emdash/shared'; -import { fsEvents } from '@main/core/fs/fs-events'; import { events } from '@main/lib/events'; -import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; import { planEventChannel } from '@shared/events/appEvents'; import { createRPCController } from '@shared/lib/ipc/rpc'; import { resolveWorkspace } from '../projects/utils'; -import { - FileSystemErrorCodes, - type FileWatcher, - type ListOptions, - type SearchOptions, -} from './types'; +import { FileSystemErrorCodes, type ListOptions, type SearchOptions } from './types'; /** * Legacy workspace filesystem RPC surface. * - * Keep this for non-tree file operations: editor read/write/image/copy, Monaco - * invalidation, lifecycle script config watches, project settings, and related - * workspace services. The file tree no longer uses this surface; new tree code - * should go through `workspace.fileTree` / `@emdash/core/file-tree`. + * Keep this for non-tree file operations: editor read/write/image/copy, + * project setup, and related workspace services. File tree and file-change + * subscriptions go through the runtime-owned file domain. */ -// One watcher per (projectId, workspaceId) pair, shared across all consumers via labels. -// Local: single recursive @parcel/watcher subscription — update() is a no-op. -// SSH: poll-based — update() receives the union of all labels' paths to poll. -const watcherRegistry = new Map(); -// Per-label path groups, keyed by `${projectId}::${workspaceId}` → label → paths. -// Paths are forwarded to update() for SSH compatibility; local ignores them. -const watcherLabeledPaths = new Map>(); - function normalizeRelativePath(filePath: string, options?: { allowEmpty?: boolean }): string { if (!filePath && options?.allowEmpty) return ''; const normalized = path.posix.normalize(filePath.replaceAll('\\', '/')); @@ -303,58 +287,4 @@ export const filesController = createRPCController({ return err({ type: 'fs_error' as const, message: String(e) }); } }, - - watchSetPaths: async ( - projectId: string, - workspaceId: string, - paths: string[], - label = 'default' - ) => { - // Legacy raw filesystem watch channel. Do not use this for the file tree; - // tree subscriptions are served by `workspace.fileTree` and `fileTreeUpdateChannel`. - const env = resolveWorkspace(projectId, workspaceId); - if (!env) { - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - } - - if (!env.fs.watch) { - return ok({ supported: false as const }); - } - - const key = `${projectId}::${workspaceId}`; - const groups = watcherLabeledPaths.get(key) ?? new Map(); - groups.set(label, paths); - watcherLabeledPaths.set(key, groups); - const union = [...new Set([...groups.values()].flat())]; - - const existing = watcherRegistry.get(key); - if (existing) { - existing.update(union); - } else { - const watcher = env.fs.watch((evts) => { - const event = { projectId, workspaceId, events: evts }; - events.emit(fsWatchEventChannel, event); - fsEvents.emitWatchEvent(event); - }); - watcher.update(union); - watcherRegistry.set(key, watcher); - } - return ok({ supported: true as const }); - }, - - watchStop: async (projectId: string, workspaceId: string, label = 'default') => { - const key = `${projectId}::${workspaceId}`; - const groups = watcherLabeledPaths.get(key); - groups?.delete(label); - - if (!groups?.size) { - watcherLabeledPaths.delete(key); - watcherRegistry.get(key)?.close(); - watcherRegistry.delete(key); - } else { - const union = [...new Set([...groups.values()].flat())]; - watcherRegistry.get(key)?.update(union); - } - return ok({}); - }, }); diff --git a/apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts b/apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts index 564fe59f63..7d7d9f98ac 100644 --- a/apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts +++ b/apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts @@ -1,4 +1,4 @@ -import type { NodeId } from '@emdash/core/file-tree'; +import type { NodeId } from '@emdash/core/files'; import { err, ok } from '@emdash/shared'; import { resolveWorkspace } from '@main/core/projects/utils'; import type { FileTreeMutationResult, FileTreeSnapshotResult } from '@shared/core/fs/file-tree'; diff --git a/apps/emdash-desktop/src/main/core/fs/fs-events.ts b/apps/emdash-desktop/src/main/core/fs/fs-events.ts deleted file mode 100644 index d3ad86a75f..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/fs-events.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { HookCore, type Hookable } from '@main/lib/hookable'; -import { log } from '@main/lib/logger'; -import type { FileWatchEvent } from '@shared/core/fs/fs'; - -export type FsHooks = { - 'watch:event': (event: { - projectId: string; - workspaceId: string; - events: FileWatchEvent[]; - }) => void | Promise; -}; - -class FsEvents implements Hookable { - private readonly _core = new HookCore((name, e) => - log.error(`FsEvents: ${String(name)} hook error`, e) - ); - - on(name: K, handler: FsHooks[K]) { - return this._core.on(name, handler); - } - - emitWatchEvent(event: { - projectId: string; - workspaceId: string; - events: FileWatchEvent[]; - }): void { - this._core.callHookBackground('watch:event', event); - } -} - -export const fsEvents = new FsEvents(); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts index df3b90e7d1..c23fbff601 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts @@ -1,19 +1,16 @@ -import { createReadStream, promises as fs, statSync, type Stats } from 'node:fs'; +import { createReadStream, promises as fs, type Stats } from 'node:fs'; import { basename, dirname, extname, join, relative, resolve } from 'node:path'; import { createInterface } from 'node:readline'; -import { isIgnored, resolveInsideRoot, watchIgnoreGlobs } from '@emdash/core/file-tree'; -import parcelWatcher from '@parcel/watcher'; +import { isIgnored, resolveInsideRoot } from '@emdash/core/files'; import { glob } from 'glob'; import ignore from 'ignore'; import { log } from '@main/lib/logger'; -import type { FileWatchEvent } from '@shared/core/fs/fs'; import { FileSystemError, FileSystemErrorCodes, type FileEntry, type FileListResult, type FileSystemProvider, - type FileWatcher, type ListOptions, type ReadResult, type SearchMatch, @@ -97,7 +94,7 @@ const IMAGE_MIME_TYPES: Record = { * Legacy local `FileSystemProvider` implementation. * * Keep for non-tree workspace file operations. The editor file tree uses - * `@emdash/core/file-tree` directly and should not add new behavior here. + * `@emdash/core/files` directly and should not add new behavior here. */ export class LocalFileSystem implements FileSystemProvider { private listAbort: AbortController | null = null; @@ -682,76 +679,4 @@ export class LocalFileSystem implements FileSystemProvider { async copyLocalFile(localAbsPath: string, destRelPath: string): Promise { await fs.copyFile(localAbsPath, this.resolveWorkspacePath(destRelPath)); } - - watch( - callback: (events: FileWatchEvent[]) => void, - options: { debounceMs?: number } = {} - ): FileWatcher { - const stabilityMs = options.debounceMs ?? 200; - let pending: FileWatchEvent[] = []; - let flushTimer: ReturnType | null = null; - // Set when the async subscribe resolves; used by close() if it resolves after close() is called. - let resolvedSub: parcelWatcher.AsyncSubscription | null = null; - let closed = false; - - const flush = () => { - if (pending.length) { - callback(pending); - pending = []; - } - }; - - const enqueue = (evt: FileWatchEvent) => { - pending.push(evt); - if (flushTimer) clearTimeout(flushTimer); - flushTimer = setTimeout(flush, stabilityMs); - }; - - const toRel = (absPath: string) => relative(this.projectPath, absPath).replace(/\\/g, '/'); - - void parcelWatcher - .subscribe( - this.projectPath, - (err, events) => { - if (err) return; - for (const e of events) { - const rel = toRel(e.path); - // Skip paths outside the project root (shouldn't happen, but guard anyway). - if (rel.startsWith('..')) continue; - - let entryType: 'file' | 'directory' = 'file'; - if (e.type !== 'delete') { - try { - entryType = statSync(e.path).isDirectory() ? 'directory' : 'file'; - } catch { - // File removed between the event and the stat — treat as file. - } - } - const type = e.type === 'update' ? ('modify' as const) : e.type; - enqueue({ type, entryType, path: rel }); - } - }, - { ignore: watchIgnoreGlobs() } - ) - .then((sub) => { - if (closed) { - void sub.unsubscribe(); - } else { - resolvedSub = sub; - } - }) - .catch(() => { - // Subscription failed (e.g. project path removed before watch started). - }); - - return { - // No-op: the recursive subscription already covers the entire worktree. - update(_paths: string[]) {}, - close() { - closed = true; - if (flushTimer) clearTimeout(flushTimer); - if (resolvedSub) void resolvedSub.unsubscribe(); - }, - }; - } } diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts index 737e1a8086..103f06cb02 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts @@ -62,7 +62,7 @@ function fileEntryMetadataChanged(prev: FileEntry, next: FileEntry): boolean { * Legacy SSH `FileSystemProvider` implementation using SFTP/SSH exec. * * This remains active for non-tree file operations and transitional SSH - * adapters. The editor file tree uses `LegacySshFileTreeRuntime` only as a + * adapters. The editor file tree uses `LegacySshFilesRuntime` only as a * temporary bridge until the `@emdash/core` file-tree runtime can run where the * remote workspace lives. */ diff --git a/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts b/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts index 37da3b4380..a3f76603cb 100644 --- a/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts +++ b/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts @@ -78,11 +78,4 @@ export class MemoryFs implements FileSystemProvider { } async mkdir(): Promise {} - - watch(): { update(paths: string[]): void; close(): void } { - return { - update: () => {}, - close: () => {}, - }; - } } diff --git a/apps/emdash-desktop/src/main/core/fs/types.ts b/apps/emdash-desktop/src/main/core/fs/types.ts index 1193d9b512..42ad0caaff 100644 --- a/apps/emdash-desktop/src/main/core/fs/types.ts +++ b/apps/emdash-desktop/src/main/core/fs/types.ts @@ -3,11 +3,11 @@ * Provides unified interface for local and remote (SSH/SFTP) filesystem operations */ -import type { FileWatchEvent } from '@shared/core/fs/fs'; - /** - * Handle returned by FileSystemProvider.watch(). - * Call update() to change the set of watched paths, close() to stop. + * Transitional SSH polling watcher handle. + * + * Runtime-owned file change feeds use this internally for the temporary SSH + * adapter; the renderer-facing legacy watch RPC has been removed. */ export interface FileWatcher { update(paths: string[]): void; @@ -142,7 +142,7 @@ export interface SearchMatch { * This provider remains active for non-tree workspace file operations * (read/write/image/copy/search/config watches/project setup). Do not extend it * for the editor file tree; file-tree reads, scopes, and deltas live in - * `@emdash/core/file-tree` and are exposed through `workspace.fileTree`. + * `@emdash/core/files` and are exposed through `workspace.fileTree`. * * Longer term this desktop-side provider should disappear behind filesystem APIs * owned by `@emdash/core`. Those APIs should run where the workspace lives and @@ -272,20 +272,6 @@ export interface FileSystemProvider { * @param destRelPath - Destination path relative to this filesystem's root */ copyLocalFile?(localAbsPath: string, destRelPath: string): Promise; - - /** - * Watch the worktree for filesystem changes. Returns a FileWatcher handle; - * call update() to hint which paths matter (SSH uses this for polling), - * call close() to stop. Batches events and delivers them via callback. - * Optional — not all implementations support watching. - * - * Local: uses @parcel/watcher for a single recursive native-OS subscription. - * SSH: polls directories passed to update() at a fixed interval. - */ - watch?( - callback: (events: FileWatchEvent[]) => void, - options?: { debounceMs?: number } - ): FileWatcher; } /** diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts index 1df2cbd921..883d443d0b 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { FileEntry, FileListResult } from '@main/core/fs/types'; -import { LegacySshFileTreeRuntime } from './ssh-file-tree'; +import { LegacySshFilesRuntime } from './ssh-files'; function listResult(entries: FileEntry[]): FileListResult { return { entries, total: entries.length }; @@ -27,7 +27,7 @@ function dirEntry(path: string): FileEntry { }; } -describe('LegacySshFileTreeRuntime', () => { +describe('LegacySshFilesRuntime file tree', () => { afterEach(() => { vi.restoreAllMocks(); }); @@ -39,8 +39,8 @@ describe('LegacySshFileTreeRuntime', () => { return listResult([]); }); - const runtime = new LegacySshFileTreeRuntime({} as never); - const opened = await runtime.open('/repo'); + const runtime = new LegacySshFilesRuntime({} as never); + const opened = await runtime.openTree('/repo'); expect(opened.success).toBe(true); if (!opened.success) return; @@ -69,7 +69,7 @@ describe('LegacySshFileTreeRuntime', () => { expandedSnapshot.data.entries.find(([, node]) => node.path === 'src/index.ts')?.[1] ).toMatchObject({ parentId: src.id }); - opened.data.release(); + await opened.data.release(); await runtime.dispose(); }); }); diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts deleted file mode 100644 index 728d8e940b..0000000000 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.ts +++ /dev/null @@ -1,446 +0,0 @@ -import path from 'node:path'; -import { - isIgnored, - normalizeRelPath, - type FileNode, - type FileTreeError, - type FileTreeLease, - type FileTreeSequences, - type FileTreeSnapshot, - type FileTreeUpdate, - type IFileTree, - type IFileTreeRuntime, - type NodeId, - type SubscribedSnapshot, -} from '@emdash/core/file-tree'; -import { LiveCollection, ResourceMap, type KeyedOp } from '@emdash/core/lib'; -import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import { FileSystemError, FileSystemErrorCodes } from '@main/core/fs/types'; -import type { FileEntry } from '@main/core/fs/types'; -import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; -import { log } from '@main/lib/logger'; - -const SSH_FILE_TREE_POLL_MS = 4_000; - -type LegacyListedEntry = { - path: string; - name: string; - type: 'file' | 'directory'; -}; - -/** - * Legacy SSH compatibility layer for the core file-tree contract. - * - * This adapter deliberately does not reuse `@emdash/core`'s native `FileTree`. - * Core owns the local, node:fs-backed implementation; this transitional layer - * translates SSH/SFTP polling into the same public snapshot/update interface - * until the core runtime can run where the remote workspace lives. - */ -export class LegacySshFileTreeRuntime implements IFileTreeRuntime { - private readonly trees: ResourceMap; - private disposeRequested = false; - - constructor(private readonly proxy: SshClientProxy) { - this.trees = new ResourceMap({ - teardown: (_rootPath, tree) => tree.dispose(), - onError: (context, error) => - log.warn('LegacySshFileTreeRuntime: teardown failed', { - context, - error: String(error), - }), - }); - } - - async open(rootPath: string): Promise> { - if (this.disposeRequested) throw new Error('LegacySshFileTreeRuntime disposed'); - const normalizedRoot = normalizeRemoteRootPath(rootPath); - const lease = await this.trees.acquire(normalizedRoot, async () => { - return new LegacySshFileTree(this.proxy, normalizedRoot, (context, error) => - log.warn('LegacySshFileTreeRuntime: background error', { - context, - error: String(error), - }) - ); - }); - - try { - const ready = await lease.value.ready(); - if (!ready.success) { - await lease.release(); - return err(ready.error); - } - return ok(lease); - } catch (error) { - await lease.release(); - throw error; - } - } - - async dispose(): Promise { - this.disposeRequested = true; - this.trees.dispose(); - } -} - -class LegacySshFileTree implements IFileTree { - readonly rootPath: string; - private readonly collection = new LiveCollection({ - scopeOf: (node) => node.parentId, - }); - private readonly fs: SshFileSystem; - private readonly pathToId = new Map(); - private readonly nodes = new Map(); - private readonly childrenByParent = new Map>(); - private readonly scopeLoads = new Map< - NodeId | null, - Promise> - >(); - private readonly pollTimer: ReturnType; - private nextId = 1; - private disposed = false; - private readyPromise: Promise> | null = null; - - constructor( - proxy: SshClientProxy, - rootPath: string, - private readonly onError: (context: string, error: unknown) => void - ) { - this.rootPath = rootPath; - this.fs = new SshFileSystem(proxy, rootPath); - this.pollTimer = setInterval(() => { - if (this.collection.subscriberCount === 0) return; - void this.refreshLoadedScopes().then( - (result) => { - if (!result.success) this.onError(`ssh file-tree refresh ${this.rootPath}`, result.error); - }, - (error) => this.onError(`ssh file-tree refresh ${this.rootPath}`, error) - ); - }, SSH_FILE_TREE_POLL_MS); - } - - async ready(): Promise> { - if (this.readyPromise) return this.readyPromise; - - const readyPromise = (async (): Promise> => { - const loaded = await this.loadDirectoryScope(null); - if (!loaded.success) return err(loaded.error); - return ok(); - })().catch((error): Result => { - if (this.readyPromise === readyPromise) { - this.readyPromise = null; - } - throw error; - }); - this.readyPromise = readyPromise; - return readyPromise; - } - - async getSnapshot(): Promise> { - const ready = await this.ready(); - if (!ready.success) return err(ready.error); - return ok(this.collection.getCached()); - } - - subscribe(cb: (update: FileTreeUpdate) => void): Unsubscribe { - return this.collection.subscribe(cb); - } - - async subscribeWithSnapshot( - cb: (update: FileTreeUpdate) => void - ): Promise, FileTreeError>> { - const unsubscribe = this.subscribe(cb); - const snapshot = await this.getSnapshot(); - if (!snapshot.success) { - unsubscribe(); - return err(snapshot.error); - } - return ok({ snapshot: snapshot.data, unsubscribe }); - } - - async expandDir(dirId: NodeId | null): Promise> { - const ready = await this.ready(); - if (!ready.success) return err(ready.error); - return this.loadDirectoryScope(dirId); - } - - async revealPath(pathToReveal: string): Promise> { - const ready = await this.ready(); - if (!ready.success) return err(ready.error); - const normalized = normalizeRelPath(pathToReveal); - if (!normalized.success) return normalized; - - const parts = normalized.data.split('/').filter(Boolean); - let sequences: FileTreeSequences = {}; - for (let index = 0; index < parts.length; index += 1) { - const relPath = parts.slice(0, index + 1).join('/'); - const node = this.getByPath(relPath); - if (!node) return err({ type: 'not-found', path: relPath }); - const shouldExpand = index < parts.length - 1 || node.type === 'directory'; - if (!shouldExpand) continue; - if (node.type !== 'directory') { - return err({ type: 'not-directory', id: node.id, path: node.path }); - } - const expanded = await this.loadDirectoryScope(node.id); - if (!expanded.success) return expanded; - sequences = mergeSequences(sequences, expanded.data); - } - return ok(sequences); - } - - async refresh(): Promise> { - const refreshed = await this.refreshLoadedScopes(); - if (!refreshed.success) return err(refreshed.error); - return ok(this.collection.getCached()); - } - - async dispose(): Promise { - if (this.disposed) return; - this.disposed = true; - clearInterval(this.pollTimer); - this.collection.dispose(); - } - - private async refreshLoadedScopes(): Promise> { - const scopes = this.collection.loadedScopes(); - let sequences: FileTreeSequences = {}; - for (const scope of scopes) { - if (scope !== null && !this.nodes.has(scope)) continue; - const refreshed = await this.loadDirectoryScope(scope); - if (!refreshed.success) { - const recovered = this.recoverMissingLoadedScope(scope, refreshed.error); - if (!recovered.success) return err(recovered.error); - sequences = mergeSequences(sequences, recovered.data); - continue; - } - sequences = mergeSequences(sequences, refreshed.data); - } - return ok(sequences); - } - - private async loadDirectoryScope( - scope: NodeId | null - ): Promise> { - const existing = this.scopeLoads.get(scope); - if (existing) return existing; - - const loading = this.loadDirectoryScopeInternal(scope); - this.scopeLoads.set(scope, loading); - void loading.finally(() => { - if (this.scopeLoads.get(scope) === loading) this.scopeLoads.delete(scope); - }); - return loading; - } - - private async loadDirectoryScopeInternal( - scope: NodeId | null - ): Promise> { - const dirNode = scope === null ? null : this.nodes.get(scope); - if (scope !== null && !dirNode) return err({ type: 'not-found', id: scope }); - if (dirNode && dirNode.type !== 'directory') { - return err({ type: 'not-directory', id: dirNode.id, path: dirNode.path }); - } - - const dirPath = dirNode?.path ?? ''; - const listed = await this.listChildren(dirPath); - if (!listed.success) return listed; - - const listedPaths = new Set(listed.data.map((entry) => entry.path)); - let sequence = this.removeMissingChildren(scope, listedPaths); - const nodes = listed.data.map((entry) => - this.upsertNode(entry, scope, this.getByPath(entry.path)?.childrenLoaded) - ); - const loaded = await this.collection.loadScope(scope, async () => - ok(nodes.map((node) => [node.id, node] as const)) - ); - if (!loaded.success) return loaded; - sequence = Math.max(sequence, loaded.data); - - if (dirNode && !dirNode.childrenLoaded) { - const updated = { ...dirNode, childrenLoaded: true }; - this.setNode(updated); - sequence = Math.max(sequence, this.collection.put(updated.id, updated)); - } - - return ok(sequence === 0 ? {} : { tree: sequence }); - } - - private async listChildren(dirPath: string): Promise> { - const normalized = normalizeRelPath(dirPath, { allowEmpty: true }); - if (!normalized.success) return normalized; - - try { - const result = await this.fs.list(normalized.data, { includeHidden: true }); - const entries: LegacyListedEntry[] = []; - for (const entry of result.entries) { - const relPath = entry.path.replace(/\\/g, '/'); - if (isIgnored(relPath)) continue; - if (entry.type !== 'dir' && entry.type !== 'file') continue; - entries.push(toListedEntry(entry)); - } - entries.sort((a, b) => { - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.localeCompare(b.name); - }); - return ok(entries); - } catch (error) { - return err(toFileTreeError(error, normalized.data)); - } - } - - private removeMissingChildren(parentId: NodeId | null, listedPaths: Set): number { - const missing = this.childrenOf(parentId) - .filter((node) => !listedPaths.has(node.path)) - .map((node) => node.id); - return this.removeSubtrees(missing); - } - - private removeSubtrees(rootIds: NodeId[]): number { - const ops: Array> = []; - const removedScopes: NodeId[] = []; - for (const rootId of rootIds) { - const removed = this.removeSubtree(rootId); - for (const node of removed) { - ops.push({ op: 'del', key: node.id }); - if (node.type === 'directory') removedScopes.push(node.id); - } - } - - let sequence = this.collection.apply(ops); - for (const scope of removedScopes) - sequence = Math.max(sequence, this.collection.unloadScope(scope)); - return sequence; - } - - private recoverMissingLoadedScope( - scope: NodeId | null, - error: FileTreeError - ): Result { - if (scope === null || (error.type !== 'not-found' && error.type !== 'not-directory')) { - return err(error); - } - - const sequence = this.removeSubtrees([scope]); - return ok(sequence === 0 ? {} : { tree: sequence }); - } - - private getByPath(path: string): FileNode | undefined { - const id = this.pathToId.get(path); - return id === undefined ? undefined : this.nodes.get(id); - } - - private upsertNode( - entry: LegacyListedEntry, - parentId: NodeId | null, - childrenLoaded?: boolean - ): FileNode { - const existingId = this.pathToId.get(entry.path); - const id = existingId ?? this.nextId++; - const previous = this.nodes.get(id); - const node: FileNode = { - id, - path: entry.path, - name: entry.name, - parentId, - type: entry.type, - childrenLoaded: - entry.type === 'directory' ? (childrenLoaded ?? previous?.childrenLoaded ?? false) : false, - }; - this.setNode(node); - return node; - } - - private setNode(node: FileNode): void { - const previous = this.nodes.get(node.id); - if (previous) { - this.pathToId.delete(previous.path); - this.removeChild(previous.parentId, node.id); - } - this.pathToId.set(node.path, node.id); - this.addChild(node.parentId, node.id); - this.nodes.set(node.id, node); - } - - private removeSubtree(rootId: NodeId): FileNode[] { - const removed: FileNode[] = []; - const visit = (id: NodeId) => { - const node = this.nodes.get(id); - if (!node) return; - for (const child of this.childrenOf(id)) visit(child.id); - this.removeNode(id); - removed.push(node); - }; - visit(rootId); - return removed; - } - - private removeNode(id: NodeId): void { - const node = this.nodes.get(id); - if (!node) return; - this.pathToId.delete(node.path); - this.removeChild(node.parentId, id); - this.nodes.delete(id); - } - - private childrenOf(parentId: NodeId | null): FileNode[] { - const ids = this.childrenByParent.get(parentId); - if (!ids) return []; - const children: FileNode[] = []; - for (const id of ids) { - const node = this.nodes.get(id); - if (node) children.push(node); - } - return children; - } - - private addChild(parentId: NodeId | null, id: NodeId): void { - let children = this.childrenByParent.get(parentId); - if (!children) { - children = new Set(); - this.childrenByParent.set(parentId, children); - } - children.add(id); - } - - private removeChild(parentId: NodeId | null, id: NodeId): void { - const children = this.childrenByParent.get(parentId); - if (!children) return; - children.delete(id); - if (children.size === 0) this.childrenByParent.delete(parentId); - } -} - -function toListedEntry(entry: FileEntry): LegacyListedEntry { - const relPath = entry.path.replace(/\\/g, '/'); - return { - path: relPath, - name: path.posix.basename(relPath), - type: entry.type === 'dir' ? 'directory' : 'file', - }; -} - -function toFileTreeError(error: unknown, relPath: string): FileTreeError { - if (error instanceof FileSystemError) { - if (error.code === FileSystemErrorCodes.NOT_FOUND) return { type: 'not-found', path: relPath }; - if (error.code === FileSystemErrorCodes.NOT_DIRECTORY) { - return { type: 'not-directory', path: relPath }; - } - if ( - error.code === FileSystemErrorCodes.INVALID_PATH || - error.code === FileSystemErrorCodes.PATH_ESCAPE - ) { - return { type: 'invalid-path', path: relPath, message: error.message }; - } - return { type: 'fs-error', path: relPath, message: error.message }; - } - return { type: 'fs-error', path: relPath, message: String(error) }; -} - -function normalizeRemoteRootPath(rootPath: string): string { - const normalized = path.posix.normalize(rootPath.replace(/\\/g, '/')); - return path.posix.isAbsolute(normalized) ? normalized : path.posix.resolve('/', normalized); -} - -function mergeSequences(left: FileTreeSequences, right: FileTreeSequences): FileTreeSequences { - return { tree: Math.max(left.tree ?? 0, right.tree ?? 0) || undefined }; -} diff --git a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts index 5d10506b9d..4f7c708d57 100644 --- a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts +++ b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts @@ -1,19 +1,19 @@ -import { FileTreeRuntime } from '@emdash/core/file-tree'; +import { FilesRuntime } from '@emdash/core/files'; import { GitRuntime } from '@emdash/core/git'; import { ResourceMap } from '@emdash/core/lib'; import type { Lease } from '@emdash/shared'; import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import { log } from '@main/lib/logger'; import { ConstantHealthSource } from './health'; -import { LegacySshFileTreeRuntime } from './legacy/ssh-file-tree'; +import { LegacySshFilesRuntime } from './legacy/ssh-files'; import { LegacySshGitRuntime } from './legacy/ssh-git'; import { machineKey, type MachineRef, type MachineRuntime, type RuntimeManager } from './types'; class LocalMachineRuntime implements MachineRuntime { readonly machine: MachineRef = { kind: 'local' }; - readonly fileTree = new FileTreeRuntime({ + readonly files = new FilesRuntime({ onError: (context, error) => - log.warn('Local FileTreeRuntime background error', { context, error: String(error) }), + log.warn('Local file runtime background error', { context, error: String(error) }), }); readonly git = new GitRuntime({ onError: (context, error) => @@ -22,14 +22,14 @@ class LocalMachineRuntime implements MachineRuntime { readonly health = new ConstantHealthSource(); async dispose(): Promise { - await this.fileTree.dispose(); + await this.files.dispose(); await this.git.dispose(); } } class SshMachineRuntime implements MachineRuntime { readonly machine: MachineRef; - readonly fileTree: LegacySshFileTreeRuntime; + readonly files: LegacySshFilesRuntime; readonly git: LegacySshGitRuntime; readonly health = new ConstantHealthSource(); @@ -38,12 +38,12 @@ class SshMachineRuntime implements MachineRuntime { proxy: Awaited> ) { this.machine = { kind: 'ssh', connectionId }; - this.fileTree = new LegacySshFileTreeRuntime(proxy); + this.files = new LegacySshFilesRuntime(proxy); this.git = new LegacySshGitRuntime(proxy); } async dispose(): Promise { - await this.fileTree.dispose(); + await this.files.dispose(); await this.git.dispose(); } } diff --git a/apps/emdash-desktop/src/main/core/runtime/types.ts b/apps/emdash-desktop/src/main/core/runtime/types.ts index 064c6875c5..b53d484f26 100644 --- a/apps/emdash-desktop/src/main/core/runtime/types.ts +++ b/apps/emdash-desktop/src/main/core/runtime/types.ts @@ -1,4 +1,4 @@ -import type { IFileTreeRuntime } from '@emdash/core/file-tree'; +import type { IFilesRuntime } from '@emdash/core/files'; import type { IGitRuntime } from '@emdash/core/git'; import type { IDisposable, Lease, Unsubscribe } from '@emdash/shared'; @@ -16,7 +16,7 @@ export interface HealthSource { export interface MachineRuntime extends IDisposable { readonly machine: MachineRef; - readonly fileTree: IFileTreeRuntime; + readonly files: IFilesRuntime; readonly git: IGitRuntime; readonly health: HealthSource; } diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts index 724d8de129..619a81740d 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts @@ -1,6 +1,5 @@ import { basename } from 'node:path'; -import { isIgnored } from '@emdash/core/file-tree'; -import { fsEvents } from '@main/core/fs/fs-events'; +import { isIgnored, type FileChangeUpdate } from '@emdash/core/files'; import type { Workspace } from '@main/core/workspaces/workspace'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { sqlite } from '@main/db/client'; @@ -19,10 +18,10 @@ class WorkspaceFileIndexService { initialize(): void { this.evictStale(); + } - fsEvents.on('watch:event', ({ workspaceId }) => { - this.scheduleReindex(workspaceId); - }); + onWorkspaceFileChange(workspaceId: string, update: FileChangeUpdate): void { + if (update.kind === 'resync' || update.changes.length > 0) this.scheduleReindex(workspaceId); } async onWorkspaceCreated(workspaceId: string, workspace: Workspace): Promise { diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts index ac5a5898f3..c4072cf637 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts @@ -24,7 +24,7 @@ import { type WorkspaceFactoryResult } from '@main/core/workspaces/workspace-reg import { handleGitWorktreeUpdate } from '@main/core/workspaces/workspace-worktree-update'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import { fileTreeUpdateChannel } from '@shared/core/fs/fsEvents'; +import { fileChangesChannel, fileTreeUpdateChannel } from '@shared/core/fs/fsEvents'; import { gitWorktreeUpdateChannel } from '@shared/core/git/events'; import type { Task } from '@shared/core/tasks/tasks'; import { getEffectiveTaskSettings } from '../projects/settings/effective-task-settings'; @@ -131,7 +131,7 @@ export function createWorkspaceFactory( }); const runtime = await acquireWorkspaceRuntime(context.workspaceRuntime, workDir); - const { gitWorktree, fileTree } = runtime; + const { gitWorktree, fileTree, filesRuntime } = runtime; const gitRepository = context.gitRepository ?? new GitRepositoryService(gitWorktree.repository, context.settings); @@ -142,6 +142,7 @@ export function createWorkspaceFactory( new GitRepositoryFetchService(gitRepository, () => gitRepository.getBaseRemote()); let unsubscribeGitUpdates: (() => void) | undefined; let unsubscribeFileTreeUpdates: (() => void) | undefined; + let unsubscribeFileChanges: (() => void) | undefined; const workspace: Workspace = { id: workspaceId, @@ -158,6 +159,8 @@ export function createWorkspaceFactory( unsubscribeGitUpdates = undefined; unsubscribeFileTreeUpdates?.(); unsubscribeFileTreeUpdates = undefined; + unsubscribeFileChanges?.(); + unsubscribeFileChanges = undefined; await runtime.release(); }, }; @@ -184,6 +187,34 @@ export function createWorkspaceFactory( update, }); }); + const fileChanges = filesRuntime.watchChanges( + workDir, + (update) => { + events.emit(fileChangesChannel, { + projectId: context.projectId, + workspaceId, + update, + }); + workspaceFileIndexService.onWorkspaceFileChange(workspaceId, update); + }, + { paths: [''] } + ); + if (fileChanges.success) { + unsubscribeFileChanges = fileChanges.data.unsubscribe; + void fileChanges.data.ready().then((result) => { + if (!result.success) { + log.warn('WorkspaceFactory: file change feed failed to become ready', { + workspaceId, + error: result.error, + }); + } + }); + } else { + log.warn('WorkspaceFactory: failed to start file change feed', { + workspaceId, + error: fileChanges.error, + }); + } if (ownsFetchService) { gitRepositoryFetchService.start(); @@ -287,7 +318,7 @@ async function acquireWorkspaceRuntime( try { const worktreeLease = await runtimeLease.value.git.openWorktree(workDir); try { - const openedFileTree = await runtimeLease.value.fileTree.open(workDir); + const openedFileTree = await runtimeLease.value.files.openTree(workDir); if (!openedFileTree.success) { throw new Error(`Failed to open file tree: ${JSON.stringify(openedFileTree.error)}`); } @@ -297,6 +328,7 @@ async function acquireWorkspaceRuntime( return { gitWorktree: worktreeLease.value, fileTree: fileTreeLease.value, + filesRuntime: runtimeLease.value.files, release: async () => { if (released) return; released = true; diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace.ts index 9e8d148a38..8a8177eaed 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace.ts @@ -1,4 +1,4 @@ -import type { IFileTree } from '@emdash/core/file-tree'; +import type { IFileTree } from '@emdash/core/files'; import type { IGitWorktree } from '@emdash/core/git'; import type { FileSystemProvider } from '@main/core/fs/types'; import type { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; diff --git a/apps/emdash-desktop/src/renderer/features/projects/stores/project-settings-store.ts b/apps/emdash-desktop/src/renderer/features/projects/stores/project-settings-store.ts index 08f35a49e0..7edaebd718 100644 --- a/apps/emdash-desktop/src/renderer/features/projects/stores/project-settings-store.ts +++ b/apps/emdash-desktop/src/renderer/features/projects/stores/project-settings-store.ts @@ -1,7 +1,7 @@ import type { Result } from '@emdash/shared'; import { events, rpc } from '@renderer/lib/ipc'; import { Resource } from '@renderer/lib/stores/resource'; -import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; +import { fileChangesChannel } from '@shared/core/fs/fsEvents'; import { PROJECT_CONFIG_FILE, type MigrateProjectConfigRequest, @@ -34,12 +34,11 @@ export class ProjectSettingsStore { return result.data; }, [{ kind: 'demand' }]); - this._unsubscribeConfigWatch = events.on(fsWatchEventChannel, (data) => { + this._unsubscribeConfigWatch = events.on(fileChangesChannel, (data) => { if (data.projectId !== projectId) return; if ( - data.events.some( - (event) => event.path === PROJECT_CONFIG_FILE || event.oldPath === PROJECT_CONFIG_FILE - ) + data.update.kind === 'resync' || + data.update.changes.some((change) => change.path === PROJECT_CONFIG_FILE) ) { this.pageData.invalidate(); } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.test.ts index 38871feb53..1069a45eb8 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.test.ts @@ -1,4 +1,4 @@ -import type { FileNode, FileTreeUpdate, NodeId } from '@emdash/core/file-tree'; +import type { FileNode, FileTreeUpdate, NodeId } from '@emdash/core/files'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fileTreeUpdateChannel } from '@shared/core/fs/fsEvents'; import { FilesStore } from './files-store'; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.ts index 06f26ffbd2..c4a15a6031 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/stores/files-store.ts @@ -1,4 +1,4 @@ -import type { FileNode as CoreFileNode, NodeId } from '@emdash/core/file-tree'; +import type { FileNode as CoreFileNode, NodeId } from '@emdash/core/files'; import type { KeyedOp } from '@emdash/core/lib'; import { computed, makeObservable, observable, runInAction } from 'mobx'; import { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts index 935405eeac..cbe2fe60c6 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/file-tree/tree-utils.ts @@ -1,4 +1,4 @@ -import type { FileNode as CoreFileNode, NodeId } from '@emdash/core/file-tree'; +import type { FileNode as CoreFileNode, NodeId } from '@emdash/core/files'; export interface RenderableFileNode { id: NodeId; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.test.ts index 2862599336..109c6b50d8 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; +import { fileChangesChannel } from '@shared/core/fs/fsEvents'; import { projectSettingsChangedChannel } from '@shared/core/projects/projectEvents'; import { lifecycleScriptStatusChannel } from '@shared/core/tasks/taskEvents'; import { createLifecycleScriptTerminalId } from '@shared/core/terminals/terminals'; @@ -8,8 +8,6 @@ import { LifecycleScriptsStore, LifecycleScriptStore } from './lifecycle-scripts const eventHandlers = new Map void>(); const offEvent = vi.fn(); const getSettings = vi.hoisted(() => vi.fn()); -const watchSetPaths = vi.hoisted(() => vi.fn(async () => ({ success: true, data: {} }))); -const watchStop = vi.hoisted(() => vi.fn(async () => ({ success: true, data: {} }))); vi.mock('@renderer/lib/ipc', () => ({ events: { @@ -22,12 +20,6 @@ vi.mock('@renderer/lib/ipc', () => ({ projectSettings: { getSettings, }, - workspace: { - fs: { - watchSetPaths, - watchStop, - }, - }, }, })); @@ -49,8 +41,6 @@ describe('LifecycleScriptStore', () => { eventHandlers.clear(); offEvent.mockClear(); getSettings.mockReset(); - watchSetPaths.mockClear(); - watchStop.mockClear(); }); it('tracks script running state from lifecycle status events', () => { @@ -106,8 +96,6 @@ describe('LifecycleScriptsStore', () => { eventHandlers.clear(); offEvent.mockClear(); getSettings.mockReset(); - watchSetPaths.mockClear(); - watchStop.mockClear(); }); it('uses stable script IDs and reconciles command changes from .emdash.json watch events', async () => { @@ -118,27 +106,23 @@ describe('LifecycleScriptsStore', () => { await (store as unknown as { load(): Promise }).load(); - expect(watchSetPaths).toHaveBeenCalledWith( - 'project-1', - 'workspace-1', - [''], - 'lifecycle-scripts' - ); expect(store.tabs).toHaveLength(1); expect(store.tabs[0].data.id).toBe(createLifecycleScriptTerminalId('run')); expect(store.tabs[0].data.command).toBe('pnpm dev'); - eventHandlers.get(`${fsWatchEventChannel.name}.`)?.({ + eventHandlers.get(`${fileChangesChannel.name}.`)?.({ projectId: 'project-1', workspaceId: 'workspace-1', - events: [{ type: 'modify', entryType: 'file', path: '.emdash.json' }], + update: { + kind: 'changes', + changes: [{ kind: 'update', entryType: 'file', path: '.emdash.json' }], + }, }); await expect.poll(() => store.tabs[0]?.data.command).toBe('pnpm start'); expect(store.tabs[0].data.id).toBe(createLifecycleScriptTerminalId('run')); store.dispose(); - expect(watchStop).toHaveBeenCalledWith('project-1', 'workspace-1', 'lifecycle-scripts'); }); it('reloads lifecycle scripts when project settings change', async () => { @@ -171,6 +155,5 @@ describe('LifecycleScriptsStore', () => { await loadPromise; expect(store.tabs).toEqual([]); - expect(watchStop).toHaveBeenCalledWith('project-1', 'workspace-1', 'lifecycle-scripts'); }); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.ts index f4c79e8c7f..63cf3958f2 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.ts @@ -9,7 +9,7 @@ import { setTabActive, setTabActiveIndex, } from '@renderer/lib/stores/tab-utils'; -import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; +import { fileChangesChannel } from '@shared/core/fs/fsEvents'; import { PROJECT_CONFIG_FILE } from '@shared/core/project-settings/project-settings'; import { projectSettingsChangedChannel } from '@shared/core/projects/projectEvents'; import { makePtySessionId } from '@shared/core/pty/ptySessionId'; @@ -88,7 +88,6 @@ export class LifecycleScriptsStore implements TabViewProvider void> = []; scripts = observable.map(); @@ -114,12 +113,11 @@ export class LifecycleScriptsStore implements TabViewProvider { + events.on(fileChangesChannel, (data) => { if (data.projectId !== this.projectId || data.workspaceId !== this.workspaceId) return; if ( - data.events.some( - (event) => event.path === PROJECT_CONFIG_FILE || event.oldPath === PROJECT_CONFIG_FILE - ) + data.update.kind === 'resync' || + data.update.changes.some((change) => change.path === PROJECT_CONFIG_FILE) ) { this.reloadIfLoaded(); } @@ -175,8 +173,6 @@ export class LifecycleScriptsStore implements TabViewProvider { if (this._disposed) return; this._loaded = true; - await this.watchConfig(); - if (this._disposed) return; await this.reload(); } @@ -185,25 +181,6 @@ export class LifecycleScriptsStore implements TabViewProvider { - if (this._watchingConfig || this._disposed) return; - try { - await rpc.workspace.fs.watchSetPaths( - this.projectId, - this.workspaceId, - [''], - 'lifecycle-scripts' - ); - if (this._disposed) { - void rpc.workspace.fs.watchStop(this.projectId, this.workspaceId, 'lifecycle-scripts'); - return; - } - this._watchingConfig = true; - } catch { - this._watchingConfig = false; - } - } - private async reload(): Promise { if (this._disposed) return; const refreshSeq = ++this._refreshSeq; @@ -264,9 +241,6 @@ export class LifecycleScriptsStore implements TabViewProvider ({ data: { entries: [], generation: 1, sequence: 0 }, }), }, - fs: { - watchSetPaths: vi.fn().mockResolvedValue(undefined), - watchStop: vi.fn().mockResolvedValue(undefined), - }, }, }, })); diff --git a/apps/emdash-desktop/src/renderer/lib/monaco/invalidation-bridges.ts b/apps/emdash-desktop/src/renderer/lib/monaco/invalidation-bridges.ts index c6f25bfda7..33df1a3bb3 100644 --- a/apps/emdash-desktop/src/renderer/lib/monaco/invalidation-bridges.ts +++ b/apps/emdash-desktop/src/renderer/lib/monaco/invalidation-bridges.ts @@ -1,29 +1,20 @@ +import type { FileChange } from '@emdash/core/files'; import { events } from '@renderer/lib/ipc'; -import type { FileWatchEvent } from '@shared/core/fs/fs'; -import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; +import { fileChangesChannel } from '@shared/core/fs/fsEvents'; import { gitRepoUpdateChannel, gitWorktreeUpdateChannel } from '@shared/core/git/events'; import { HEAD_REF, STAGED_REF } from '@shared/core/git/types'; import type { MonacoModelRegistry } from './monaco-model-registry'; /** Disk models for paths affected by a watch event (atomic saves often use create/delete, not modify). */ -function diskUrisForFsWatchEvent( +function diskUrisForFileChange( registry: MonacoModelRegistry, workspaceId: string, - e: FileWatchEvent + change: FileChange ): string[] { - if (e.path.startsWith('.git')) return []; - if (e.oldPath?.startsWith('.git')) return []; + if (change.path.startsWith('.git')) return []; - if (e.type === 'rename' && e.oldPath) { - return [ - ...registry.findDiskUris({ workspaceId, filePath: e.path }), - ...registry.findDiskUris({ workspaceId, filePath: e.oldPath }), - ]; - } - - if (e.entryType !== 'file') return []; - if (e.type === 'modify' || e.type === 'create' || e.type === 'delete') { - return registry.findDiskUris({ workspaceId, filePath: e.path }); + if (change.entryType !== 'directory') { + return registry.findDiskUris({ workspaceId, filePath: change.path }); } return []; } @@ -36,11 +27,16 @@ function diskUrisForFsWatchEvent( */ export function wireModelRegistryInvalidation(registry: MonacoModelRegistry): () => void { // Disk file modifications → invalidate matching disk:// models. - const unsubFs = events.on(fsWatchEventChannel, ({ workspaceId, events: fsEvents }) => { - for (const e of fsEvents) { - const skippedGit = e.path.startsWith('.git') || e.oldPath?.startsWith('.git'); - const uris = skippedGit ? [] : diskUrisForFsWatchEvent(registry, workspaceId, e); - if (skippedGit) continue; + const unsubFs = events.on(fileChangesChannel, ({ workspaceId, update }) => { + if (update.kind === 'resync') { + for (const uri of registry.findDiskUris({ workspaceId })) { + void registry.invalidateModel(uri); + } + return; + } + + for (const change of update.changes) { + const uris = diskUrisForFileChange(registry, workspaceId, change); for (const uri of uris) { void registry.invalidateModel(uri); } diff --git a/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.ts b/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.ts index 1cb675a17a..bb77ecff63 100644 --- a/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.ts +++ b/apps/emdash-desktop/src/renderer/lib/monaco/monaco-model-registry.ts @@ -871,12 +871,12 @@ export class MonacoModelRegistry { * Return all registered disk:// URIs for the given workspace and file path. * Used by the FS-event invalidation bridge. */ - findDiskUris(filter: { workspaceId: string; filePath: string }): string[] { + findDiskUris(filter: { workspaceId: string; filePath?: string }): string[] { const result: string[] = []; for (const [uri, entry] of this.modelMap) { if (entry.type !== 'disk') continue; if (entry.workspaceId !== filter.workspaceId) continue; - if (entry.filePath !== filter.filePath) continue; + if (filter.filePath !== undefined && entry.filePath !== filter.filePath) continue; result.push(uri); } return result; diff --git a/apps/emdash-desktop/src/shared/core/fs/file-tree-errors.ts b/apps/emdash-desktop/src/shared/core/fs/file-tree-errors.ts index acb49c6895..c155c7a035 100644 --- a/apps/emdash-desktop/src/shared/core/fs/file-tree-errors.ts +++ b/apps/emdash-desktop/src/shared/core/fs/file-tree-errors.ts @@ -1,4 +1,4 @@ -import type { FileTreeError } from '@emdash/core/file-tree'; +import type { FileTreeError } from '@emdash/core/files'; export type FileTreeNotFoundError = { type: 'not_found' }; export type FileTreeOperationError = FileTreeNotFoundError | FileTreeError; diff --git a/apps/emdash-desktop/src/shared/core/fs/file-tree.ts b/apps/emdash-desktop/src/shared/core/fs/file-tree.ts index 00d864b1e1..468ff9bb19 100644 --- a/apps/emdash-desktop/src/shared/core/fs/file-tree.ts +++ b/apps/emdash-desktop/src/shared/core/fs/file-tree.ts @@ -1,4 +1,4 @@ -import type { FileTreeSequences, FileTreeSnapshot } from '@emdash/core/file-tree'; +import type { FileTreeSequences, FileTreeSnapshot } from '@emdash/core/files'; import type { Result } from '@emdash/shared'; import type { FileTreeOperationError } from './file-tree-errors'; diff --git a/apps/emdash-desktop/src/shared/core/fs/fsEvents.ts b/apps/emdash-desktop/src/shared/core/fs/fsEvents.ts index f324615d1a..c31ff4f7fe 100644 --- a/apps/emdash-desktop/src/shared/core/fs/fsEvents.ts +++ b/apps/emdash-desktop/src/shared/core/fs/fsEvents.ts @@ -1,12 +1,14 @@ -import type { FileTreeUpdate } from '@emdash/core/file-tree'; -import type { FileWatchEvent } from '@shared/core/fs/fs'; +import type { FileTreeUpdate } from '@emdash/core/files'; +import type { FileChangeUpdate } from '@emdash/core/files'; import { defineEvent } from '@shared/lib/ipc/events'; -export const fsWatchEventChannel = defineEvent<{ +export type FileChangesEvent = { projectId: string; workspaceId: string; - events: FileWatchEvent[]; -}>('fs:watch-event'); + update: FileChangeUpdate; +}; + +export const fileChangesChannel = defineEvent('files:changes'); export type FileTreeUpdateEvent = { projectId: string; From 678b60b51e918453251e393028991f335a4bdc88 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:52:41 -0700 Subject: [PATCH 15/37] feat(core): add files enumeration API --- packages/core/src/files/enumerate.test.ts | 57 +++++++++++++++++++ packages/core/src/files/enumerate.ts | 35 ++++++++++++ packages/core/src/files/files-runtime.test.ts | 24 ++++++++ packages/core/src/files/files-runtime.ts | 13 +++++ packages/core/src/files/index.test.ts | 1 + packages/core/src/files/index.ts | 1 + packages/core/src/files/types.ts | 4 ++ 7 files changed, 135 insertions(+) create mode 100644 packages/core/src/files/enumerate.test.ts create mode 100644 packages/core/src/files/enumerate.ts diff --git a/packages/core/src/files/enumerate.test.ts b/packages/core/src/files/enumerate.test.ts new file mode 100644 index 0000000000..2227827a91 --- /dev/null +++ b/packages/core/src/files/enumerate.test.ts @@ -0,0 +1,57 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { enumerate } from './enumerate'; + +const roots: string[] = []; + +async function makeRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'emdash-files-enumerate-')); + roots.push(root); + return root; +} + +async function collect(iterable: AsyncIterable): Promise { + const paths: string[] = []; + for await (const relPath of iterable) paths.push(relPath); + return paths; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('enumerate', () => { + it('streams regular files recursively with the canonical ignore set', async () => { + const root = await makeRoot(); + await mkdir(path.join(root, 'src', 'nested'), { recursive: true }); + await mkdir(path.join(root, 'node_modules', 'pkg'), { recursive: true }); + await mkdir(path.join(root, '.git'), { recursive: true }); + await writeFile(path.join(root, 'README.md'), 'readme'); + await writeFile(path.join(root, '.env'), 'env'); + await writeFile(path.join(root, 'src', 'index.ts'), 'src'); + await writeFile(path.join(root, 'src', 'nested', 'deep.ts'), 'deep'); + await writeFile(path.join(root, 'node_modules', 'pkg', 'index.js'), 'ignored'); + await writeFile(path.join(root, '.git', 'HEAD'), 'ignored'); + + await expect(collect(enumerate(root))).resolves.toEqual([ + '.env', + 'README.md', + 'src/index.ts', + 'src/nested/deep.ts', + ]); + }); + + it('skips symlinks and other non-regular entries', async () => { + const root = await makeRoot(); + await writeFile(path.join(root, 'target.txt'), 'target'); + try { + await symlink('target.txt', path.join(root, 'link.txt'), 'file'); + } catch { + // Some environments disallow symlink creation. + } + + await expect(collect(enumerate(root))).resolves.toEqual(['target.txt']); + }); +}); diff --git a/packages/core/src/files/enumerate.ts b/packages/core/src/files/enumerate.ts new file mode 100644 index 0000000000..b2afd5e428 --- /dev/null +++ b/packages/core/src/files/enumerate.ts @@ -0,0 +1,35 @@ +import { readdir } from 'node:fs/promises'; +import { isIgnored } from './ignores'; +import { normalizeRelPath, resolveInsideRoot, type RelPath } from './paths'; + +export async function* enumerate(rootPath: string): AsyncIterable { + yield* enumerateDirectory(rootPath, ''); +} + +async function* enumerateDirectory(rootPath: string, dirPath: string): AsyncIterable { + const resolved = resolveInsideRoot(rootPath, dirPath, { allowEmpty: true }); + if (!resolved.success) return; + + let entries; + try { + entries = await readdir(resolved.data.absPath, { withFileTypes: true }); + } catch { + return; + } + + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const relPath = resolved.data.relPath ? `${resolved.data.relPath}/${entry.name}` : entry.name; + const normalized = normalizeRelPath(relPath); + if (!normalized.success || isIgnored(normalized.data)) continue; + + if (entry.isFile()) { + yield normalized.data; + continue; + } + if (entry.isDirectory()) { + yield* enumerateDirectory(rootPath, normalized.data); + } + } +} diff --git a/packages/core/src/files/files-runtime.test.ts b/packages/core/src/files/files-runtime.test.ts index 03b90f632c..08037cf3db 100644 --- a/packages/core/src/files/files-runtime.test.ts +++ b/packages/core/src/files/files-runtime.test.ts @@ -38,6 +38,12 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function collect(iterable: AsyncIterable): Promise { + const paths: string[] = []; + for await (const relPath of iterable) paths.push(relPath); + return paths; +} + describe('FilesRuntime', () => { it('wires file tree and change feeds through the same watch root and ignore set', async () => { const root = await makeRoot(); @@ -62,4 +68,22 @@ describe('FilesRuntime', () => { await fileTree.data.release(); await runtime.dispose(); }); + + it('enumerates files without acquiring a watch subscription', async () => { + const root = await makeRoot(); + await mkdir(path.join(root, 'src')); + await writeFile(path.join(root, 'src/index.ts'), 'content'); + await writeFile(path.join(root, '.env'), 'env'); + const watcher = new RecordingWatchService(); + const runtime = new FilesRuntime({ watcher }); + + const enumeration = runtime.enumerate(root); + expect(enumeration.success).toBe(true); + if (!enumeration.success) return; + + await expect(collect(enumeration.data)).resolves.toEqual(['.env', 'src/index.ts']); + expect(watcher.watches).toEqual([]); + + await runtime.dispose(); + }); }); diff --git a/packages/core/src/files/files-runtime.ts b/packages/core/src/files/files-runtime.ts index a36b4d7c84..b6348cb03b 100644 --- a/packages/core/src/files/files-runtime.ts +++ b/packages/core/src/files/files-runtime.ts @@ -3,11 +3,13 @@ import { err, ok, type Result } from '@emdash/shared'; import { ResourceMap } from '../lib'; import { WatchService, realpathOrResolve, type IWatchService } from '../watch'; import { FileChanges } from './changes/changes'; +import { enumerate as enumerateFiles } from './enumerate'; import type { FileError, FilesOnError } from './errors'; import type { FileTreeError, FileTreeOnError } from './tree/errors'; import { FileTree } from './tree/file-tree'; import type { FileTreeLease } from './tree/types'; import type { + FileEnumeration, FileChangeSubscription, FileChangeUpdate, FileChangeWatchOptions, @@ -97,6 +99,17 @@ export class FilesRuntime implements IFilesRuntime { }); } + enumerate(rootPath: string): Result { + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: '', + message: 'FilesRuntime disposed', + }); + } + return ok(enumerateFiles(realpathOrResolve(path.resolve(rootPath)))); + } + async dispose(): Promise { this.disposeRequested = true; await this.trees.dispose(); diff --git a/packages/core/src/files/index.test.ts b/packages/core/src/files/index.test.ts index 05d8488d99..91ea218570 100644 --- a/packages/core/src/files/index.test.ts +++ b/packages/core/src/files/index.test.ts @@ -6,6 +6,7 @@ describe('@emdash/core/files public exports', () => { const exported = files as Record; expect(exported.FilesRuntime).toBeTypeOf('function'); + expect(exported.enumerate).toBeTypeOf('function'); expect(exported.isIgnored).toBeTypeOf('function'); expect(exported.watchIgnoreGlobs).toBeTypeOf('function'); expect(exported.normalizeRelPath).toBeTypeOf('function'); diff --git a/packages/core/src/files/index.ts b/packages/core/src/files/index.ts index 84b00ab5a5..2e7f73f7f7 100644 --- a/packages/core/src/files/index.ts +++ b/packages/core/src/files/index.ts @@ -1,3 +1,4 @@ +export { enumerate } from './enumerate'; export { FilesRuntime, type FilesRuntimeOptions } from './files-runtime'; export { classifyFileError, type FileError, type FilesOnError } from './errors'; export { IGNORED_PATH_SEGMENTS, isIgnored, watchIgnoreGlobs } from './ignores'; diff --git a/packages/core/src/files/types.ts b/packages/core/src/files/types.ts index 1453138b0c..433492adc5 100644 --- a/packages/core/src/files/types.ts +++ b/packages/core/src/files/types.ts @@ -5,9 +5,12 @@ import type { FileChangeWatchOptions, } from './changes/types'; import type { FileError } from './errors'; +import type { RelPath } from './paths'; import type { FileTreeError } from './tree/errors'; import type { FileTreeLease } from './tree/types'; +export type FileEnumeration = AsyncIterable; + export interface IFilesRuntime { openTree(rootPath: string): Promise>; watchChanges( @@ -15,6 +18,7 @@ export interface IFilesRuntime { cb: (update: FileChangeUpdate) => void, options?: FileChangeWatchOptions ): Result; + enumerate(rootPath: string): Result; dispose(): Promise; } From f2191b1960a21c0f85f41d223cf016a95c5aa41f Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:52:45 -0700 Subject: [PATCH 16/37] feat(ssh): stream workspace file enumeration --- .../core/runtime/legacy/ssh-files.test.ts | 26 ++++ .../src/main/core/runtime/legacy/ssh-files.ts | 115 ++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts index 773b6bc522..46decc7dee 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts @@ -106,6 +106,22 @@ describe('LegacySshFilesRuntime', () => { await runtime.dispose(); }); + it('enumerates remote files with one streamed command', async () => { + const { proxy, exec } = makeSnapshotProxy([ + enumeration(['README.md', 'src/a.ts', 'node_modules/pkg/index.js']), + ]); + const runtime = new LegacySshFilesRuntime(proxy); + + const result = runtime.enumerate('/repo'); + expect(result.success).toBe(true); + if (!result.success) return; + + await expect(collect(result.data)).resolves.toEqual(['README.md', 'src/a.ts']); + expect(exec).toHaveBeenCalledTimes(1); + + await runtime.dispose(); + }); + it('returns a disposed error when watched after disposal', async () => { const runtime = new LegacySshFilesRuntime({} as never); await runtime.dispose(); @@ -122,6 +138,12 @@ describe('LegacySshFilesRuntime', () => { }); }); +async function collect(iterable: AsyncIterable): Promise { + const paths: string[] = []; + for await (const relPath of iterable) paths.push(relPath); + return paths; +} + function snapshot(records: SnapshotRecord[]): Buffer { const fields = records.flatMap((record) => [ record.kind, @@ -132,6 +154,10 @@ function snapshot(records: SnapshotRecord[]): Buffer { return Buffer.from(`${fields.join('\0')}\0`); } +function enumeration(paths: string[]): Buffer { + return Buffer.from(`${paths.join('\0')}\0`); +} + function makeSnapshotProxy(snapshots: Buffer[]): { proxy: SshClientProxy; exec: ReturnType; diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts index 068f884f7e..5d82b18296 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts @@ -1,10 +1,12 @@ import path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; import { IGNORED_PATH_SEGMENTS, isIgnored, normalizeRelPath, normalizeRelPaths, type FileChange, + type FileEnumeration, type FileChangeSubscription, type FileChangeUpdate, type FileChangeWatchOptions, @@ -19,6 +21,7 @@ import { type IFileTree, type IFilesRuntime, type NodeId, + type RelPath, type SubscribedSnapshot, } from '@emdash/core/files'; import { LiveCollection, ResourceMap, type KeyedOp } from '@emdash/core/lib'; @@ -106,6 +109,18 @@ export class LegacySshFilesRuntime implements IFilesRuntime { } } + enumerate(rootPath: string): Result { + const normalizedRoot = normalizeRemoteRootPath(rootPath); + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: normalizedRoot, + message: 'LegacySshFilesRuntime disposed', + }); + } + return ok(enumerateRemoteWorkspace(this.proxy, normalizedRoot)); + } + watchChanges( rootPath: string, cb: (update: FileChangeUpdate) => void, @@ -626,6 +641,17 @@ function eventsToChanges(events: FileWatchEvent[]): FileChange[] { return changes; } +async function* enumerateRemoteWorkspace( + proxy: SshClientProxy, + rootPath: string +): AsyncIterable { + for await (const rawPath of execRemoteNulFields(proxy, buildRemoteEnumerationCommand(rootPath))) { + const normalized = normalizeRelPath(rawPath); + if (!normalized.success || isIgnored(normalized.data)) continue; + yield normalized.data; + } +} + function diffRecursiveSnapshots( previous: Map, next: Map @@ -713,6 +739,95 @@ function readExecStream( }); } +async function* execRemoteNulFields(proxy: SshClientProxy, command: string): AsyncIterable { + const profile = await proxy.getRemoteShellProfile(); + const fullCommand = buildRemoteShellCommand(profile, command); + const decoder = new StringDecoder('utf8'); + const queue: string[] = []; + let stream: ClientChannel | undefined; + let pending = ''; + let stderr = ''; + let done = false; + let error: unknown; + let notify: (() => void) | undefined; + + const wake = () => { + notify?.(); + notify = undefined; + }; + const waitForEvent = () => + new Promise((resolve) => { + notify = resolve; + }); + + await new Promise((resolve, reject) => { + proxy.exec(fullCommand, (err, channel) => { + if (err) { + reject(err); + return; + } + + stream = channel; + channel.on('data', (chunk: Buffer) => { + const text = pending + decoder.write(chunk); + const parts = text.split('\0'); + pending = parts.pop() ?? ''; + queue.push(...parts); + wake(); + }); + channel.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + channel.on('close', (exitCode: number | null) => { + const tail = pending + decoder.end(); + if (tail) queue.push(tail); + pending = ''; + if ((exitCode ?? 0) !== 0) { + error = new Error(stderr.trim() || `Remote command exited with code ${exitCode}`); + } + done = true; + wake(); + }); + channel.on('error', (streamError: Error) => { + error = streamError; + done = true; + wake(); + }); + resolve(); + }); + }); + + try { + while (!done || queue.length > 0) { + while (queue.length > 0) { + const item = queue.shift(); + if (item) yield item; + } + if (error) throw error; + if (!done) await waitForEvent(); + } + if (error) throw error; + } finally { + if (!done) stream?.destroy(); + } +} + +function buildRemoteEnumerationCommand(rootPath: string): string { + const pruneExpression = buildFindPruneExpression(); + const enumerateScript = ` +for p do + rel=\${p#./} + [ "$rel" = "." ] && continue + printf '%s\\0' "$rel" +done +`.trim(); + + return [ + `cd ${quoteShellArg(rootPath)} || exit 1`, + `find . ${pruneExpression}-type f -exec sh -c ${quoteShellArg(enumerateScript)} sh {} +`, + ].join('\n'); +} + function buildRecursiveSnapshotCommand(rootPath: string): string { const pruneExpression = buildFindPruneExpression(); const snapshotScript = ` From cbf6fa4ae817ce0f8150d4ea5643900702dcc40a Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:52:51 -0700 Subject: [PATCH 17/37] feat(search): index workspace files through files runtime --- .../core/search/collect-with-budget.test.ts | 55 +++ .../main/core/search/collect-with-budget.ts | 41 ++ .../workspace-file-index-service.db.test.ts | 230 ++++++++++++ .../workspace-file-index-service.test.ts | 260 +++++++++++++ .../search/workspace-file-index-service.ts | 353 +++++++++++------- .../workspace-file-index-store.db.test.ts | 176 +++++++++ .../core/search/workspace-file-index-store.ts | 268 +++++++++++++ .../main/core/workspaces/workspace-factory.ts | 7 +- apps/emdash-desktop/src/main/db/initialize.ts | 11 +- 9 files changed, 1252 insertions(+), 149 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts create mode 100644 apps/emdash-desktop/src/main/core/search/collect-with-budget.ts create mode 100644 apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts create mode 100644 apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts create mode 100644 apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts create mode 100644 apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts diff --git a/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts b/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts new file mode 100644 index 0000000000..fc184d0b32 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts @@ -0,0 +1,55 @@ +import type { RelPath } from '@emdash/core/files'; +import { describe, expect, it } from 'vitest'; +import { collectWithBudget } from './collect-with-budget'; + +describe('collectWithBudget', () => { + it('collects all paths when no budget is exceeded', async () => { + await expect( + collectWithBudget(paths(['a.ts', 'b.ts']), { + maxFiles: 10, + timeoutMs: 1_000, + now: () => 0, + }) + ).resolves.toEqual({ + paths: ['a.ts', 'b.ts'], + truncated: false, + truncateReason: undefined, + }); + }); + + it('truncates at maxFiles', async () => { + await expect( + collectWithBudget(paths(['a.ts', 'b.ts', 'c.ts']), { + maxFiles: 2, + timeoutMs: 1_000, + now: () => 0, + }) + ).resolves.toEqual({ + paths: ['a.ts', 'b.ts'], + truncated: true, + truncateReason: 'maxEntries', + }); + }); + + it('truncates when the injected clock exceeds the time budget', async () => { + const ticks = [0, 0, 31]; + + await expect( + collectWithBudget(paths(['a.ts', 'b.ts']), { + maxFiles: 10, + timeoutMs: 30, + now: () => ticks.shift() ?? 31, + }) + ).resolves.toEqual({ + paths: ['a.ts'], + truncated: true, + truncateReason: 'timeBudget', + }); + }); +}); + +async function* paths(values: string[]): AsyncIterable { + for (const value of values) { + yield value as RelPath; + } +} diff --git a/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts b/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts new file mode 100644 index 0000000000..6ebb128f75 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts @@ -0,0 +1,41 @@ +import type { RelPath } from '@emdash/core/files'; +import type { FileIndexTruncateReason } from './workspace-file-index-store'; + +export type CollectWithBudgetOptions = { + maxFiles: number; + timeoutMs: number; + now?: () => number; +}; + +export type BudgetedFileCollection = { + paths: RelPath[]; + truncated: boolean; + truncateReason?: FileIndexTruncateReason; +}; + +export async function collectWithBudget( + paths: AsyncIterable, + options: CollectWithBudgetOptions +): Promise { + const now = options.now ?? Date.now; + const startTime = now(); + const collected: RelPath[] = []; + let truncated = false; + let truncateReason: FileIndexTruncateReason | undefined; + + for await (const relPath of paths) { + if (now() - startTime > options.timeoutMs) { + truncated = true; + truncateReason = 'timeBudget'; + break; + } + if (collected.length >= options.maxFiles) { + truncated = true; + truncateReason = 'maxEntries'; + break; + } + collected.push(relPath); + } + + return { paths: collected, truncated, truncateReason }; +} diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts new file mode 100644 index 0000000000..af17812e7e --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts @@ -0,0 +1,230 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { IFilesRuntime, RelPath } from '@emdash/core/files'; +import type BetterSqlite3 from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { WorkspaceFileIndexServiceOptions } from './workspace-file-index-service'; + +type LoadedService = Awaited>; +type FileIndexMetaRow = { + status: string; + file_count: number; + truncate_reason: string | null; +}; + +let loadedService: LoadedService | undefined; + +describe('WorkspaceFileIndexService', () => { + afterEach(async () => { + vi.useRealTimers(); + loadedService?.service.onWorkspaceDeactivated('ws-1'); + loadedService?.sqlite.close(); + if (loadedService) { + await rm(loadedService.tempDir, { recursive: true, force: true }); + } + loadedService = undefined; + vi.resetModules(); + delete process.env.EMDASH_DB_FILE; + }); + + it('indexes files from core enumeration when a workspace is activated', async () => { + loadedService = await loadService(); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['README.md', 'src/index.ts']), + }); + + expect(indexedPaths(sqlite)).toEqual(['README.md', 'src/index.ts']); + expect(indexMeta(sqlite)).toEqual({ + status: 'complete', + file_count: 2, + truncate_reason: null, + }); + expect(service.search('ws-1', 'index')).toEqual([ + { path: 'src/index.ts', filename: 'index.ts' }, + ]); + }); + + it('applies path-changing file events incrementally', async () => { + loadedService = await loadService(); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['src/changed.ts', 'src/old.ts']), + }); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [ + { kind: 'create', path: 'src/new.ts', entryType: 'file' }, + { kind: 'update', path: 'src/changed.ts', entryType: 'file' }, + { kind: 'update', path: 'src/missing.ts', entryType: 'file' }, + { kind: 'delete', path: 'src/old.ts', entryType: 'file' }, + ], + }); + + expect(indexedPaths(sqlite)).toEqual(['src/changed.ts', 'src/new.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 2 }); + }); + + it('removes descendants when a directory-like path is deleted', async () => { + loadedService = await loadService(); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['other.ts', 'src/a.ts', 'src/nested/b.ts']), + }); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [{ kind: 'delete', path: 'src', entryType: 'unknown' }], + }); + + expect(indexedPaths(sqlite)).toEqual(['other.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 1 }); + }); + + it('reindexes from core enumeration on resync', async () => { + vi.useFakeTimers(); + loadedService = await loadService({ reindexDebounceMs: 1 }); + const { service, sqlite } = loadedService; + let paths = ['stale.ts']; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => paths), + }); + expect(indexedPaths(sqlite)).toEqual(['stale.ts']); + + paths = ['fresh.ts']; + service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); + await vi.advanceTimersByTimeAsync(1); + + expect(indexedPaths(sqlite)).toEqual(['fresh.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 1 }); + }); + + it('records truncated full indexes as incomplete', async () => { + loadedService = await loadService({ maxFiles: 2 }); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['a.ts', 'b.ts', 'c.ts']), + }); + + expect(indexedPaths(sqlite)).toEqual(['a.ts', 'b.ts']); + expect(indexMeta(sqlite)).toEqual({ + status: 'truncated', + file_count: 2, + truncate_reason: 'maxEntries', + }); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [{ kind: 'create', path: 'd.ts', entryType: 'file' }], + }); + + expect(indexedPaths(sqlite)).toEqual(['a.ts', 'b.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'truncated', file_count: 2 }); + }); + + it('does not grow a complete index past the file cap', async () => { + loadedService = await loadService({ maxFiles: 2 }); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['a.ts', 'b.ts']), + }); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [{ kind: 'create', path: 'c.ts', entryType: 'file' }], + }); + + expect(indexedPaths(sqlite)).toEqual(['a.ts', 'b.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'stale', file_count: 2 }); + }); +}); + +async function loadService(options: WorkspaceFileIndexServiceOptions = {}) { + vi.resetModules(); + const tempDir = await mkdtemp(join(tmpdir(), 'emdash-file-index-')); + process.env.EMDASH_DB_FILE = join(tempDir, 'test.db'); + + const [{ WorkspaceFileIndexService }, { sqlite }] = await Promise.all([ + import('./workspace-file-index-service'), + import('@main/db/client'), + ]); + createFileIndexTables(sqlite); + + return { + service: new WorkspaceFileIndexService(options), + sqlite, + tempDir, + }; +} + +function createFileIndexTables(sqlite: BetterSqlite3.Database): void { + sqlite.exec(` + CREATE VIRTUAL TABLE workspace_file_index USING fts5( + workspace_id UNINDEXED, + path, + filename, + tokenize = 'trigram case_sensitive 0' + ); + CREATE TABLE workspace_file_index_meta ( + workspace_id TEXT PRIMARY KEY, + indexed_at INTEGER NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('complete', 'stale', 'truncated')), + file_count INTEGER NOT NULL, + truncate_reason TEXT + CHECK (truncate_reason IS NULL OR truncate_reason IN ('maxEntries', 'timeBudget')) + ); + `); +} + +function filesRuntime(readPaths: () => readonly string[]): IFilesRuntime { + return { + openTree: async () => { + throw new Error('openTree is not used by WorkspaceFileIndexService tests'); + }, + watchChanges: () => { + throw new Error('watchChanges is not used by WorkspaceFileIndexService tests'); + }, + enumerate: () => ({ + success: true, + data: (async function* () { + for (const path of readPaths()) { + yield path as RelPath; + } + })(), + }), + dispose: async () => {}, + }; +} + +function indexedPaths(sqlite: BetterSqlite3.Database): string[] { + return ( + sqlite.prepare(`SELECT path FROM workspace_file_index ORDER BY path`).all() as Array<{ + path: string; + }> + ).map((row) => row.path); +} + +function indexMeta(sqlite: BetterSqlite3.Database): FileIndexMetaRow | undefined { + return sqlite + .prepare( + `SELECT status, file_count, truncate_reason + FROM workspace_file_index_meta + WHERE workspace_id = 'ws-1'` + ) + .get() as FileIndexMetaRow | undefined; +} diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts new file mode 100644 index 0000000000..0d540e75e7 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts @@ -0,0 +1,260 @@ +import type { IFilesRuntime, RelPath } from '@emdash/core/files'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + FileHit, + FileIndexMeta, + IWorkspaceFileIndexStore, +} from './workspace-file-index-store'; + +vi.mock('./workspace-file-index-store', () => ({ + WorkspaceFileIndexStore: class {}, +})); + +describe('WorkspaceFileIndexService', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('delegates initialize and search to the store', async () => { + const store = new FakeStore(); + store.searchResults = [{ path: 'src/index.ts', filename: 'index.ts' }]; + const service = await createService(store); + + service.initialize(); + + expect(store.evictedDays).toBe(14); + expect(service.search('ws-1', 'index')).toEqual([ + { path: 'src/index.ts', filename: 'index.ts' }, + ]); + expect(store.operations).toContain('search:index'); + }); + + it('refreshes complete metadata on activation without enumerating', async () => { + const store = new FakeStore(); + store.meta.set('ws-1', { status: 'complete', fileCount: 1, truncateReason: null }); + const service = await createService(store); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => { + throw new Error('should not enumerate'); + }), + }); + + expect(store.operations).toEqual(['refresh:ws-1']); + }); + + it('indexes from enumeration when metadata is missing', async () => { + const store = new FakeStore(); + const service = await createService(store); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['README.md', 'src/index.ts']), + }); + + expect([...store.pathSet('ws-1')].sort()).toEqual(['README.md', 'src/index.ts']); + expect(store.meta.get('ws-1')).toEqual({ + status: 'complete', + fileCount: 2, + truncateReason: null, + }); + }); + + it('debounces and coalesces resync requests', async () => { + vi.useFakeTimers(); + const store = new FakeStore(); + store.meta.set('ws-1', { status: 'complete', fileCount: 1, truncateReason: null }); + const service = await createService(store, { reindexDebounceMs: 5 }); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['fresh.ts']), + }); + service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); + service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); + + await vi.advanceTimersByTimeAsync(5); + + expect(store.operations.filter((op) => op.startsWith('sync:'))).toEqual(['sync:fresh.ts']); + expect(store.meta.get('ws-1')).toMatchObject({ status: 'complete', fileCount: 1 }); + }); + + it('applies deletes before creates, ignores updates, and recounts once for subtree deletes', async () => { + const store = new FakeStore(); + store.meta.set('ws-1', { status: 'complete', fileCount: 3, truncateReason: null }); + store.paths.set('ws-1', new Set(['changed.ts', 'dir/a.ts', 'old.ts'])); + const service = await createService(store); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [ + { kind: 'create', path: 'new.ts', entryType: 'file' }, + { kind: 'update', path: 'missing.ts', entryType: 'file' }, + { kind: 'delete', path: 'old.ts', entryType: 'file' }, + { kind: 'delete', path: 'dir', entryType: 'unknown' }, + ], + }); + + expect([...store.pathSet('ws-1')].sort()).toEqual(['changed.ts', 'new.ts']); + expect(store.operations).toEqual([ + 'transaction', + 'count:ws-1', + 'deletePath:old.ts', + 'deleteSubtree:dir', + 'count:ws-1', + 'insert:new.ts', + 'count:ws-1', + 'record:complete:2', + ]); + }); + + it('marks the index stale when creates would exceed the cap', async () => { + vi.useFakeTimers(); + const store = new FakeStore(); + store.meta.set('ws-1', { status: 'complete', fileCount: 2, truncateReason: null }); + store.paths.set('ws-1', new Set(['a.ts', 'b.ts'])); + const service = await createService(store, { maxFiles: 2, reindexDebounceMs: 1_000 }); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['a.ts', 'b.ts', 'c.ts']), + }); + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [ + { kind: 'delete', path: 'missing.ts', entryType: 'file' }, + { kind: 'create', path: 'c.ts', entryType: 'file' }, + ], + }); + + expect([...store.pathSet('ws-1')].sort()).toEqual(['a.ts', 'b.ts']); + expect(store.meta.get('ws-1')).toMatchObject({ status: 'stale', fileCount: 2 }); + }); + + it('ignores incremental changes while the current index is truncated', async () => { + const store = new FakeStore(); + store.meta.set('ws-1', { status: 'truncated', fileCount: 2, truncateReason: 'maxEntries' }); + store.paths.set('ws-1', new Set(['a.ts', 'b.ts'])); + const service = await createService(store); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [{ kind: 'create', path: 'c.ts', entryType: 'file' }], + }); + + expect([...store.pathSet('ws-1')].sort()).toEqual(['a.ts', 'b.ts']); + expect(store.operations).toEqual([]); + }); +}); + +async function createService( + store: FakeStore, + options: { maxFiles?: number; reindexDebounceMs?: number } = {} +) { + const { WorkspaceFileIndexService } = await import('./workspace-file-index-service'); + return new WorkspaceFileIndexService({ store, ...options }); +} + +function filesRuntime(readPaths: () => readonly string[]): IFilesRuntime { + return { + openTree: async () => { + throw new Error('openTree is not used by WorkspaceFileIndexService tests'); + }, + watchChanges: () => { + throw new Error('watchChanges is not used by WorkspaceFileIndexService tests'); + }, + enumerate: () => ({ + success: true, + data: (async function* () { + for (const path of readPaths()) { + yield path as RelPath; + } + })(), + }), + dispose: async () => {}, + }; +} + +class FakeStore implements IWorkspaceFileIndexStore { + meta = new Map(); + paths = new Map>(); + operations: string[] = []; + evictedDays: number | undefined; + searchResults: FileHit[] = []; + + transaction(fn: () => T): T { + this.operations.push('transaction'); + return fn(); + } + + getMeta(workspaceId: string): FileIndexMeta | null { + return this.meta.get(workspaceId) ?? null; + } + + recordMeta(workspaceId: string, meta: FileIndexMeta): void { + this.operations.push(`record:${meta.status}:${meta.fileCount}`); + this.meta.set(workspaceId, meta); + } + + refreshMetaTimestamp(workspaceId: string): void { + this.operations.push(`refresh:${workspaceId}`); + } + + syncRows(workspaceId: string, paths: RelPath[]): void { + this.operations.push(`sync:${paths.join(',')}`); + this.paths.set(workspaceId, new Set(paths)); + } + + insertPath(workspaceId: string, path: string): boolean { + this.operations.push(`insert:${path}`); + const paths = this.pathSet(workspaceId); + const alreadyIndexed = paths.has(path); + paths.add(path); + return !alreadyIndexed; + } + + deletePath(workspaceId: string, path: string): boolean { + this.operations.push(`deletePath:${path}`); + return this.pathSet(workspaceId).delete(path); + } + + deleteSubtree(workspaceId: string, path: string): void { + this.operations.push(`deleteSubtree:${path}`); + const paths = this.pathSet(workspaceId); + for (const indexedPath of [...paths]) { + if (indexedPath === path || indexedPath.startsWith(`${path}/`)) { + paths.delete(indexedPath); + } + } + } + + countIndexedFiles(workspaceId: string): number { + this.operations.push(`count:${workspaceId}`); + return this.pathSet(workspaceId).size; + } + + search(_workspaceId: string, query: string): FileHit[] { + this.operations.push(`search:${query}`); + return this.searchResults; + } + + deleteIndex(workspaceId: string): void { + this.operations.push(`deleteIndex:${workspaceId}`); + this.paths.delete(workspaceId); + this.meta.delete(workspaceId); + } + + evict(staleDays: number): void { + this.evictedDays = staleDays; + } + + pathSet(workspaceId: string): Set { + let paths = this.paths.get(workspaceId); + if (!paths) { + paths = new Set(); + this.paths.set(workspaceId, paths); + } + return paths; + } +} diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts index 619a81740d..6f97c018d6 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts @@ -1,205 +1,270 @@ -import { basename } from 'node:path'; -import { isIgnored, type FileChangeUpdate } from '@emdash/core/files'; -import type { Workspace } from '@main/core/workspaces/workspace'; -import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import { sqlite } from '@main/db/client'; +import { + isIgnored, + type FileChange, + type FileChangeUpdate, + type IFilesRuntime, +} from '@emdash/core/files'; import { log } from '@main/lib/logger'; +import { collectWithBudget } from './collect-with-budget'; +import { + WorkspaceFileIndexStore, + type FileHit, + type IWorkspaceFileIndexStore, +} from './workspace-file-index-store'; const STALE_DAYS = 14; -const MAX_FILES = 50_000; -const CRAWL_TIMEOUT_MS = 30_000; -const REINDEX_DEBOUNCE_MS = 3_000; +const DEFAULT_MAX_FILES = 50_000; +const DEFAULT_REINDEX_TIMEOUT_MS = 30_000; +const DEFAULT_REINDEX_DEBOUNCE_MS = 3_000; -type FileHit = { path: string; filename: string }; +export type WorkspaceFileIndexSource = { + rootPath: string; + filesRuntime: IFilesRuntime; +}; -class WorkspaceFileIndexService { - private crawling = new Set(); +export type WorkspaceFileIndexServiceOptions = { + store?: IWorkspaceFileIndexStore; + maxFiles?: number; + reindexTimeoutMs?: number; + reindexDebounceMs?: number; + now?: () => number; +}; + +export class WorkspaceFileIndexService { + private readonly store: IWorkspaceFileIndexStore; + private reindexing = new Set(); + private pendingReindex = new Set(); private debounceTimers = new Map>(); + private activeSources = new Map(); + + constructor(private readonly options: WorkspaceFileIndexServiceOptions = {}) { + this.store = options.store ?? new WorkspaceFileIndexStore(); + } initialize(): void { - this.evictStale(); + this.store.evict(STALE_DAYS); } onWorkspaceFileChange(workspaceId: string, update: FileChangeUpdate): void { - if (update.kind === 'resync' || update.changes.length > 0) this.scheduleReindex(workspaceId); + if (update.kind === 'resync') { + this.scheduleReindex(workspaceId); + return; + } + if (update.changes.length === 0) return; + + const meta = this.store.getMeta(workspaceId); + if (this.reindexing.has(workspaceId) || !meta) { + this.scheduleReindex(workspaceId); + return; + } + if (meta.status === 'stale') { + this.scheduleReindex(workspaceId); + return; + } + if (meta.status === 'truncated') return; + + this.applyChanges(workspaceId, update.changes); } - async onWorkspaceCreated(workspaceId: string, workspace: Workspace): Promise { - const alreadyIndexed = sqlite - .prepare(`SELECT 1 FROM workspace_file_index_meta WHERE workspace_id = ?`) - .get(workspaceId); + async onWorkspaceActivated(workspaceId: string, source: WorkspaceFileIndexSource): Promise { + this.activeSources.set(workspaceId, source); + const meta = this.store.getMeta(workspaceId); - if (alreadyIndexed) { - this.touchMeta(workspaceId); + if (meta?.status === 'complete') { + this.store.refreshMetaTimestamp(workspaceId); return; } - await this.crawl(workspaceId, workspace); + await this.reindex(workspaceId); } - onWorkspaceDestroyed(_workspaceId: string): void { - // Intentionally a no-op: the index ages out 14 days after the last provision. - // Calling touchMeta here would reset the staleness clock on every destroy, - // preventing eviction of stale entries for frequently-cycled workspaces. + onWorkspaceDeactivated(workspaceId: string): void { + // Do not touch meta here: that would reset the staleness clock on every destroy + // and prevent eviction of stale entries for frequently-cycled workspaces. + this.activeSources.delete(workspaceId); + this.pendingReindex.delete(workspaceId); + this.clearDebounceTimer(workspaceId); } deleteIndex(workspaceId: string): void { - try { - sqlite.transaction(() => { - sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`).run(workspaceId); - sqlite - .prepare(`DELETE FROM workspace_file_index_meta WHERE workspace_id = ?`) - .run(workspaceId); - })(); - log.info('WorkspaceFileIndexService: deleted index', { workspaceId }); - } catch (e) { - log.warn('WorkspaceFileIndexService: deleteIndex failed', { workspaceId, error: String(e) }); - } + this.store.deleteIndex(workspaceId); } search(workspaceId: string, query: string): FileHit[] { - const terms = query - .trim() - .split(/[\s\-_/]+/) - .filter((t) => t.length >= 3); + return this.store.search(workspaceId, query); + } - if (terms.length === 0) return []; + private async reindex(workspaceId: string): Promise { + if (this.reindexing.has(workspaceId)) { + this.pendingReindex.add(workspaceId); + return; + } + + this.reindexing.add(workspaceId); - const ftsQuery = terms.map((t) => `"${t}"`).join(' AND '); try { - return sqlite - .prepare( - `SELECT path, filename - FROM workspace_file_index - WHERE workspace_file_index MATCH ? - AND workspace_id = ? - ORDER BY bm25(workspace_file_index, 1.0, 2.0) - LIMIT 20` - ) - .all(ftsQuery, workspaceId) as FileHit[]; + do { + this.pendingReindex.delete(workspaceId); + const source = this.activeSources.get(workspaceId); + if (!source) return; + + const enumeration = source.filesRuntime.enumerate(source.rootPath); + if (!enumeration.success) { + log.warn('WorkspaceFileIndexService: enumerate failed to start', { + workspaceId, + error: enumeration.error, + }); + return; + } + + const result = await collectWithBudget(enumeration.data, { + maxFiles: this.maxFiles, + timeoutMs: this.reindexTimeoutMs, + now: this.options.now, + }); + if (!this.activeSources.has(workspaceId)) return; + + this.store.transaction(() => { + this.store.syncRows(workspaceId, result.paths); + this.store.recordMeta(workspaceId, { + status: result.truncated ? 'truncated' : 'complete', + fileCount: result.paths.length, + truncateReason: result.truncateReason ?? null, + }); + }); + + const logPayload = { + workspaceId, + count: result.paths.length, + truncated: result.truncated, + truncateReason: result.truncateReason, + }; + if (result.truncated) { + log.warn('WorkspaceFileIndexService: indexed partial workspace', logPayload); + } else { + log.info('WorkspaceFileIndexService: indexed workspace', logPayload); + } + } while (this.pendingReindex.has(workspaceId)); } catch (e) { - log.warn('WorkspaceFileIndexService: search failed', { workspaceId, error: String(e) }); - return []; + log.warn('WorkspaceFileIndexService: reindex failed', { workspaceId, error: String(e) }); + } finally { + this.reindexing.delete(workspaceId); } } - private async crawl(workspaceId: string, workspace: Workspace): Promise { - if (this.crawling.has(workspaceId)) return; - this.crawling.add(workspaceId); - + private applyChanges(workspaceId: string, changes: FileChange[]): void { + let needsReindex = false; try { - const result = await workspace.fs.list('', { - recursive: true, - maxEntries: MAX_FILES, - timeBudgetMs: CRAWL_TIMEOUT_MS, - }); + this.store.transaction(() => { + let indexedFileCount = this.store.countIndexedFiles(workspaceId); + let needsCountRefresh = false; + const creates: string[] = []; - const files = result.entries.filter((e) => e.type === 'file' && !isIgnored(e.path)); + for (const change of changes) { + if (isIgnored(change.path)) continue; - sqlite.transaction(() => { - sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`).run(workspaceId); - const stmt = sqlite.prepare( - `INSERT INTO workspace_file_index(workspace_id, path, filename) VALUES (?, ?, ?)` - ); - for (const f of files) { - stmt.run(workspaceId, f.path, basename(f.path)); + if (change.kind === 'delete') { + if (change.entryType === 'file') { + if (this.store.deletePath(workspaceId, change.path)) { + indexedFileCount = Math.max(0, indexedFileCount - 1); + } + } else { + this.store.deleteSubtree(workspaceId, change.path); + needsCountRefresh = true; + } + continue; + } + + if (change.entryType === 'directory') { + needsReindex = true; + continue; + } + + if (change.kind === 'create') { + creates.push(change.path); + } } - })(); - this.touchMeta(workspaceId); - log.info('WorkspaceFileIndexService: indexed workspace', { - workspaceId, - count: files.length, - truncated: result.truncated ?? false, + if (needsCountRefresh) { + indexedFileCount = this.store.countIndexedFiles(workspaceId); + } + + for (const path of creates) { + if (indexedFileCount >= this.maxFiles) { + needsReindex = true; + continue; + } + + const added = this.store.insertPath(workspaceId, path); + if (added) indexedFileCount += 1; + } + }); + + if (needsReindex) { + this.markStale(workspaceId); + this.scheduleReindex(workspaceId); + return; + } + + this.store.recordMeta(workspaceId, { + status: 'complete', + fileCount: this.store.countIndexedFiles(workspaceId), + truncateReason: null, }); } catch (e) { - log.warn('WorkspaceFileIndexService: crawl failed', { workspaceId, error: String(e) }); - } finally { - this.crawling.delete(workspaceId); + log.warn('WorkspaceFileIndexService: incremental update failed', { + workspaceId, + error: String(e), + }); + this.markStale(workspaceId); + this.scheduleReindex(workspaceId); } } private scheduleReindex(workspaceId: string): void { - const existing = this.debounceTimers.get(workspaceId); - if (existing) clearTimeout(existing); + if (!this.activeSources.has(workspaceId)) return; + this.clearDebounceTimer(workspaceId); this.debounceTimers.set( workspaceId, setTimeout(() => { this.debounceTimers.delete(workspaceId); - const ws = workspaceRegistry.get(workspaceId); - if (ws) void this.crawl(workspaceId, ws); - }, REINDEX_DEBOUNCE_MS) + void this.reindex(workspaceId); + }, this.reindexDebounceMs) ); } - private touchMeta(workspaceId: string): void { - try { - sqlite - .prepare( - `INSERT OR REPLACE INTO workspace_file_index_meta (workspace_id, indexed_at) - VALUES (?, unixepoch())` - ) - .run(workspaceId); - } catch (e) { - log.warn('WorkspaceFileIndexService: touchMeta failed', { workspaceId, error: String(e) }); - } + private clearDebounceTimer(workspaceId: string): void { + const timer = this.debounceTimers.get(workspaceId); + if (timer) clearTimeout(timer); + this.debounceTimers.delete(workspaceId); } - private evictStale(): void { + private markStale(workspaceId: string): void { try { - const cutoff = Math.floor(Date.now() / 1000) - STALE_DAYS * 86400; - const stale = sqlite - .prepare(`SELECT workspace_id FROM workspace_file_index_meta WHERE indexed_at < ?`) - .all(cutoff) as Array<{ workspace_id: string }>; - - if (stale.length > 0) { - sqlite.transaction(() => { - const delIndex = sqlite.prepare( - `DELETE FROM workspace_file_index WHERE workspace_id = ?` - ); - const delMeta = sqlite.prepare( - `DELETE FROM workspace_file_index_meta WHERE workspace_id = ?` - ); - for (const row of stale) { - delIndex.run(row.workspace_id); - delMeta.run(row.workspace_id); - } - })(); - log.info('WorkspaceFileIndexService: evicted stale indexes', { count: stale.length }); - } + this.store.recordMeta(workspaceId, { + status: 'stale', + fileCount: this.store.countIndexedFiles(workspaceId), + truncateReason: null, + }); } catch (e) { - log.warn('WorkspaceFileIndexService: evictStale failed', { error: String(e) }); + log.warn('WorkspaceFileIndexService: markStale failed', { workspaceId, error: String(e) }); } + } - try { - const orphans = sqlite - .prepare( - `SELECT m.workspace_id - FROM workspace_file_index_meta m - LEFT JOIN workspaces w ON w.id = m.workspace_id - WHERE w.id IS NULL` - ) - .all() as Array<{ workspace_id: string }>; - - if (orphans.length === 0) return; - - sqlite.transaction(() => { - const delIndex = sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`); - const delMeta = sqlite.prepare( - `DELETE FROM workspace_file_index_meta WHERE workspace_id = ?` - ); - for (const row of orphans) { - delIndex.run(row.workspace_id); - delMeta.run(row.workspace_id); - } - })(); + private get maxFiles(): number { + return this.options.maxFiles ?? DEFAULT_MAX_FILES; + } - log.info('WorkspaceFileIndexService: evicted orphan indexes', { count: orphans.length }); - } catch (e) { - log.warn('WorkspaceFileIndexService: evictOrphans failed', { error: String(e) }); - } + private get reindexTimeoutMs(): number { + return this.options.reindexTimeoutMs ?? DEFAULT_REINDEX_TIMEOUT_MS; + } + + private get reindexDebounceMs(): number { + return this.options.reindexDebounceMs ?? DEFAULT_REINDEX_DEBOUNCE_MS; } } -export const workspaceFileIndexService = new WorkspaceFileIndexService(); +export const workspaceFileIndexService = new WorkspaceFileIndexService({ + store: new WorkspaceFileIndexStore(), +}); diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts new file mode 100644 index 0000000000..d3da924796 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts @@ -0,0 +1,176 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { RelPath } from '@emdash/core/files'; +import type BetterSqlite3 from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type LoadedStore = Awaited>; + +let loadedStore: LoadedStore | undefined; + +describe('WorkspaceFileIndexStore', () => { + afterEach(async () => { + loadedStore?.sqlite.close(); + if (loadedStore) { + await rm(loadedStore.tempDir, { recursive: true, force: true }); + } + loadedStore = undefined; + vi.resetModules(); + delete process.env.EMDASH_DB_FILE; + }); + + it('stores and reads file index metadata', async () => { + loadedStore = await loadStore(); + const { store } = loadedStore; + + store.recordMeta('ws-1', { + status: 'truncated', + fileCount: 50_000, + truncateReason: 'maxEntries', + }); + + expect(store.getMeta('ws-1')).toEqual({ + status: 'truncated', + fileCount: 50_000, + truncateReason: 'maxEntries', + }); + }); + + it('syncs rows by diffing existing paths', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + + store.syncRows('ws-1', relPaths(['a.ts', 'b.ts'])); + store.syncRows('ws-1', relPaths(['b.ts', 'c.ts'])); + + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['b.ts', 'c.ts']); + expect(store.countIndexedFiles('ws-1')).toBe(2); + }); + + it('returns whether insertPath added a new row', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + + expect(store.insertPath('ws-1', 'src/index.ts')).toBe(true); + expect(store.insertPath('ws-1', 'src/index.ts')).toBe(false); + + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['src/index.ts']); + expect(store.countIndexedFiles('ws-1')).toBe(1); + }); + + it('searches with the FTS query dialect', async () => { + loadedStore = await loadStore(); + const { store } = loadedStore; + + store.syncRows('ws-1', relPaths(['README.md', 'src/index.ts', 'src/router.ts'])); + + expect(store.search('ws-1', 'index')).toEqual([{ path: 'src/index.ts', filename: 'index.ts' }]); + expect(store.search('ws-1', 'in')).toEqual([]); + }); + + it('deletes exact paths and escaped subtrees', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + store.syncRows('ws-1', relPaths(['foo_%', 'foo_%/a.ts', 'foo_%/nested/b.ts', 'foo-x/a.ts'])); + + store.deletePath('ws-1', 'foo_%/a.ts'); + store.deleteSubtree('ws-1', 'foo_%'); + + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['foo-x/a.ts']); + }); + + it('deletes an entire workspace index', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + store.syncRows('ws-1', relPaths(['a.ts'])); + store.recordMeta('ws-1', { status: 'complete', fileCount: 1, truncateReason: null }); + + store.deleteIndex('ws-1'); + + expect(indexedPaths(sqlite, 'ws-1')).toEqual([]); + expect(store.getMeta('ws-1')).toBeNull(); + }); + + it('evicts stale and orphaned indexes', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + sqlite.prepare(`INSERT INTO workspaces (id) VALUES (?)`).run('fresh'); + + store.syncRows('stale', relPaths(['stale.ts'])); + store.recordMeta('stale', { status: 'complete', fileCount: 1, truncateReason: null }); + store.syncRows('orphan', relPaths(['orphan.ts'])); + store.recordMeta('orphan', { status: 'complete', fileCount: 1, truncateReason: null }); + store.syncRows('fresh', relPaths(['fresh.ts'])); + store.recordMeta('fresh', { status: 'complete', fileCount: 1, truncateReason: null }); + sqlite + .prepare(`UPDATE workspace_file_index_meta SET indexed_at = ? WHERE workspace_id = ?`) + .run(Math.floor(Date.now() / 1000) - 15 * 86400, 'stale'); + + store.evict(14); + + expect(allIndexedWorkspaces(sqlite)).toEqual(['fresh']); + expect(indexedPaths(sqlite, 'fresh')).toEqual(['fresh.ts']); + }); +}); + +async function loadStore() { + vi.resetModules(); + const tempDir = await mkdtemp(join(tmpdir(), 'emdash-file-index-store-')); + process.env.EMDASH_DB_FILE = join(tempDir, 'test.db'); + + const [{ WorkspaceFileIndexStore }, { sqlite }] = await Promise.all([ + import('./workspace-file-index-store'), + import('@main/db/client'), + ]); + createTables(sqlite); + + return { + store: new WorkspaceFileIndexStore(), + sqlite, + tempDir, + }; +} + +function createTables(sqlite: BetterSqlite3.Database): void { + sqlite.exec(` + CREATE VIRTUAL TABLE workspace_file_index USING fts5( + workspace_id UNINDEXED, + path, + filename, + tokenize = 'trigram case_sensitive 0' + ); + CREATE TABLE workspace_file_index_meta ( + workspace_id TEXT PRIMARY KEY, + indexed_at INTEGER NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('complete', 'stale', 'truncated')), + file_count INTEGER NOT NULL, + truncate_reason TEXT + CHECK (truncate_reason IS NULL OR truncate_reason IN ('maxEntries', 'timeBudget')) + ); + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY + ); + `); +} + +function relPaths(paths: string[]): RelPath[] { + return paths as RelPath[]; +} + +function indexedPaths(sqlite: BetterSqlite3.Database, workspaceId: string): string[] { + return ( + sqlite + .prepare(`SELECT path FROM workspace_file_index WHERE workspace_id = ? ORDER BY path`) + .all(workspaceId) as Array<{ path: string }> + ).map((row) => row.path); +} + +function allIndexedWorkspaces(sqlite: BetterSqlite3.Database): string[] { + return ( + sqlite + .prepare(`SELECT DISTINCT workspace_id FROM workspace_file_index ORDER BY workspace_id`) + .all() as Array<{ workspace_id: string }> + ).map((row) => row.workspace_id); +} diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts new file mode 100644 index 0000000000..311302bd4f --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts @@ -0,0 +1,268 @@ +import { basename } from 'node:path'; +import type { RelPath } from '@emdash/core/files'; +import { sqlite } from '@main/db/client'; +import { log } from '@main/lib/logger'; + +export type FileHit = { path: string; filename: string }; +export type FileIndexStatus = 'complete' | 'stale' | 'truncated'; +export type FileIndexTruncateReason = 'maxEntries' | 'timeBudget'; +export type FileIndexMeta = { + status: FileIndexStatus; + fileCount: number; + truncateReason: FileIndexTruncateReason | null; +}; + +export interface IWorkspaceFileIndexStore { + transaction(fn: () => T): T; + getMeta(workspaceId: string): FileIndexMeta | null; + recordMeta(workspaceId: string, meta: FileIndexMeta): void; + refreshMetaTimestamp(workspaceId: string): void; + syncRows(workspaceId: string, paths: RelPath[]): void; + insertPath(workspaceId: string, path: string): boolean; + deletePath(workspaceId: string, path: string): boolean; + deleteSubtree(workspaceId: string, path: string): void; + countIndexedFiles(workspaceId: string): number; + search(workspaceId: string, query: string): FileHit[]; + deleteIndex(workspaceId: string): void; + evict(staleDays: number): void; +} + +export class WorkspaceFileIndexStore implements IWorkspaceFileIndexStore { + transaction(fn: () => T): T { + return sqlite.transaction(fn)(); + } + + getMeta(workspaceId: string): FileIndexMeta | null { + try { + const row = sqlite + .prepare( + `SELECT status, file_count, truncate_reason + FROM workspace_file_index_meta + WHERE workspace_id = ?` + ) + .get(workspaceId) as + | { status: string; file_count: number; truncate_reason: string | null } + | undefined; + + if (!row || !isFileIndexStatus(row.status)) return null; + return { + status: row.status, + fileCount: row.file_count, + truncateReason: isFileIndexTruncateReason(row.truncate_reason) ? row.truncate_reason : null, + }; + } catch (e) { + log.warn('WorkspaceFileIndexStore: getMeta failed', { workspaceId, error: String(e) }); + return null; + } + } + + recordMeta(workspaceId: string, meta: FileIndexMeta): void { + sqlite + .prepare( + `INSERT OR REPLACE INTO workspace_file_index_meta ( + workspace_id, + indexed_at, + status, + file_count, + truncate_reason + ) + VALUES (?, unixepoch(), ?, ?, ?)` + ) + .run(workspaceId, meta.status, meta.fileCount, meta.truncateReason); + } + + refreshMetaTimestamp(workspaceId: string): void { + try { + sqlite + .prepare( + `UPDATE workspace_file_index_meta + SET indexed_at = unixepoch() + WHERE workspace_id = ?` + ) + .run(workspaceId); + } catch (e) { + log.warn('WorkspaceFileIndexStore: refreshMetaTimestamp failed', { + workspaceId, + error: String(e), + }); + } + } + + syncRows(workspaceId: string, paths: RelPath[]): void { + const existingPaths = this.indexedPathSet(workspaceId); + const desiredPaths = new Set(paths); + const deletePath = sqlite.prepare( + `DELETE FROM workspace_file_index WHERE workspace_id = ? AND path = ?` + ); + const insertPath = sqlite.prepare( + `INSERT INTO workspace_file_index(workspace_id, path, filename) VALUES (?, ?, ?)` + ); + + for (const path of existingPaths) { + if (!desiredPaths.has(path)) deletePath.run(workspaceId, path); + } + + for (const path of paths) { + if (!existingPaths.has(path)) insertPath.run(workspaceId, path, basename(path)); + } + } + + insertPath(workspaceId: string, path: string): boolean { + if (this.hasIndexedPath(workspaceId, path)) return false; + sqlite + .prepare(`INSERT INTO workspace_file_index(workspace_id, path, filename) VALUES (?, ?, ?)`) + .run(workspaceId, path, basename(path)); + return true; + } + + deletePath(workspaceId: string, path: string): boolean { + const result = sqlite + .prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ? AND path = ?`) + .run(workspaceId, path); + return result.changes > 0; + } + + deleteSubtree(workspaceId: string, path: string): void { + sqlite + .prepare( + `DELETE FROM workspace_file_index + WHERE workspace_id = ? + AND (path = ? OR path LIKE ? ESCAPE '\\')` + ) + .run(workspaceId, path, `${escapeSqliteLike(path)}/%`); + } + + countIndexedFiles(workspaceId: string): number { + const row = sqlite + .prepare(`SELECT COUNT(*) AS count FROM workspace_file_index WHERE workspace_id = ?`) + .get(workspaceId) as { count: number }; + return row.count; + } + + search(workspaceId: string, query: string): FileHit[] { + const terms = query + .trim() + .split(/[\s\-_/]+/) + .filter((t) => t.length >= 3); + + if (terms.length === 0) return []; + + const ftsQuery = terms.map((t) => `"${t}"`).join(' AND '); + try { + return sqlite + .prepare( + `SELECT path, filename + FROM workspace_file_index + WHERE workspace_file_index MATCH ? + AND workspace_id = ? + ORDER BY bm25(workspace_file_index, 1.0, 2.0) + LIMIT 20` + ) + .all(ftsQuery, workspaceId) as FileHit[]; + } catch (e) { + log.warn('WorkspaceFileIndexStore: search failed', { workspaceId, error: String(e) }); + return []; + } + } + + deleteIndex(workspaceId: string): void { + try { + sqlite.transaction(() => { + sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`).run(workspaceId); + sqlite + .prepare(`DELETE FROM workspace_file_index_meta WHERE workspace_id = ?`) + .run(workspaceId); + })(); + log.info('WorkspaceFileIndexStore: deleted index', { workspaceId }); + } catch (e) { + log.warn('WorkspaceFileIndexStore: deleteIndex failed', { workspaceId, error: String(e) }); + } + } + + evict(staleDays: number): void { + this.evictStale(staleDays); + this.evictOrphans(); + } + + private indexedPathSet(workspaceId: string): Set { + const rows = sqlite + .prepare(`SELECT path FROM workspace_file_index WHERE workspace_id = ?`) + .all(workspaceId) as Array<{ path: string }>; + return new Set(rows.map((row) => row.path)); + } + + private hasIndexedPath(workspaceId: string, path: string): boolean { + return Boolean( + sqlite + .prepare(`SELECT 1 FROM workspace_file_index WHERE workspace_id = ? AND path = ? LIMIT 1`) + .get(workspaceId, path) + ); + } + + private evictStale(staleDays: number): void { + try { + const cutoff = Math.floor(Date.now() / 1000) - staleDays * 86400; + const stale = sqlite + .prepare(`SELECT workspace_id FROM workspace_file_index_meta WHERE indexed_at < ?`) + .all(cutoff) as Array<{ workspace_id: string }>; + + if (stale.length === 0) return; + + sqlite.transaction(() => { + const delIndex = sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`); + const delMeta = sqlite.prepare( + `DELETE FROM workspace_file_index_meta WHERE workspace_id = ?` + ); + for (const row of stale) { + delIndex.run(row.workspace_id); + delMeta.run(row.workspace_id); + } + })(); + log.info('WorkspaceFileIndexStore: evicted stale indexes', { count: stale.length }); + } catch (e) { + log.warn('WorkspaceFileIndexStore: evictStale failed', { error: String(e) }); + } + } + + private evictOrphans(): void { + try { + const orphans = sqlite + .prepare( + `SELECT m.workspace_id + FROM workspace_file_index_meta m + LEFT JOIN workspaces w ON w.id = m.workspace_id + WHERE w.id IS NULL` + ) + .all() as Array<{ workspace_id: string }>; + + if (orphans.length === 0) return; + + sqlite.transaction(() => { + const delIndex = sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`); + const delMeta = sqlite.prepare( + `DELETE FROM workspace_file_index_meta WHERE workspace_id = ?` + ); + for (const row of orphans) { + delIndex.run(row.workspace_id); + delMeta.run(row.workspace_id); + } + })(); + + log.info('WorkspaceFileIndexStore: evicted orphan indexes', { count: orphans.length }); + } catch (e) { + log.warn('WorkspaceFileIndexStore: evictOrphans failed', { error: String(e) }); + } + } +} + +function escapeSqliteLike(value: string): string { + return value.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function isFileIndexStatus(value: string): value is FileIndexStatus { + return value === 'complete' || value === 'stale' || value === 'truncated'; +} + +function isFileIndexTruncateReason(value: string | null): value is FileIndexTruncateReason { + return value === 'maxEntries' || value === 'timeBudget'; +} diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts index c4072cf637..8fda946a75 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts @@ -171,6 +171,10 @@ export function createWorkspaceFactory( workspace, onCreateSideEffect: (ws) => { + void workspaceFileIndexService.onWorkspaceActivated(workspaceId, { + rootPath: ws.path, + filesRuntime, + }); unsubscribeGitUpdates = ws.gitWorktree.subscribe((update) => handleGitWorktreeUpdate(workspaceId, update, (emitted) => { events.emit(gitWorktreeUpdateChannel, { @@ -219,7 +223,6 @@ export function createWorkspaceFactory( if (ownsFetchService) { gitRepositoryFetchService.start(); } - void workspaceFileIndexService.onWorkspaceCreated(workspaceId, ws); void (async () => { if (scripts?.setup && (projectSettings.autoRunSetupScriptOnTaskCreation ?? true)) { const setupResult = await runLifecycleScriptWithPolicy({ @@ -271,7 +274,7 @@ export function createWorkspaceFactory( if (ownsFetchService) { gitRepositoryFetchService.stop(); } - workspaceFileIndexService.onWorkspaceDestroyed(workspaceId); + workspaceFileIndexService.onWorkspaceDeactivated(workspaceId); const latestTaskSettings = await getEffectiveTaskSettings({ projectSettings: context.settings, taskFs: ws.fs, diff --git a/apps/emdash-desktop/src/main/db/initialize.ts b/apps/emdash-desktop/src/main/db/initialize.ts index 10f39db187..48fe415844 100644 --- a/apps/emdash-desktop/src/main/db/initialize.ts +++ b/apps/emdash-desktop/src/main/db/initialize.ts @@ -91,7 +91,7 @@ function ensureSearchIndex(connection: BetterSqlite3.Database): void { * changes without a full Drizzle migration. */ function ensureFileIndex(connection: BetterSqlite3.Database): void { - const FILE_INDEX_VERSION = '1'; + const FILE_INDEX_VERSION = '2'; const row = connection.prepare(`SELECT value FROM kv WHERE key = 'file_index_version'`).get() as | { value: string } @@ -111,8 +111,13 @@ function ensureFileIndex(connection: BetterSqlite3.Database): void { `); connection.exec(` CREATE TABLE workspace_file_index_meta ( - workspace_id TEXT PRIMARY KEY, - indexed_at INTEGER NOT NULL + workspace_id TEXT PRIMARY KEY, + indexed_at INTEGER NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('complete', 'stale', 'truncated')), + file_count INTEGER NOT NULL, + truncate_reason TEXT + CHECK (truncate_reason IS NULL OR truncate_reason IN ('maxEntries', 'timeBudget')) ) `); connection From 979bbb538670845333662238d8842c157e1e803f Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:42:46 -0700 Subject: [PATCH 18/37] feat: add filesystem to file runtime --- packages/core/src/files/errors.ts | 10 +- packages/core/src/files/files-runtime.test.ts | 19 ++ packages/core/src/files/files-runtime.ts | 13 + .../core/src/files/fs/file-system.test.ts | 96 ++++++++ packages/core/src/files/fs/file-system.ts | 231 ++++++++++++++++++ packages/core/src/files/fs/index.ts | 9 + packages/core/src/files/fs/types.ts | 44 ++++ packages/core/src/files/index.ts | 1 + packages/core/src/files/types.ts | 10 + 9 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/files/fs/file-system.test.ts create mode 100644 packages/core/src/files/fs/file-system.ts create mode 100644 packages/core/src/files/fs/index.ts create mode 100644 packages/core/src/files/fs/types.ts diff --git a/packages/core/src/files/errors.ts b/packages/core/src/files/errors.ts index 1c180b23e2..a2ada09eb1 100644 --- a/packages/core/src/files/errors.ts +++ b/packages/core/src/files/errors.ts @@ -2,8 +2,14 @@ export type FilesOnError = (context: string, error: unknown) => void; export type FileError = | { type: 'invalid-path'; path: string; message: string } - | { type: 'fs-error'; path: string; message: string }; + | { type: 'fs-error'; path: string; message: string; code?: string }; export function classifyFileError(error: unknown, path: string): FileError { - return { type: 'fs-error', path, message: String(error) }; + const code = (error as { code?: unknown } | undefined)?.code; + return { + type: 'fs-error', + path, + message: error instanceof Error ? error.message : String(error), + ...(typeof code === 'string' ? { code } : {}), + }; } diff --git a/packages/core/src/files/files-runtime.test.ts b/packages/core/src/files/files-runtime.test.ts index 08037cf3db..459318126d 100644 --- a/packages/core/src/files/files-runtime.test.ts +++ b/packages/core/src/files/files-runtime.test.ts @@ -86,4 +86,23 @@ describe('FilesRuntime', () => { await runtime.dispose(); }); + + it('opens file systems without acquiring a watch subscription', async () => { + const root = await makeRoot(); + await writeFile(path.join(root, 'file.txt'), 'content'); + const watcher = new RecordingWatchService(); + const runtime = new FilesRuntime({ watcher }); + + const fileSystem = runtime.fileSystem(root); + expect(fileSystem.success).toBe(true); + if (!fileSystem.success) return; + + await expect(fileSystem.data.readText('file.txt')).resolves.toMatchObject({ + success: true, + data: { content: 'content', truncated: false, totalSize: 7 }, + }); + expect(watcher.watches).toEqual([]); + + await runtime.dispose(); + }); }); diff --git a/packages/core/src/files/files-runtime.ts b/packages/core/src/files/files-runtime.ts index b6348cb03b..94e88b5a3e 100644 --- a/packages/core/src/files/files-runtime.ts +++ b/packages/core/src/files/files-runtime.ts @@ -5,6 +5,7 @@ import { WatchService, realpathOrResolve, type IWatchService } from '../watch'; import { FileChanges } from './changes/changes'; import { enumerate as enumerateFiles } from './enumerate'; import type { FileError, FilesOnError } from './errors'; +import { FileSystem } from './fs/file-system'; import type { FileTreeError, FileTreeOnError } from './tree/errors'; import { FileTree } from './tree/file-tree'; import type { FileTreeLease } from './tree/types'; @@ -13,6 +14,7 @@ import type { FileChangeSubscription, FileChangeUpdate, FileChangeWatchOptions, + IFileSystem, IFilesRuntime, } from './types'; @@ -110,6 +112,17 @@ export class FilesRuntime implements IFilesRuntime { return ok(enumerateFiles(realpathOrResolve(path.resolve(rootPath)))); } + fileSystem(rootPath: string): Result { + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: '', + message: 'FilesRuntime disposed', + }); + } + return ok(new FileSystem(realpathOrResolve(path.resolve(rootPath)))); + } + async dispose(): Promise { this.disposeRequested = true; await this.trees.dispose(); diff --git a/packages/core/src/files/fs/file-system.test.ts b/packages/core/src/files/fs/file-system.test.ts new file mode 100644 index 0000000000..412eb38081 --- /dev/null +++ b/packages/core/src/files/fs/file-system.test.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { FileSystem } from './file-system'; + +const roots: string[] = []; + +async function makeRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'emdash-core-fs-')); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('FileSystem', () => { + it('reads text and bytes with truncation metadata', async () => { + const root = await makeRoot(); + await writeFile(path.join(root, 'file.txt'), 'hello world', 'utf8'); + const fs = new FileSystem(root); + + const text = await fs.readText('file.txt', { maxBytes: 5 }); + expect(text.success).toBe(true); + if (!text.success) return; + expect(text.data).toEqual({ content: 'hello', truncated: true, totalSize: 11 }); + + const bytes = await fs.readBytes('file.txt', { maxBytes: 20 }); + expect(bytes.success).toBe(true); + if (!bytes.success) return; + expect(Buffer.from(bytes.data.bytes).toString('utf8')).toBe('hello world'); + expect(bytes.data.truncated).toBe(false); + }); + + it('writes files inside the root and creates parent directories', async () => { + const root = await makeRoot(); + const fs = new FileSystem(root); + + const written = await fs.writeText('src/index.ts', 'export {};'); + expect(written.success).toBe(true); + if (!written.success) return; + expect(written.data.bytesWritten).toBe(Buffer.byteLength('export {};')); + await expect(readFile(path.join(root, 'src/index.ts'), 'utf8')).resolves.toBe('export {};'); + }); + + it('rejects absolute paths and parent traversal', async () => { + const root = await makeRoot(); + const fs = new FileSystem(root); + + await expect(fs.readText('/tmp/file.txt')).resolves.toMatchObject({ + success: false, + error: { type: 'invalid-path' }, + }); + await expect(fs.writeText('../file.txt', 'x')).resolves.toMatchObject({ + success: false, + error: { type: 'invalid-path' }, + }); + }); + + it('stats, checks existence, copies, and removes files', async () => { + const root = await makeRoot(); + await mkdir(path.join(root, 'src')); + await writeFile(path.join(root, 'src/a.txt'), 'a', 'utf8'); + const fs = new FileSystem(root); + + const stat = await fs.stat('src/a.txt'); + expect(stat.success).toBe(true); + if (!stat.success) return; + expect(stat.data).toMatchObject({ path: 'src/a.txt', type: 'file', size: 1 }); + + await expect(fs.exists('src/a.txt')).resolves.toEqual({ success: true, data: true }); + await expect(fs.copyFile('src/a.txt', 'dest/b.txt')).resolves.toEqual({ + success: true, + data: undefined, + }); + await expect(readFile(path.join(root, 'dest/b.txt'), 'utf8')).resolves.toBe('a'); + await expect(fs.remove('src/a.txt')).resolves.toEqual({ success: true, data: undefined }); + await expect(fs.exists('src/a.txt')).resolves.toEqual({ success: true, data: false }); + }); + + it('does not remove the root through an empty path', async () => { + const root = await makeRoot(); + const fs = new FileSystem(root); + + await expect(fs.remove('')).resolves.toMatchObject({ + success: false, + error: { type: 'invalid-path' }, + }); + await expect(fs.exists('')).resolves.toMatchObject({ + success: false, + error: { type: 'invalid-path' }, + }); + }); +}); diff --git a/packages/core/src/files/fs/file-system.ts b/packages/core/src/files/fs/file-system.ts new file mode 100644 index 0000000000..34436cc139 --- /dev/null +++ b/packages/core/src/files/fs/file-system.ts @@ -0,0 +1,231 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { err, ok, type Result } from '@emdash/shared'; +import { classifyFileError, type FileError } from '../errors'; +import { resolveInsideRoot } from '../paths'; +import type { + FileStat, + IFileSystem, + ReadBytesResult, + ReadFileOptions, + ReadTextResult, + WriteFileResult, +} from './types'; + +const DEFAULT_MAX_BYTES = 200 * 1024; +const MAX_READ_BYTES = 100 * 1024 * 1024; + +export class FileSystem implements IFileSystem { + constructor(private readonly rootPath: string) {} + + async readText( + relPath: string, + options?: ReadFileOptions + ): Promise> { + const result = await this.readBytes(relPath, options); + if (!result.success) return result; + return ok({ + content: Buffer.from(result.data.bytes).toString('utf8'), + truncated: result.data.truncated, + totalSize: result.data.totalSize, + }); + } + + async readBytes( + relPath: string, + options: ReadFileOptions = {} + ): Promise> { + const resolved = this.resolve(relPath); + if (!resolved.success) return resolved; + + try { + const stat = await fs.stat(resolved.data.absPath); + if (stat.isDirectory()) { + return err({ + type: 'fs-error', + path: relPath, + message: `Path is a directory: ${relPath}`, + code: 'EISDIR', + }); + } + + const maxBytes = normalizeMaxBytes(options.maxBytes); + const readSize = Math.min(stat.size, maxBytes); + if (readSize === 0) { + return ok({ + bytes: new Uint8Array(), + truncated: stat.size > maxBytes, + totalSize: stat.size, + }); + } + + const handle = await fs.open(resolved.data.absPath, 'r'); + try { + const buffer = Buffer.alloc(readSize); + const { bytesRead } = await handle.read(buffer, 0, readSize, 0); + return ok({ + bytes: buffer.subarray(0, bytesRead), + truncated: stat.size > readSize, + totalSize: stat.size, + }); + } finally { + await handle.close(); + } + } catch (error) { + return err(classifyFileError(error, relPath)); + } + } + + async writeText(relPath: string, content: string): Promise> { + return this.writeBuffer(relPath, Buffer.from(content, 'utf8')); + } + + async writeBytes( + relPath: string, + bytes: Uint8Array + ): Promise> { + return this.writeBuffer(relPath, Buffer.from(bytes)); + } + + async stat(relPath: string): Promise> { + const resolved = this.resolve(relPath); + if (!resolved.success) return resolved; + + try { + const stat = await fs.stat(resolved.data.absPath); + return ok({ + path: resolved.data.relPath, + type: stat.isDirectory() ? 'directory' : 'file', + size: stat.size, + mtime: stat.mtime, + ctime: stat.ctime, + mode: stat.mode, + }); + } catch (error) { + return err(classifyFileError(error, relPath)); + } + } + + async exists(relPath: string): Promise> { + const resolved = this.resolve(relPath); + if (!resolved.success) return resolved; + + try { + await fs.access(resolved.data.absPath); + return ok(true); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return ok(false); + return err(classifyFileError(error, relPath)); + } + } + + async mkdir( + relPath: string, + options: { recursive?: boolean } = {} + ): Promise> { + const resolved = this.resolve(relPath, { allowEmpty: true }); + if (!resolved.success) return resolved; + if (resolved.data.relPath === '') return ok(); + + try { + await fs.mkdir(resolved.data.absPath, { recursive: options.recursive ?? false }); + return ok(); + } catch (error) { + return err(classifyFileError(error, relPath)); + } + } + + async remove( + relPath: string, + options: { recursive?: boolean } = {} + ): Promise> { + const resolved = this.resolve(relPath); + if (!resolved.success) return resolved; + + try { + const stat = await fs.stat(resolved.data.absPath); + if (stat.isDirectory()) { + if (!options.recursive) { + return err({ + type: 'fs-error', + path: relPath, + message: `Path is a directory: ${relPath}`, + code: 'EISDIR', + }); + } + await fs.rm(resolved.data.absPath, { recursive: true, force: true }); + return ok(); + } + + await this.unlinkFile(resolved.data.absPath); + return ok(); + } catch (error) { + return err(classifyFileError(error, relPath)); + } + } + + async realPath(relPath: string): Promise> { + const resolved = this.resolve(relPath, { allowEmpty: true }); + if (!resolved.success) return resolved; + + try { + return ok(await fs.realpath(resolved.data.absPath)); + } catch (error) { + return err(classifyFileError(error, relPath)); + } + } + + async copyFile(src: string, dest: string): Promise> { + const resolvedSrc = this.resolve(src); + if (!resolvedSrc.success) return resolvedSrc; + const resolvedDest = this.resolve(dest); + if (!resolvedDest.success) return resolvedDest; + + try { + await fs.mkdir(path.dirname(resolvedDest.data.absPath), { recursive: true }); + await fs.copyFile(resolvedSrc.data.absPath, resolvedDest.data.absPath); + return ok(); + } catch (error) { + return err(classifyFileError(error, dest)); + } + } + + private async writeBuffer( + relPath: string, + buffer: Buffer + ): Promise> { + const resolved = this.resolve(relPath); + if (!resolved.success) return resolved; + + try { + await fs.mkdir(path.dirname(resolved.data.absPath), { recursive: true }); + await fs.writeFile(resolved.data.absPath, buffer); + return ok({ bytesWritten: buffer.byteLength }); + } catch (error) { + return err(classifyFileError(error, relPath)); + } + } + + private async unlinkFile(absPath: string): Promise { + try { + await fs.unlink(absPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EACCES' && code !== 'EPERM') throw error; + + await fs.chmod(absPath, 0o666); + await fs.unlink(absPath); + } + } + + private resolve(relPath: string, options: { allowEmpty?: boolean } = {}) { + return resolveInsideRoot(this.rootPath, relPath, options); + } +} + +function normalizeMaxBytes(maxBytes: number | undefined): number { + if (maxBytes === undefined) return DEFAULT_MAX_BYTES; + if (!Number.isFinite(maxBytes) || maxBytes < 0) return 0; + return Math.min(Math.floor(maxBytes), MAX_READ_BYTES); +} diff --git a/packages/core/src/files/fs/index.ts b/packages/core/src/files/fs/index.ts new file mode 100644 index 0000000000..4200133a80 --- /dev/null +++ b/packages/core/src/files/fs/index.ts @@ -0,0 +1,9 @@ +export { FileSystem } from './file-system'; +export type { + FileStat, + IFileSystem, + ReadBytesResult, + ReadFileOptions, + ReadTextResult, + WriteFileResult, +} from './types'; diff --git a/packages/core/src/files/fs/types.ts b/packages/core/src/files/fs/types.ts new file mode 100644 index 0000000000..c1e11bd34f --- /dev/null +++ b/packages/core/src/files/fs/types.ts @@ -0,0 +1,44 @@ +import type { Result } from '@emdash/shared'; +import type { FileError } from '../errors'; + +export type FileStat = { + path: string; + type: 'file' | 'directory'; + size: number; + mtime: Date; + ctime: Date; + mode: number; +}; + +export type ReadFileOptions = { + maxBytes?: number; +}; + +export type ReadTextResult = { + content: string; + truncated: boolean; + totalSize: number; +}; + +export type ReadBytesResult = { + bytes: Uint8Array; + truncated: boolean; + totalSize: number; +}; + +export type WriteFileResult = { + bytesWritten: number; +}; + +export interface IFileSystem { + readText(path: string, options?: ReadFileOptions): Promise>; + readBytes(path: string, options?: ReadFileOptions): Promise>; + writeText(path: string, content: string): Promise>; + writeBytes(path: string, bytes: Uint8Array): Promise>; + stat(path: string): Promise>; + exists(path: string): Promise>; + mkdir(path: string, options?: { recursive?: boolean }): Promise>; + remove(path: string, options?: { recursive?: boolean }): Promise>; + realPath(path: string): Promise>; + copyFile(src: string, dest: string): Promise>; +} diff --git a/packages/core/src/files/index.ts b/packages/core/src/files/index.ts index 2e7f73f7f7..4da4f7b16d 100644 --- a/packages/core/src/files/index.ts +++ b/packages/core/src/files/index.ts @@ -1,6 +1,7 @@ export { enumerate } from './enumerate'; export { FilesRuntime, type FilesRuntimeOptions } from './files-runtime'; export { classifyFileError, type FileError, type FilesOnError } from './errors'; +export { FileSystem } from './fs'; export { IGNORED_PATH_SEGMENTS, isIgnored, watchIgnoreGlobs } from './ignores'; export { basenameFromRelPath, diff --git a/packages/core/src/files/types.ts b/packages/core/src/files/types.ts index 433492adc5..813db58a64 100644 --- a/packages/core/src/files/types.ts +++ b/packages/core/src/files/types.ts @@ -5,6 +5,7 @@ import type { FileChangeWatchOptions, } from './changes/types'; import type { FileError } from './errors'; +import type { IFileSystem } from './fs/types'; import type { RelPath } from './paths'; import type { FileTreeError } from './tree/errors'; import type { FileTreeLease } from './tree/types'; @@ -19,6 +20,7 @@ export interface IFilesRuntime { options?: FileChangeWatchOptions ): Result; enumerate(rootPath: string): Result; + fileSystem(rootPath: string): Result; dispose(): Promise; } @@ -31,6 +33,14 @@ export type { FileEntryType, IFileChanges, } from './changes/types'; +export type { + FileStat, + IFileSystem, + ReadBytesResult, + ReadFileOptions, + ReadTextResult, + WriteFileResult, +} from './fs/types'; export type { FileNode, FileNodeType, FileTreeScope, NodeId } from './tree/models/tree'; export type { FileTreeLease, From d6191be388b5e4c0c3eda11a1b767885b2edb9a3 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:00:36 -0700 Subject: [PATCH 19/37] feat(core-files): use absolute machine paths --- packages/core/package.json | 1 + .../core/src/files/changes/changes.test.ts | 16 +- packages/core/src/files/changes/changes.ts | 45 +++-- packages/core/src/files/changes/types.ts | 4 +- packages/core/src/files/enumerate.test.ts | 12 +- packages/core/src/files/enumerate.ts | 25 ++- packages/core/src/files/errors.ts | 19 ++ packages/core/src/files/files-runtime.test.ts | 58 ++++-- packages/core/src/files/files-runtime.ts | 28 +-- .../core/src/files/fs/file-system.test.ts | 110 +++++++--- packages/core/src/files/fs/file-system.ts | 188 ++++++++++++------ packages/core/src/files/fs/index.ts | 3 + packages/core/src/files/fs/types.ts | 11 + packages/core/src/files/ignores.ts | 9 +- packages/core/src/files/index.test.ts | 4 +- packages/core/src/files/index.ts | 23 +-- packages/core/src/files/paths.ts | 81 +------- .../core/src/files/tree/file-tree.test.ts | 60 ++++-- packages/core/src/files/tree/file-tree.ts | 24 ++- packages/core/src/files/tree/list.ts | 61 +++--- .../src/files/tree/watch/classifier.test.ts | 75 ++++--- .../core/src/files/tree/watch/classifier.ts | 34 ++-- packages/core/src/files/types.ts | 9 +- pnpm-lock.yaml | 3 + 24 files changed, 541 insertions(+), 362 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 6a0cf9924d..95f005b127 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,6 +63,7 @@ "dependencies": { "@emdash/shared": "workspace:*", "@parcel/watcher": "^2.5.6", + "glob": "^13.0.6", "semver": "^7.8.4", "smol-toml": "^1.6.0", "zod": "^4.4.3" diff --git a/packages/core/src/files/changes/changes.test.ts b/packages/core/src/files/changes/changes.test.ts index c1ce698b0d..872b750929 100644 --- a/packages/core/src/files/changes/changes.test.ts +++ b/packages/core/src/files/changes/changes.test.ts @@ -75,10 +75,10 @@ describe('FileChanges feed', () => { { kind: 'changes', changes: [ - { kind: 'update', path: 'src/index.ts', entryType: 'file' }, - { kind: 'create', path: 'src', entryType: 'directory' }, - { kind: 'update', path: '..valid-name', entryType: 'file' }, - { kind: 'delete', path: 'missing.ts', entryType: 'unknown' }, + { kind: 'update', path: path.join(root!, 'src/index.ts'), entryType: 'file' }, + { kind: 'create', path: path.join(root!, 'src'), entryType: 'directory' }, + { kind: 'update', path: path.join(root!, '..valid-name'), entryType: 'file' }, + { kind: 'delete', path: path.join(root!, 'missing.ts'), entryType: 'unknown' }, ], }, ]); @@ -94,7 +94,9 @@ describe('FileChanges feed', () => { await writeFile(path.join(root!, 'node_modules/pkg.js'), 'content'); const updates: FileChangeUpdate[] = []; - const subscription = files.watch((update) => updates.push(update), { paths: ['src'] }); + const subscription = files.watch((update) => updates.push(update), { + paths: [path.join(root!, 'src')], + }); expect(subscription.success).toBe(true); watcher.emit([ @@ -106,7 +108,7 @@ describe('FileChanges feed', () => { expect(updates).toEqual([ { kind: 'changes', - changes: [{ kind: 'update', path: 'src/index.ts', entryType: 'file' }], + changes: [{ kind: 'update', path: path.join(root!, 'src/index.ts'), entryType: 'file' }], }, ]); }); @@ -125,7 +127,7 @@ describe('FileChanges feed', () => { it('rejects invalid watched paths', async () => { const { files } = await createFiles(); - const subscription = files.watch(() => {}, { paths: ['../outside'] }); + const subscription = files.watch(() => {}, { paths: ['src'] }); expect(subscription.success).toBe(false); if (!subscription.success) expect(subscription.error.type).toBe('invalid-path'); diff --git a/packages/core/src/files/changes/changes.ts b/packages/core/src/files/changes/changes.ts index 85459e74d2..4c64f3fd1f 100644 --- a/packages/core/src/files/changes/changes.ts +++ b/packages/core/src/files/changes/changes.ts @@ -4,7 +4,7 @@ import { err, ok, type Result } from '@emdash/shared'; import type { IWatchService, WatchEvent, WatchHandle } from '../../watch'; import { classifyFileError, type FileError, type FilesOnError } from '../errors'; import { isIgnored, watchIgnoreGlobs } from '../ignores'; -import { isRelPathWithinScope, normalizeRelPaths } from '../paths'; +import { contains, validateAbsolutePath } from '../paths'; import type { FileChange, FileChangeSubscription, @@ -45,7 +45,7 @@ export class FileChanges implements IFileChanges { }); } - const watchedPaths = normalizeWatchedPaths(options.paths); + const watchedPaths = normalizeWatchedPaths(this.rootPath, options.paths); if (!watchedPaths.success) return watchedPaths; const handle = this.watcher.watch( @@ -91,9 +91,25 @@ export class FileChanges implements IFileChanges { } } -function normalizeWatchedPaths(paths: string[] | undefined): Result { - if (!paths || paths.length === 0) return ok(['']); - return normalizeRelPaths(paths, { allowEmpty: true }); +function normalizeWatchedPaths( + rootPath: string, + paths: string[] | undefined +): Result { + if (!paths || paths.length === 0) return ok([]); + const normalized: string[] = []; + for (const input of paths) { + const validated = validateAbsolutePath(input); + if (!validated.success) return validated; + if (!contains(rootPath, validated.data)) { + return err({ + type: 'invalid-path', + path: input, + message: `Watched path must be inside root: ${input}`, + }); + } + normalized.push(validated.data); + } + return ok(normalized); } function rawEventsToChanges( @@ -103,29 +119,30 @@ function rawEventsToChanges( ): FileChange[] { const changes: FileChange[] = []; for (const event of events) { - const relPath = relativeFromRawEvent(rootPath, event); - if (!relPath) continue; - if (isIgnored(relPath)) continue; - if (!isWatchedPath(relPath, watchedPaths)) continue; + const absPath = absoluteFromRawEvent(rootPath, event); + if (!absPath) continue; + if (isIgnored(absPath)) continue; + if (!isWatchedPath(absPath, rootPath, watchedPaths)) continue; changes.push({ kind: event.kind, - path: relPath, + path: absPath, entryType: entryTypeForRawEvent(event), }); } return changes; } -function relativeFromRawEvent(rootPath: string, event: WatchEvent): string | null { +function absoluteFromRawEvent(rootPath: string, event: WatchEvent): string | null { const relPath = path.relative(rootPath, event.path).replace(/\\/g, '/'); if (!relPath || relPath === '..' || relPath.startsWith('../') || path.isAbsolute(relPath)) { return null; } - return relPath; + return path.normalize(event.path); } -function isWatchedPath(relPath: string, watchedPaths: string[]): boolean { - return watchedPaths.some((watchedPath) => isRelPathWithinScope(relPath, watchedPath)); +function isWatchedPath(absPath: string, rootPath: string, watchedPaths: string[]): boolean { + if (watchedPaths.length === 0) return contains(rootPath, absPath); + return watchedPaths.some((watchedPath) => contains(watchedPath, absPath)); } function entryTypeForRawEvent(event: WatchEvent): FileEntryType { diff --git a/packages/core/src/files/changes/types.ts b/packages/core/src/files/changes/types.ts index a8d57c49cd..2e8920869d 100644 --- a/packages/core/src/files/changes/types.ts +++ b/packages/core/src/files/changes/types.ts @@ -14,9 +14,9 @@ export type FileChangeUpdate = { kind: 'changes'; changes: FileChange[] } | { ki export type FileChangeWatchOptions = { /** - * Paths relative to the watched root. An empty path includes the whole root. + * Absolute paths to include under the watched root. Omitted paths include the whole root. * Implementations may apply this at the underlying watch layer or as a - * consumer-side filter; the emitted paths are always root-relative. + * consumer-side filter; emitted paths are absolute machine paths. */ paths?: string[]; debounceMs?: number; diff --git a/packages/core/src/files/enumerate.test.ts b/packages/core/src/files/enumerate.test.ts index 2227827a91..e65a29db99 100644 --- a/packages/core/src/files/enumerate.test.ts +++ b/packages/core/src/files/enumerate.test.ts @@ -14,7 +14,7 @@ async function makeRoot(): Promise { async function collect(iterable: AsyncIterable): Promise { const paths: string[] = []; - for await (const relPath of iterable) paths.push(relPath); + for await (const filePath of iterable) paths.push(filePath); return paths; } @@ -36,10 +36,10 @@ describe('enumerate', () => { await writeFile(path.join(root, '.git', 'HEAD'), 'ignored'); await expect(collect(enumerate(root))).resolves.toEqual([ - '.env', - 'README.md', - 'src/index.ts', - 'src/nested/deep.ts', + path.join(root, '.env'), + path.join(root, 'README.md'), + path.join(root, 'src/index.ts'), + path.join(root, 'src/nested/deep.ts'), ]); }); @@ -52,6 +52,6 @@ describe('enumerate', () => { // Some environments disallow symlink creation. } - await expect(collect(enumerate(root))).resolves.toEqual(['target.txt']); + await expect(collect(enumerate(root))).resolves.toEqual([path.join(root, 'target.txt')]); }); }); diff --git a/packages/core/src/files/enumerate.ts b/packages/core/src/files/enumerate.ts index b2afd5e428..28952db87a 100644 --- a/packages/core/src/files/enumerate.ts +++ b/packages/core/src/files/enumerate.ts @@ -1,18 +1,18 @@ import { readdir } from 'node:fs/promises'; +import path from 'node:path'; import { isIgnored } from './ignores'; -import { normalizeRelPath, resolveInsideRoot, type RelPath } from './paths'; +import { validateAbsolutePath } from './paths'; -export async function* enumerate(rootPath: string): AsyncIterable { - yield* enumerateDirectory(rootPath, ''); +export async function* enumerate(rootPath: string): AsyncIterable { + const validated = validateAbsolutePath(rootPath); + if (!validated.success) return; + yield* enumerateDirectory(validated.data); } -async function* enumerateDirectory(rootPath: string, dirPath: string): AsyncIterable { - const resolved = resolveInsideRoot(rootPath, dirPath, { allowEmpty: true }); - if (!resolved.success) return; - +async function* enumerateDirectory(dirPath: string): AsyncIterable { let entries; try { - entries = await readdir(resolved.data.absPath, { withFileTypes: true }); + entries = await readdir(dirPath, { withFileTypes: true }); } catch { return; } @@ -20,16 +20,15 @@ async function* enumerateDirectory(rootPath: string, dirPath: string): AsyncIter entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { - const relPath = resolved.data.relPath ? `${resolved.data.relPath}/${entry.name}` : entry.name; - const normalized = normalizeRelPath(relPath); - if (!normalized.success || isIgnored(normalized.data)) continue; + const absPath = path.join(dirPath, entry.name); + if (isIgnored(absPath)) continue; if (entry.isFile()) { - yield normalized.data; + yield absPath; continue; } if (entry.isDirectory()) { - yield* enumerateDirectory(rootPath, normalized.data); + yield* enumerateDirectory(absPath); } } } diff --git a/packages/core/src/files/errors.ts b/packages/core/src/files/errors.ts index a2ada09eb1..be278ff9a1 100644 --- a/packages/core/src/files/errors.ts +++ b/packages/core/src/files/errors.ts @@ -4,6 +4,10 @@ export type FileError = | { type: 'invalid-path'; path: string; message: string } | { type: 'fs-error'; path: string; message: string; code?: string }; +export const FILE_NOT_FOUND_ERROR_CODES = ['ENOENT', 'ENOTDIR', 'NOT_FOUND'] as const; + +export type FileNotFoundErrorCode = (typeof FILE_NOT_FOUND_ERROR_CODES)[number]; + export function classifyFileError(error: unknown, path: string): FileError { const code = (error as { code?: unknown } | undefined)?.code; return { @@ -13,3 +17,18 @@ export function classifyFileError(error: unknown, path: string): FileError { ...(typeof code === 'string' ? { code } : {}), }; } + +export function isFileNotFoundCode(code: unknown): code is FileNotFoundErrorCode { + return ( + typeof code === 'string' && (FILE_NOT_FOUND_ERROR_CODES as readonly string[]).includes(code) + ); +} + +export function isFileNotFoundException(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('code' in error)) return false; + return isFileNotFoundCode((error as { code?: unknown }).code); +} + +export function isFileNotFoundError(error: FileError): boolean { + return error.type === 'fs-error' && isFileNotFoundCode(error.code); +} diff --git a/packages/core/src/files/files-runtime.test.ts b/packages/core/src/files/files-runtime.test.ts index 459318126d..23ef008f96 100644 --- a/packages/core/src/files/files-runtime.test.ts +++ b/packages/core/src/files/files-runtime.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -38,12 +38,6 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); -async function collect(iterable: AsyncIterable): Promise { - const paths: string[] = []; - for await (const relPath of iterable) paths.push(relPath); - return paths; -} - describe('FilesRuntime', () => { it('wires file tree and change feeds through the same watch root and ignore set', async () => { const root = await makeRoot(); @@ -69,19 +63,18 @@ describe('FilesRuntime', () => { await runtime.dispose(); }); - it('enumerates files without acquiring a watch subscription', async () => { - const root = await makeRoot(); - await mkdir(path.join(root, 'src')); - await writeFile(path.join(root, 'src/index.ts'), 'content'); - await writeFile(path.join(root, '.env'), 'env'); + it('rejects relative roots for scoped services', async () => { const watcher = new RecordingWatchService(); const runtime = new FilesRuntime({ watcher }); - const enumeration = runtime.enumerate(root); - expect(enumeration.success).toBe(true); - if (!enumeration.success) return; - - await expect(collect(enumeration.data)).resolves.toEqual(['.env', 'src/index.ts']); + await expect(runtime.openTree('relative-root')).resolves.toMatchObject({ + success: false, + error: { type: 'invalid-path' }, + }); + expect(runtime.watchChanges('relative-root', () => {})).toMatchObject({ + success: false, + error: { type: 'invalid-path' }, + }); expect(watcher.watches).toEqual([]); await runtime.dispose(); @@ -93,11 +86,11 @@ describe('FilesRuntime', () => { const watcher = new RecordingWatchService(); const runtime = new FilesRuntime({ watcher }); - const fileSystem = runtime.fileSystem(root); + const fileSystem = runtime.fileSystem(); expect(fileSystem.success).toBe(true); if (!fileSystem.success) return; - await expect(fileSystem.data.readText('file.txt')).resolves.toMatchObject({ + await expect(fileSystem.data.readText(path.join(root, 'file.txt'))).resolves.toMatchObject({ success: true, data: { content: 'content', truncated: false, totalSize: 7 }, }); @@ -105,4 +98,31 @@ describe('FilesRuntime', () => { await runtime.dispose(); }); + + it('copies files across roots and creates the destination parent', async () => { + const sourceRoot = await makeRoot(); + const destRoot = await makeRoot(); + await mkdir(path.join(sourceRoot, 'config'), { recursive: true }); + await writeFile(path.join(sourceRoot, 'config', '.env'), 'SECRET=1', 'utf8'); + await chmod(path.join(sourceRoot, 'config', '.env'), 0o640); + const watcher = new RecordingWatchService(); + const runtime = new FilesRuntime({ watcher }); + + const fileSystem = runtime.fileSystem(); + expect(fileSystem.success).toBe(true); + if (!fileSystem.success) return; + + await expect( + fileSystem.data.copyFile( + path.join(sourceRoot, 'config/.env'), + path.join(destRoot, 'nested/.env') + ) + ).resolves.toEqual({ success: true, data: undefined }); + + await expect(readFile(path.join(destRoot, 'nested', '.env'), 'utf8')).resolves.toBe('SECRET=1'); + expect((await stat(path.join(destRoot, 'nested', '.env'))).mode & 0o777).toBe(0o640); + expect(watcher.watches).toEqual([]); + + await runtime.dispose(); + }); }); diff --git a/packages/core/src/files/files-runtime.ts b/packages/core/src/files/files-runtime.ts index 94e88b5a3e..4f281ccb9c 100644 --- a/packages/core/src/files/files-runtime.ts +++ b/packages/core/src/files/files-runtime.ts @@ -1,16 +1,14 @@ -import path from 'node:path'; import { err, ok, type Result } from '@emdash/shared'; import { ResourceMap } from '../lib'; import { WatchService, realpathOrResolve, type IWatchService } from '../watch'; import { FileChanges } from './changes/changes'; -import { enumerate as enumerateFiles } from './enumerate'; import type { FileError, FilesOnError } from './errors'; import { FileSystem } from './fs/file-system'; +import { validateAbsolutePath } from './paths'; import type { FileTreeError, FileTreeOnError } from './tree/errors'; import { FileTree } from './tree/file-tree'; import type { FileTreeLease } from './tree/types'; import type { - FileEnumeration, FileChangeSubscription, FileChangeUpdate, FileChangeWatchOptions, @@ -25,6 +23,7 @@ export type FilesRuntimeOptions = { export class FilesRuntime implements IFilesRuntime { private readonly trees: ResourceMap; + private readonly fs = new FileSystem(); private readonly watcher: IWatchService; private readonly ownsWatcher: boolean; private disposeRequested = false; @@ -49,7 +48,9 @@ export class FilesRuntime implements IFilesRuntime { message: 'FilesRuntime disposed', }); } - const resolvedRoot = realpathOrResolve(path.resolve(rootPath)); + const validatedRoot = validateAbsolutePath(rootPath); + if (!validatedRoot.success) return validatedRoot; + const resolvedRoot = realpathOrResolve(validatedRoot.data); const lease = await this.trees.acquire(resolvedRoot, async () => { return new FileTree({ rootPath: resolvedRoot, @@ -82,8 +83,10 @@ export class FilesRuntime implements IFilesRuntime { message: 'FilesRuntime disposed', }); } + const validatedRoot = validateAbsolutePath(rootPath); + if (!validatedRoot.success) return validatedRoot; const changes = new FileChanges({ - rootPath: realpathOrResolve(path.resolve(rootPath)), + rootPath: realpathOrResolve(validatedRoot.data), watcher: this.watcher, onError: this.options.onError, }); @@ -101,7 +104,7 @@ export class FilesRuntime implements IFilesRuntime { }); } - enumerate(rootPath: string): Result { + fileSystem(): Result { if (this.disposeRequested) { return err({ type: 'fs-error', @@ -109,18 +112,7 @@ export class FilesRuntime implements IFilesRuntime { message: 'FilesRuntime disposed', }); } - return ok(enumerateFiles(realpathOrResolve(path.resolve(rootPath)))); - } - - fileSystem(rootPath: string): Result { - if (this.disposeRequested) { - return err({ - type: 'fs-error', - path: '', - message: 'FilesRuntime disposed', - }); - } - return ok(new FileSystem(realpathOrResolve(path.resolve(rootPath)))); + return ok(this.fs); } async dispose(): Promise { diff --git a/packages/core/src/files/fs/file-system.test.ts b/packages/core/src/files/fs/file-system.test.ts index 412eb38081..db96272581 100644 --- a/packages/core/src/files/fs/file-system.test.ts +++ b/packages/core/src/files/fs/file-system.test.ts @@ -1,9 +1,15 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { FileSystem } from './file-system'; +async function collect(iterable: AsyncIterable): Promise { + const items: string[] = []; + for await (const item of iterable) items.push(item); + return items; +} + const roots: string[] = []; async function makeRoot(): Promise { @@ -19,15 +25,16 @@ afterEach(async () => { describe('FileSystem', () => { it('reads text and bytes with truncation metadata', async () => { const root = await makeRoot(); - await writeFile(path.join(root, 'file.txt'), 'hello world', 'utf8'); - const fs = new FileSystem(root); + const filePath = path.join(root, 'file.txt'); + await writeFile(filePath, 'hello world', 'utf8'); + const fs = new FileSystem(); - const text = await fs.readText('file.txt', { maxBytes: 5 }); + const text = await fs.readText(filePath, { maxBytes: 5 }); expect(text.success).toBe(true); if (!text.success) return; expect(text.data).toEqual({ content: 'hello', truncated: true, totalSize: 11 }); - const bytes = await fs.readBytes('file.txt', { maxBytes: 20 }); + const bytes = await fs.readBytes(filePath, { maxBytes: 20 }); expect(bytes.success).toBe(true); if (!bytes.success) return; expect(Buffer.from(bytes.data.bytes).toString('utf8')).toBe('hello world'); @@ -36,23 +43,19 @@ describe('FileSystem', () => { it('writes files inside the root and creates parent directories', async () => { const root = await makeRoot(); - const fs = new FileSystem(root); + const fs = new FileSystem(); + const filePath = path.join(root, 'src/index.ts'); - const written = await fs.writeText('src/index.ts', 'export {};'); + const written = await fs.writeText(filePath, 'export {};'); expect(written.success).toBe(true); if (!written.success) return; expect(written.data.bytesWritten).toBe(Buffer.byteLength('export {};')); await expect(readFile(path.join(root, 'src/index.ts'), 'utf8')).resolves.toBe('export {};'); }); - it('rejects absolute paths and parent traversal', async () => { - const root = await makeRoot(); - const fs = new FileSystem(root); + it('rejects relative paths', async () => { + const fs = new FileSystem(); - await expect(fs.readText('/tmp/file.txt')).resolves.toMatchObject({ - success: false, - error: { type: 'invalid-path' }, - }); await expect(fs.writeText('../file.txt', 'x')).resolves.toMatchObject({ success: false, error: { type: 'invalid-path' }, @@ -61,28 +64,29 @@ describe('FileSystem', () => { it('stats, checks existence, copies, and removes files', async () => { const root = await makeRoot(); - await mkdir(path.join(root, 'src')); - await writeFile(path.join(root, 'src/a.txt'), 'a', 'utf8'); - const fs = new FileSystem(root); + const srcPath = path.join(root, 'src/a.txt'); + const destPath = path.join(root, 'dest/b.txt'); + await mkdir(path.dirname(srcPath)); + await writeFile(srcPath, 'a', 'utf8'); + const fs = new FileSystem(); - const stat = await fs.stat('src/a.txt'); + const stat = await fs.stat(srcPath); expect(stat.success).toBe(true); if (!stat.success) return; - expect(stat.data).toMatchObject({ path: 'src/a.txt', type: 'file', size: 1 }); + expect(stat.data).toMatchObject({ path: srcPath, type: 'file', size: 1 }); - await expect(fs.exists('src/a.txt')).resolves.toEqual({ success: true, data: true }); - await expect(fs.copyFile('src/a.txt', 'dest/b.txt')).resolves.toEqual({ + await expect(fs.exists(srcPath)).resolves.toEqual({ success: true, data: true }); + await expect(fs.copyFile(srcPath, destPath)).resolves.toEqual({ success: true, data: undefined, }); - await expect(readFile(path.join(root, 'dest/b.txt'), 'utf8')).resolves.toBe('a'); - await expect(fs.remove('src/a.txt')).resolves.toEqual({ success: true, data: undefined }); - await expect(fs.exists('src/a.txt')).resolves.toEqual({ success: true, data: false }); + await expect(readFile(destPath, 'utf8')).resolves.toBe('a'); + await expect(fs.remove(srcPath)).resolves.toEqual({ success: true, data: undefined }); + await expect(fs.exists(srcPath)).resolves.toEqual({ success: true, data: false }); }); - it('does not remove the root through an empty path', async () => { - const root = await makeRoot(); - const fs = new FileSystem(root); + it('rejects empty paths', async () => { + const fs = new FileSystem(); await expect(fs.remove('')).resolves.toMatchObject({ success: false, @@ -93,4 +97,56 @@ describe('FileSystem', () => { error: { type: 'invalid-path' }, }); }); + + it('streams ignore-free glob matches including dotfiles', async () => { + const root = await makeRoot(); + await mkdir(path.join(root, '.cursor', 'rules'), { recursive: true }); + await mkdir(path.join(root, '.claude'), { recursive: true }); + await writeFile(path.join(root, '.cursor', 'rules', 'style.md'), 'rules', 'utf8'); + await writeFile(path.join(root, '.claude.json'), '{}', 'utf8'); + await writeFile(path.join(root, '.claude', 'settings.json'), '{}', 'utf8'); + const fs = new FileSystem(); + + const matched = fs.glob(['.cursor/**', '.claude.json', '.claude/**'], { cwd: root, dot: true }); + expect(matched.success).toBe(true); + if (!matched.success) return; + + const paths: string[] = []; + for await (const absPath of matched.data) paths.push(path.relative(root, absPath)); + expect(paths.sort()).toEqual([ + '.claude', + '.claude.json', + '.claude/settings.json', + '.cursor', + '.cursor/rules', + '.cursor/rules/style.md', + ]); + }); + + it('enumerates files recursively', async () => { + const root = await makeRoot(); + await mkdir(path.join(root, 'src')); + await writeFile(path.join(root, 'src/index.ts'), 'content', 'utf8'); + await writeFile(path.join(root, '.env'), 'env', 'utf8'); + const fs = new FileSystem(); + + const enumeration = fs.enumerate(root); + expect(enumeration.success).toBe(true); + if (!enumeration.success) return; + + const canonicalRoot = await realpath(root); + await expect(collect(enumeration.data)).resolves.toEqual([ + path.join(canonicalRoot, '.env'), + path.join(canonicalRoot, 'src/index.ts'), + ]); + }); + + it('rejects relative roots for enumerate', () => { + const fs = new FileSystem(); + + expect(fs.enumerate('relative-root')).toMatchObject({ + success: false, + error: { type: 'invalid-path' }, + }); + }); }); diff --git a/packages/core/src/files/fs/file-system.ts b/packages/core/src/files/fs/file-system.ts index 34436cc139..956aabb403 100644 --- a/packages/core/src/files/fs/file-system.ts +++ b/packages/core/src/files/fs/file-system.ts @@ -1,9 +1,15 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { err, ok, type Result } from '@emdash/shared'; -import { classifyFileError, type FileError } from '../errors'; -import { resolveInsideRoot } from '../paths'; +import { globIterate } from 'glob'; +import { realpathOrResolve } from '../../watch'; +import { enumerate as enumerateFiles } from '../enumerate'; +import { classifyFileError, isFileNotFoundCode, type FileError } from '../errors'; +import { validateAbsolutePath } from '../paths'; import type { + FileEnumeration, + FileGlob, + FileGlobOptions, FileStat, IFileSystem, ReadBytesResult, @@ -16,13 +22,11 @@ const DEFAULT_MAX_BYTES = 200 * 1024; const MAX_READ_BYTES = 100 * 1024 * 1024; export class FileSystem implements IFileSystem { - constructor(private readonly rootPath: string) {} - async readText( - relPath: string, + absPath: string, options?: ReadFileOptions ): Promise> { - const result = await this.readBytes(relPath, options); + const result = await this.readBytes(absPath, options); if (!result.success) return result; return ok({ content: Buffer.from(result.data.bytes).toString('utf8'), @@ -32,19 +36,19 @@ export class FileSystem implements IFileSystem { } async readBytes( - relPath: string, + absPath: string, options: ReadFileOptions = {} ): Promise> { - const resolved = this.resolve(relPath); - if (!resolved.success) return resolved; + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; try { - const stat = await fs.stat(resolved.data.absPath); + const stat = await fs.stat(validated.data); if (stat.isDirectory()) { return err({ type: 'fs-error', - path: relPath, - message: `Path is a directory: ${relPath}`, + path: validated.data, + message: `Path is a directory: ${validated.data}`, code: 'EISDIR', }); } @@ -59,7 +63,7 @@ export class FileSystem implements IFileSystem { }); } - const handle = await fs.open(resolved.data.absPath, 'r'); + const handle = await fs.open(validated.data, 'r'); try { const buffer = Buffer.alloc(readSize); const { bytesRead } = await handle.read(buffer, 0, readSize, 0); @@ -72,29 +76,29 @@ export class FileSystem implements IFileSystem { await handle.close(); } } catch (error) { - return err(classifyFileError(error, relPath)); + return err(classifyFileError(error, validated.data)); } } - async writeText(relPath: string, content: string): Promise> { - return this.writeBuffer(relPath, Buffer.from(content, 'utf8')); + async writeText(absPath: string, content: string): Promise> { + return this.writeBuffer(absPath, Buffer.from(content, 'utf8')); } async writeBytes( - relPath: string, + absPath: string, bytes: Uint8Array ): Promise> { - return this.writeBuffer(relPath, Buffer.from(bytes)); + return this.writeBuffer(absPath, Buffer.from(bytes)); } - async stat(relPath: string): Promise> { - const resolved = this.resolve(relPath); - if (!resolved.success) return resolved; + async stat(absPath: string): Promise> { + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; try { - const stat = await fs.stat(resolved.data.absPath); + const stat = await fs.stat(validated.data); return ok({ - path: resolved.data.relPath, + path: validated.data, type: stat.isDirectory() ? 'directory' : 'file', size: stat.size, mtime: stat.mtime, @@ -102,108 +106,120 @@ export class FileSystem implements IFileSystem { mode: stat.mode, }); } catch (error) { - return err(classifyFileError(error, relPath)); + return err(classifyFileError(error, validated.data)); } } - async exists(relPath: string): Promise> { - const resolved = this.resolve(relPath); - if (!resolved.success) return resolved; + async exists(absPath: string): Promise> { + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; try { - await fs.access(resolved.data.absPath); + await fs.access(validated.data); return ok(true); } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') return ok(false); - return err(classifyFileError(error, relPath)); + if (isFileNotFoundCode((error as NodeJS.ErrnoException).code)) return ok(false); + return err(classifyFileError(error, validated.data)); } } async mkdir( - relPath: string, + absPath: string, options: { recursive?: boolean } = {} ): Promise> { - const resolved = this.resolve(relPath, { allowEmpty: true }); - if (!resolved.success) return resolved; - if (resolved.data.relPath === '') return ok(); + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; try { - await fs.mkdir(resolved.data.absPath, { recursive: options.recursive ?? false }); + await fs.mkdir(validated.data, { recursive: options.recursive ?? false }); return ok(); } catch (error) { - return err(classifyFileError(error, relPath)); + return err(classifyFileError(error, validated.data)); } } async remove( - relPath: string, + absPath: string, options: { recursive?: boolean } = {} ): Promise> { - const resolved = this.resolve(relPath); - if (!resolved.success) return resolved; + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; try { - const stat = await fs.stat(resolved.data.absPath); + const stat = await fs.stat(validated.data); if (stat.isDirectory()) { if (!options.recursive) { return err({ type: 'fs-error', - path: relPath, - message: `Path is a directory: ${relPath}`, + path: validated.data, + message: `Path is a directory: ${validated.data}`, code: 'EISDIR', }); } - await fs.rm(resolved.data.absPath, { recursive: true, force: true }); + await fs.rm(validated.data, { recursive: true, force: true }); return ok(); } - await this.unlinkFile(resolved.data.absPath); + await this.unlinkFile(validated.data); return ok(); } catch (error) { - return err(classifyFileError(error, relPath)); + return err(classifyFileError(error, validated.data)); } } - async realPath(relPath: string): Promise> { - const resolved = this.resolve(relPath, { allowEmpty: true }); - if (!resolved.success) return resolved; + async realPath(absPath: string): Promise> { + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; try { - return ok(await fs.realpath(resolved.data.absPath)); + return ok(await fs.realpath(validated.data)); } catch (error) { - return err(classifyFileError(error, relPath)); + return err(classifyFileError(error, validated.data)); } } async copyFile(src: string, dest: string): Promise> { - const resolvedSrc = this.resolve(src); - if (!resolvedSrc.success) return resolvedSrc; - const resolvedDest = this.resolve(dest); - if (!resolvedDest.success) return resolvedDest; + const validatedSrc = validateAbsolutePath(src); + if (!validatedSrc.success) return validatedSrc; + const validatedDest = validateAbsolutePath(dest); + if (!validatedDest.success) return validatedDest; try { - await fs.mkdir(path.dirname(resolvedDest.data.absPath), { recursive: true }); - await fs.copyFile(resolvedSrc.data.absPath, resolvedDest.data.absPath); + await fs.mkdir(path.dirname(validatedDest.data), { recursive: true }); + await fs.copyFile(validatedSrc.data, validatedDest.data); + const sourceStat = await fs.stat(validatedSrc.data); + await fs.chmod(validatedDest.data, sourceStat.mode); return ok(); } catch (error) { - return err(classifyFileError(error, dest)); + return err(classifyFileError(error, validatedDest.data)); } } + glob(patterns: string[], options: FileGlobOptions): Result { + const validated = validateGlobArgs(patterns, options); + if (!validated.success) return validated; + return ok(this.globPaths(validated.data.patterns, validated.data.cwd, options)); + } + + enumerate(absPath: string): Result { + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; + return ok(enumerateFiles(realpathOrResolve(validated.data))); + } + private async writeBuffer( - relPath: string, + absPath: string, buffer: Buffer ): Promise> { - const resolved = this.resolve(relPath); - if (!resolved.success) return resolved; + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; try { - await fs.mkdir(path.dirname(resolved.data.absPath), { recursive: true }); - await fs.writeFile(resolved.data.absPath, buffer); + await fs.mkdir(path.dirname(validated.data), { recursive: true }); + await fs.writeFile(validated.data, buffer); return ok({ bytesWritten: buffer.byteLength }); } catch (error) { - return err(classifyFileError(error, relPath)); + return err(classifyFileError(error, validated.data)); } } @@ -219,8 +235,16 @@ export class FileSystem implements IFileSystem { } } - private resolve(relPath: string, options: { allowEmpty?: boolean } = {}) { - return resolveInsideRoot(this.rootPath, relPath, options); + private async *globPaths(patterns: string[], cwd: string, options: FileGlobOptions): FileGlob { + for await (const match of globIterate(patterns, { + absolute: false, + cwd, + dot: options.dot ?? false, + follow: false, + })) { + if (typeof match !== 'string') continue; + yield path.resolve(cwd, match); + } } } @@ -229,3 +253,35 @@ function normalizeMaxBytes(maxBytes: number | undefined): number { if (!Number.isFinite(maxBytes) || maxBytes < 0) return 0; return Math.min(Math.floor(maxBytes), MAX_READ_BYTES); } + +function validateGlobArgs( + patterns: string[], + options: FileGlobOptions +): Result<{ patterns: string[]; cwd: string }, FileError> { + if (patterns.length === 0) { + return err({ + type: 'invalid-path', + path: '', + message: 'At least one glob pattern is required', + }); + } + + const cwd = validateAbsolutePath(options.cwd); + if (!cwd.success) return cwd; + + const normalizedPatterns: string[] = []; + for (const pattern of patterns) { + if (!pattern) { + return err({ + type: 'invalid-path', + path: pattern, + message: 'Glob pattern must not be empty', + }); + } + if (pattern.includes('\0')) { + return err({ type: 'invalid-path', path: pattern, message: 'Path contains a null byte' }); + } + normalizedPatterns.push(pattern.replace(/\\/g, '/')); + } + return ok({ patterns: normalizedPatterns, cwd: cwd.data }); +} diff --git a/packages/core/src/files/fs/index.ts b/packages/core/src/files/fs/index.ts index 4200133a80..2b8233f6b7 100644 --- a/packages/core/src/files/fs/index.ts +++ b/packages/core/src/files/fs/index.ts @@ -1,5 +1,8 @@ export { FileSystem } from './file-system'; export type { + FileEnumeration, + FileGlob, + FileGlobOptions, FileStat, IFileSystem, ReadBytesResult, diff --git a/packages/core/src/files/fs/types.ts b/packages/core/src/files/fs/types.ts index c1e11bd34f..38aa9c29fa 100644 --- a/packages/core/src/files/fs/types.ts +++ b/packages/core/src/files/fs/types.ts @@ -30,6 +30,15 @@ export type WriteFileResult = { bytesWritten: number; }; +export type FileGlobOptions = { + cwd: string; + dot?: boolean; +}; + +export type FileGlob = AsyncIterable; + +export type FileEnumeration = AsyncIterable; + export interface IFileSystem { readText(path: string, options?: ReadFileOptions): Promise>; readBytes(path: string, options?: ReadFileOptions): Promise>; @@ -41,4 +50,6 @@ export interface IFileSystem { remove(path: string, options?: { recursive?: boolean }): Promise>; realPath(path: string): Promise>; copyFile(src: string, dest: string): Promise>; + glob(patterns: string[], options: FileGlobOptions): Result; + enumerate(path: string): Result; } diff --git a/packages/core/src/files/ignores.ts b/packages/core/src/files/ignores.ts index d964fa4cdd..ad65cbfde9 100644 --- a/packages/core/src/files/ignores.ts +++ b/packages/core/src/files/ignores.ts @@ -45,9 +45,12 @@ export const IGNORED_PATH_SEGMENTS = [ const IGNORED_PATH_SEGMENT_SET = new Set(IGNORED_PATH_SEGMENTS); -export function isIgnored(relPath: string): boolean { - if (!relPath) return false; - return relPath.split('/').some((segment) => IGNORED_PATH_SEGMENT_SET.has(segment)); +export function isIgnored(filePath: string): boolean { + if (!filePath) return false; + return filePath + .replace(/\\/g, '/') + .split('/') + .some((segment) => IGNORED_PATH_SEGMENT_SET.has(segment)); } export function watchIgnoreGlobs(): string[] { diff --git a/packages/core/src/files/index.test.ts b/packages/core/src/files/index.test.ts index 91ea218570..356a88ed84 100644 --- a/packages/core/src/files/index.test.ts +++ b/packages/core/src/files/index.test.ts @@ -6,10 +6,8 @@ describe('@emdash/core/files public exports', () => { const exported = files as Record; expect(exported.FilesRuntime).toBeTypeOf('function'); - expect(exported.enumerate).toBeTypeOf('function'); expect(exported.isIgnored).toBeTypeOf('function'); expect(exported.watchIgnoreGlobs).toBeTypeOf('function'); - expect(exported.normalizeRelPath).toBeTypeOf('function'); - expect(exported.resolveInsideRoot).toBeTypeOf('function'); + expect(exported.validateAbsolutePath).toBeTypeOf('function'); }); }); diff --git a/packages/core/src/files/index.ts b/packages/core/src/files/index.ts index 4da4f7b16d..5da79744e5 100644 --- a/packages/core/src/files/index.ts +++ b/packages/core/src/files/index.ts @@ -1,17 +1,16 @@ -export { enumerate } from './enumerate'; export { FilesRuntime, type FilesRuntimeOptions } from './files-runtime'; -export { classifyFileError, type FileError, type FilesOnError } from './errors'; +export { + FILE_NOT_FOUND_ERROR_CODES, + classifyFileError, + isFileNotFoundCode, + isFileNotFoundError, + isFileNotFoundException, + type FileError, + type FileNotFoundErrorCode, + type FilesOnError, +} from './errors'; export { FileSystem } from './fs'; export { IGNORED_PATH_SEGMENTS, isIgnored, watchIgnoreGlobs } from './ignores'; -export { - basenameFromRelPath, - isRelPathWithinScope, - normalizeRelPath, - normalizeRelPaths, - parentRelPath, - resolveInsideRoot, - type RelPath, - type ResolvedPath, -} from './paths'; +export { validateAbsolutePath, contains, type AbsPath } from './paths'; export { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './tree/errors'; export type * from './types'; diff --git a/packages/core/src/files/paths.ts b/packages/core/src/files/paths.ts index a5f0d17c78..a8a639d3bd 100644 --- a/packages/core/src/files/paths.ts +++ b/packages/core/src/files/paths.ts @@ -2,84 +2,19 @@ import path from 'node:path'; import { err, ok, type Result } from '@emdash/shared'; import type { FileError } from './errors'; -export type RelPath = string & { readonly __relPath: unique symbol }; +export type AbsPath = string; -export type ResolvedPath = { - relPath: RelPath; - absPath: string; -}; - -export function normalizeRelPath( - input: string, - options: { allowEmpty?: boolean } = {} -): Result { +export function validateAbsolutePath(input: string): Result { if (input.includes('\0')) { return err({ type: 'invalid-path', path: input, message: 'Path contains a null byte' }); } - if (path.isAbsolute(input) || path.win32.isAbsolute(input)) { - return err({ type: 'invalid-path', path: input, message: 'Absolute paths are not allowed' }); - } - - const parts = input - .replace(/\\/g, '/') - .split('/') - .filter((part) => part.length > 0 && part !== '.'); - if (parts.includes('..')) { - return err({ - type: 'invalid-path', - path: input, - message: 'Parent path segments are not allowed', - }); - } - - const normalized = parts.join('/'); - if (!normalized && !options.allowEmpty) { - return err({ type: 'invalid-path', path: input, message: 'Path must not be empty' }); + if (!path.isAbsolute(input) && !path.win32.isAbsolute(input)) { + return err({ type: 'invalid-path', path: input, message: 'Path must be absolute' }); } - return ok(normalized as RelPath); -} - -export function normalizeRelPaths( - inputs: readonly string[], - options: { allowEmpty?: boolean } = {} -): Result { - const normalized = new Set(); - for (const input of inputs) { - const result = normalizeRelPath(input, options); - if (!result.success) return result; - normalized.add(result.data); - } - return ok([...normalized]); -} - -export function resolveInsideRoot( - rootPath: string, - input: string, - options: { allowEmpty?: boolean } = {} -): Result { - const normalized = normalizeRelPath(input, options); - if (!normalized.success) return normalized; - - const root = path.resolve(rootPath); - const absPath = path.resolve(root, normalized.data); - const relativeToRoot = path.relative(root, absPath); - if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { - return err({ type: 'invalid-path', path: input, message: 'Path escapes the root' }); - } - - return ok({ relPath: normalized.data, absPath }); -} - -export function parentRelPath(relPath: string): string { - const index = relPath.lastIndexOf('/'); - return index === -1 ? '' : relPath.slice(0, index); -} - -export function basenameFromRelPath(relPath: string): string { - const index = relPath.lastIndexOf('/'); - return index === -1 ? relPath : relPath.slice(index + 1); + return ok(path.normalize(input)); } -export function isRelPathWithinScope(relPath: string, scopePath: string): boolean { - return scopePath === '' || relPath === scopePath || relPath.startsWith(`${scopePath}/`); +export function contains(parent: string, child: string): boolean { + const rel = path.relative(parent, child); + return rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.isAbsolute(rel)); } diff --git a/packages/core/src/files/tree/file-tree.test.ts b/packages/core/src/files/tree/file-tree.test.ts index 437b21f788..daa837fbbe 100644 --- a/packages/core/src/files/tree/file-tree.test.ts +++ b/packages/core/src/files/tree/file-tree.test.ts @@ -5,7 +5,6 @@ import type { Result } from '@emdash/shared'; import { afterEach, describe, expect, it } from 'vitest'; import type { IWatchService, WatchEvent, WatchHandle } from '../../watch'; import { FilesRuntime } from '../files-runtime'; -import { resolveInsideRoot } from '../paths'; import { FileTree } from './file-tree'; import type { FileNode } from './models/tree'; @@ -138,7 +137,7 @@ describe('FileTree', () => { const tree = new FileTree({ rootPath: root, watcher: new ManualWatchService() }); unwrap(await tree.ready()); - await expect(tree.revealPath('src/a/file.ts')).resolves.toMatchObject({ + await expect(tree.revealPath(path.join(root, 'src/a/file.ts'))).resolves.toMatchObject({ success: true, data: { tree: expect.any(Number) }, }); @@ -154,9 +153,9 @@ describe('FileTree', () => { const tree = new FileTree({ rootPath: root, watcher: new ManualWatchService() }); unwrap(await tree.ready()); - await expect(tree.revealPath('src/missing/file.ts')).resolves.toMatchObject({ + await expect(tree.revealPath(path.join(root, 'src/missing/file.ts'))).resolves.toMatchObject({ success: false, - error: { type: 'not-found', path: 'src/missing' }, + error: { type: 'not-found', path: path.join(root, 'src/missing') }, }); await tree.dispose(); }); @@ -185,12 +184,6 @@ describe('FileTree', () => { } }); - it('rejects unsafe paths before filesystem access', async () => { - const root = await makeRoot(); - expect(resolveInsideRoot(root, '../outside').success).toBe(false); - expect(resolveInsideRoot(root, path.join(root, 'absolute')).success).toBe(false); - }); - it('reuses a node id for a delete/create rename batch with matching inode', async () => { const root = await makeRoot(); await writeFile(path.join(root, 'a.txt'), 'x', 'utf8'); @@ -219,7 +212,7 @@ describe('FileTree', () => { const watcher = new ManualWatchService(); const tree = new FileTree({ rootPath: root, watcher }); unwrap(await tree.ready()); - unwrap(await tree.revealPath('src/nested/a.ts')); + unwrap(await tree.revealPath(path.join(root, 'src/nested/a.ts'))); const before = await nodes(tree); const src = nodeByPath(before, 'src'); const nested = nodeByPath(before, 'src/nested'); @@ -247,7 +240,7 @@ describe('FileTree', () => { const watcher = new ManualWatchService(); const tree = new FileTree({ rootPath: root, watcher }); unwrap(await tree.ready()); - await tree.revealPath('src/nested/a.ts'); + await tree.revealPath(path.join(root, 'src/nested/a.ts')); expect(paths(await nodes(tree))).toEqual(['src', 'src/nested', 'src/nested/a.ts']); await rm(path.join(root, 'src'), { recursive: true }); @@ -264,7 +257,7 @@ describe('FileTree', () => { await writeFile(path.join(root, 'src', 'nested', 'a.ts'), 'x', 'utf8'); const tree = new FileTree({ rootPath: root, watcher: new ManualWatchService() }); unwrap(await tree.ready()); - unwrap(await tree.revealPath('src/nested/a.ts')); + unwrap(await tree.revealPath(path.join(root, 'src/nested/a.ts'))); expect(paths(await nodes(tree))).toEqual(['src', 'src/nested', 'src/nested/a.ts']); await rm(path.join(root, 'src'), { recursive: true }); @@ -371,7 +364,7 @@ describe('FileTree', () => { await expect(runtime.openTree(root)).resolves.toMatchObject({ success: false, - error: { type: 'not-found', path: '' }, + error: { type: 'not-found', path: root }, }); await runtime.dispose(); }); @@ -400,17 +393,26 @@ async function nodes(tree: FileTree): Promise { } function paths(nodes: FileNode[]): string[] { - return nodes.map((node) => node.path); + const root = commonRoot(nodes.map((node) => node.path)); + return nodes.map((node) => path.relative(root, node.path).replace(/\\/g, '/')); } -function nodeByPath(nodes: FileNode[], path: string): FileNode { - const node = nodes.find((candidate) => candidate.path === path); - if (!node) throw new Error(`Missing node ${path}`); +function nodeByPath(nodes: FileNode[], expectedPath: string): FileNode { + const root = commonRoot(nodes.map((node) => node.path)); + const node = nodes.find( + (candidate) => + candidate.path === expectedPath || + path.relative(root, candidate.path).replace(/\\/g, '/') === expectedPath + ); + if (!node) throw new Error(`Missing node ${expectedPath}`); return node; } -async function waitForNode(tree: FileTree, path: string): Promise { - await waitFor(async () => (await nodes(tree)).some((node) => node.path === path)); +async function waitForNode(tree: FileTree, expectedPath: string): Promise { + await waitFor(async () => { + nodeByPath(await nodes(tree), expectedPath); + return true; + }); } async function waitForPaths(tree: FileTree, expected: string[]): Promise { @@ -420,6 +422,24 @@ async function waitForPaths(tree: FileTree, expected: string[]): Promise { }); } +function commonRoot(paths: string[]): string { + if (paths.length === 0) return ''; + let root = path.dirname(paths[0]); + for (const current of paths.slice(1)) { + while (root && !isSameOrChild(root, current)) { + const next = path.dirname(root); + if (next === root) break; + root = next; + } + } + return root; +} + +function isSameOrChild(parent: string, child: string): boolean { + const rel = path.relative(parent, child); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + async function waitFor(check: () => Promise, timeoutMs = 500): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; diff --git a/packages/core/src/files/tree/file-tree.ts b/packages/core/src/files/tree/file-tree.ts index 80122467ff..740a6dcb4a 100644 --- a/packages/core/src/files/tree/file-tree.ts +++ b/packages/core/src/files/tree/file-tree.ts @@ -3,7 +3,7 @@ import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; import { KeyedMutex, LiveCollection, type KeyedOp } from '../../lib'; import type { IWatchService, WatchEvent, WatchHandle } from '../../watch'; import { isIgnored, watchIgnoreGlobs } from '../ignores'; -import { resolveInsideRoot } from '../paths'; +import { contains, validateAbsolutePath } from '../paths'; import { classifyFileTreeFsError, type FileTreeError, type FileTreeOnError } from './errors'; import { listChildren } from './list'; import type { FileNode, NodeId } from './models/tree'; @@ -154,15 +154,23 @@ export class FileTree implements IFileTree { const ready = await this.ready(); if (!ready.success) return err(ready.error); return this.runMutation(async () => { - const normalized = resolveInsideRoot(this.rootPath, pathToReveal); - if (!normalized.success) return normalized; + const validated = validateAbsolutePath(pathToReveal); + if (!validated.success) return validated; + if (!contains(this.rootPath, validated.data)) { + return err({ + type: 'invalid-path', + path: pathToReveal, + message: 'Path is outside tree root', + }); + } - const parts = normalized.data.relPath.split('/').filter(Boolean); + const relativePath = path.relative(this.rootPath, validated.data); + const parts = relativePath.split(path.sep).filter(Boolean); let sequences: FileTreeSequences = {}; for (let index = 0; index < parts.length; index += 1) { - const relPath = parts.slice(0, index + 1).join('/'); - const node = this.ids.getByPath(relPath); - if (!node) return err({ type: 'not-found', path: relPath }); + const absPath = path.join(this.rootPath, ...parts.slice(0, index + 1)); + const node = this.ids.getByPath(absPath); + if (!node) return err({ type: 'not-found', path: absPath }); const shouldExpand = index < parts.length - 1 || node.type === 'directory'; if (!shouldExpand) continue; if (node.type !== 'directory') { @@ -216,7 +224,7 @@ export class FileTree implements IFileTree { return err({ type: 'not-directory', id: dirNode.id, path: dirNode.path }); } - const dirPath = dirNode?.path ?? ''; + const dirPath = dirNode?.path ?? this.rootPath; const listed = await listChildren(this.rootPath, dirPath); if (!listed.success) return listed; diff --git a/packages/core/src/files/tree/list.ts b/packages/core/src/files/tree/list.ts index d2530c125e..53146e5b1f 100644 --- a/packages/core/src/files/tree/list.ts +++ b/packages/core/src/files/tree/list.ts @@ -1,7 +1,8 @@ import { lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; import { err, ok, type Result } from '@emdash/shared'; import { isIgnored } from '../ignores'; -import { basenameFromRelPath, resolveInsideRoot } from '../paths'; +import { contains, validateAbsolutePath } from '../paths'; import { classifyFileTreeFsError, type FileTreeError } from './errors'; import type { FileNodeType } from './models/tree'; @@ -18,36 +19,33 @@ export async function listChildren( rootPath: string, dirPath: string ): Promise> { - const resolved = resolveInsideRoot(rootPath, dirPath, { allowEmpty: true }); - if (!resolved.success) return resolved; + const resolvedRoot = validateAbsolutePath(rootPath); + if (!resolvedRoot.success) return resolvedRoot; + const resolvedDir = resolveTreePath(resolvedRoot.data, dirPath); + if (!resolvedDir.success) return resolvedDir; let entries; try { - entries = await readdir(resolved.data.absPath, { withFileTypes: true }); + entries = await readdir(resolvedDir.data, { withFileTypes: true }); } catch (error) { - return err(classifyFileTreeFsError(error, resolved.data.relPath)); + return err(classifyFileTreeFsError(error, resolvedDir.data)); } - const candidates: Array & { absPath: string }> = []; + const candidates: Array> = []; for (const entry of entries) { if (!entry.isFile() && !entry.isDirectory()) continue; - const relPath = resolved.data.relPath ? `${resolved.data.relPath}/${entry.name}` : entry.name; - if (isIgnored(relPath)) continue; - const childResolved = resolveInsideRoot(rootPath, relPath); - if (!childResolved.success) return childResolved; + const absPath = path.join(resolvedDir.data, entry.name); + if (isIgnored(absPath)) continue; candidates.push({ - path: relPath, - name: basenameFromRelPath(relPath), + path: absPath, + name: path.basename(absPath), type: entry.isDirectory() ? 'directory' : 'file', - absPath: childResolved.data.absPath, }); } - const devInos = await Promise.all(candidates.map((entry) => statDevIno(entry.absPath))); + const devInos = await Promise.all(candidates.map((entry) => statDevIno(entry.path))); const listed: ListedEntry[] = candidates.map((entry, index) => ({ - path: entry.path, - name: entry.name, - type: entry.type, + ...entry, devIno: devInos[index], })); @@ -60,24 +58,37 @@ export async function listChildren( export async function statEntry( rootPath: string, - relPath: string + absPath: string ): Promise> { - const resolved = resolveInsideRoot(rootPath, relPath); - if (!resolved.success) return resolved; + const resolvedRoot = validateAbsolutePath(rootPath); + if (!resolvedRoot.success) return resolvedRoot; + const resolvedPath = resolveTreePath(resolvedRoot.data, absPath); + if (!resolvedPath.success) return resolvedPath; + try { - const stats = await lstat(resolved.data.absPath); + const stats = await lstat(resolvedPath.data); if (!stats.isFile() && !stats.isDirectory()) { - return err({ type: 'not-found', path: relPath }); + return err({ type: 'not-found', path: resolvedPath.data }); } return ok({ - path: resolved.data.relPath, - name: basenameFromRelPath(resolved.data.relPath), + path: resolvedPath.data, + name: path.basename(resolvedPath.data), type: stats.isDirectory() ? 'directory' : 'file', devIno: toDevIno(stats.dev, stats.ino), }); } catch (error) { - return err(classifyFileTreeFsError(error, relPath)); + return err(classifyFileTreeFsError(error, resolvedPath.data)); + } +} + +function resolveTreePath(rootPath: string, inputPath: string): Result { + const absPath = inputPath ? inputPath : rootPath; + const validated = validateAbsolutePath(absPath); + if (!validated.success) return validated; + if (!contains(rootPath, validated.data)) { + return err({ type: 'invalid-path', path: inputPath, message: 'Path is outside tree root' }); } + return ok(validated.data); } async function statDevIno(absPath: string): Promise { diff --git a/packages/core/src/files/tree/watch/classifier.test.ts b/packages/core/src/files/tree/watch/classifier.test.ts index 199fd37099..4f2f8ba0c5 100644 --- a/packages/core/src/files/tree/watch/classifier.test.ts +++ b/packages/core/src/files/tree/watch/classifier.test.ts @@ -18,7 +18,7 @@ describe('classifyFileTreeWatchEvents', () => { it('ignores content update events', async () => { const root = await makeRoot(); const ids = new NodeIdAssigner(); - ids.upsert(entry('a.txt', 'file', '1:1'), null); + ids.upsert(entry(root, 'a.txt', 'file', '1:1'), null); const classification = await classify(root, ids, [ { kind: 'update', path: absPath(root, 'a.txt') }, @@ -38,9 +38,13 @@ describe('classifyFileTreeWatchEvents', () => { ]); expect(classification.ops).toMatchObject([ - { op: 'put', key: expect.any(Number), value: { path: 'a.txt', parentId: null } }, + { + op: 'put', + key: expect.any(Number), + value: { path: absPath(root, 'a.txt'), parentId: null }, + }, ]); - expect(ids.getByPath('a.txt')?.id).toBe(classification.ops[0]?.key); + expect(ids.getByPath(absPath(root, 'a.txt'))?.id).toBe(classification.ops[0]?.key); }); it('ignores creates under unloaded directory scopes', async () => { @@ -48,14 +52,14 @@ describe('classifyFileTreeWatchEvents', () => { await mkdir(path.join(root, 'src'), { recursive: true }); await writeFile(path.join(root, 'src', 'a.ts'), 'a', 'utf8'); const ids = new NodeIdAssigner(); - ids.upsert(unwrap(await statEntry(root, 'src')), null); + ids.upsert(unwrap(await statEntry(root, absPath(root, 'src'))), null); const classification = await classify(root, ids, [ { kind: 'create', path: absPath(root, 'src/a.ts') }, ]); expect(classification.ops).toEqual([]); - expect(ids.getByPath('src/a.ts')).toBeUndefined(); + expect(ids.getByPath(absPath(root, 'src/a.ts'))).toBeUndefined(); }); it('ignores runtime creates for excluded paths before statting them', async () => { @@ -75,16 +79,16 @@ describe('classifyFileTreeWatchEvents', () => { expect(classification.ops).toEqual([]); expect(classification.unloadedScopes).toEqual([]); - expect(ids.getByPath('node_modules')).toBeUndefined(); - expect(ids.getByPath('.DS_Store')).toBeUndefined(); + expect(ids.getByPath(absPath(root, 'node_modules'))).toBeUndefined(); + expect(ids.getByPath(absPath(root, '.DS_Store'))).toBeUndefined(); }); it('cascades deletes for unmatched directory tombstones', async () => { const root = await makeRoot(); const ids = new NodeIdAssigner(); - const src = ids.upsert(entry('src', 'directory', '1:1'), null, true); - const nested = ids.upsert(entry('src/nested', 'directory', '1:2'), src.id, true); - const file = ids.upsert(entry('src/nested/a.ts', 'file', '1:3'), nested.id); + const src = ids.upsert(entry(root, 'src', 'directory', '1:1'), null, true); + const nested = ids.upsert(entry(root, 'src/nested', 'directory', '1:2'), src.id, true); + const file = ids.upsert(entry(root, 'src/nested/a.ts', 'file', '1:3'), nested.id); const classification = await classify( root, @@ -101,15 +105,15 @@ describe('classifyFileTreeWatchEvents', () => { { op: 'del', key: src.id }, ]); expect(new Set(classification.unloadedScopes)).toEqual(new Set([src.id, nested.id])); - expect(ids.getByPath('src')).toBeUndefined(); - expect(ids.getByPath('src/nested/a.ts')).toBeUndefined(); + expect(ids.getByPath(absPath(root, 'src'))).toBeUndefined(); + expect(ids.getByPath(absPath(root, 'src/nested/a.ts'))).toBeUndefined(); }); it('reuses a file node id for a delete/create rename batch with matching inode', async () => { const root = await makeRoot(); await writeFile(path.join(root, 'a.txt'), 'a', 'utf8'); const ids = new NodeIdAssigner(); - const before = ids.upsert(unwrap(await statEntry(root, 'a.txt')), null); + const before = ids.upsert(unwrap(await statEntry(root, absPath(root, 'a.txt'))), null); await rename(path.join(root, 'a.txt'), path.join(root, 'b.txt')); const classification = await classify(root, ids, [ @@ -121,12 +125,12 @@ describe('classifyFileTreeWatchEvents', () => { { op: 'put', key: before.id, - value: expect.objectContaining({ id: before.id, path: 'b.txt' }), + value: expect.objectContaining({ id: before.id, path: absPath(root, 'b.txt') }), }, ]); expect(classification.unloadedScopes).toEqual([]); - expect(ids.getByPath('a.txt')).toBeUndefined(); - expect(ids.getByPath('b.txt')?.id).toBe(before.id); + expect(ids.getByPath(absPath(root, 'a.txt'))).toBeUndefined(); + expect(ids.getByPath(absPath(root, 'b.txt'))?.id).toBe(before.id); }); it('moves loaded descendants when a directory rename reuses the directory id', async () => { @@ -134,9 +138,16 @@ describe('classifyFileTreeWatchEvents', () => { await mkdir(path.join(root, 'src', 'nested'), { recursive: true }); await writeFile(path.join(root, 'src', 'nested', 'a.ts'), 'a', 'utf8'); const ids = new NodeIdAssigner(); - const src = ids.upsert(unwrap(await statEntry(root, 'src')), null, true); - const nested = ids.upsert(unwrap(await statEntry(root, 'src/nested')), src.id, true); - const file = ids.upsert(unwrap(await statEntry(root, 'src/nested/a.ts')), nested.id); + const src = ids.upsert(unwrap(await statEntry(root, absPath(root, 'src'))), null, true); + const nested = ids.upsert( + unwrap(await statEntry(root, absPath(root, 'src/nested'))), + src.id, + true + ); + const file = ids.upsert( + unwrap(await statEntry(root, absPath(root, 'src/nested/a.ts'))), + nested.id + ); await rename(path.join(root, 'src'), path.join(root, 'lib')); const classification = await classify( @@ -152,23 +163,27 @@ describe('classifyFileTreeWatchEvents', () => { ); expect(classification.ops).toEqual([ - { op: 'put', key: src.id, value: expect.objectContaining({ id: src.id, path: 'lib' }) }, + { + op: 'put', + key: src.id, + value: expect.objectContaining({ id: src.id, path: absPath(root, 'lib') }), + }, { op: 'put', key: nested.id, - value: expect.objectContaining({ id: nested.id, path: 'lib/nested' }), + value: expect.objectContaining({ id: nested.id, path: absPath(root, 'lib/nested') }), }, { op: 'put', key: file.id, - value: expect.objectContaining({ id: file.id, path: 'lib/nested/a.ts' }), + value: expect.objectContaining({ id: file.id, path: absPath(root, 'lib/nested/a.ts') }), }, ]); expect(classification.unloadedScopes).toEqual([]); - expect(ids.getByPath('src')).toBeUndefined(); - expect(ids.getByPath('lib')?.id).toBe(src.id); - expect(ids.getByPath('lib/nested')?.id).toBe(nested.id); - expect(ids.getByPath('lib/nested/a.ts')?.id).toBe(file.id); + expect(ids.getByPath(absPath(root, 'src'))).toBeUndefined(); + expect(ids.getByPath(absPath(root, 'lib'))?.id).toBe(src.id); + expect(ids.getByPath(absPath(root, 'lib/nested'))?.id).toBe(nested.id); + expect(ids.getByPath(absPath(root, 'lib/nested/a.ts'))?.id).toBe(file.id); }); it('ignores events outside the watched root', async () => { @@ -207,7 +222,13 @@ async function classify( }); } -function entry(path: string, type: FileNodeType, devIno?: DevIno): ListedEntry { +function entry( + rootPath: string, + relPath: string, + type: FileNodeType, + devIno?: DevIno +): ListedEntry { + const path = absPath(rootPath, relPath); return { path, name: basename(path), type, devIno }; } diff --git a/packages/core/src/files/tree/watch/classifier.ts b/packages/core/src/files/tree/watch/classifier.ts index 6e6cbad075..a5e0b40f89 100644 --- a/packages/core/src/files/tree/watch/classifier.ts +++ b/packages/core/src/files/tree/watch/classifier.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import type { KeyedOp } from '../../../lib'; import type { WatchEvent } from '../../../watch'; import { isIgnored } from '../../ignores'; -import { parentRelPath, resolveInsideRoot } from '../../paths'; +import { contains } from '../../paths'; import { statEntry as statFileTreeEntry, type ListedEntry } from '../list'; import type { FileNode, NodeId } from '../models/tree'; import type { NodeIdAssigner, Tombstone } from '../node-id'; @@ -27,22 +27,22 @@ export async function classifyFileTreeWatchEvents( const unloadedScopes: NodeId[] = []; for (const event of events) { - const relPath = relPathFromWatchEvent(options.rootPath, event); - if (!relPath) continue; - if (isIgnored(relPath)) continue; + const absPath = absolutePathFromWatchEvent(options.rootPath, event); + if (!absPath) continue; + if (isIgnored(absPath)) continue; if (event.kind === 'update') continue; if (event.kind === 'delete') { - const node = options.ids.getByPath(relPath); + const node = options.ids.getByPath(absPath); if (!node) continue; const tombstone = options.ids.markDeleted(node.id); if (tombstone) tombstones.push(tombstone); continue; } - const stat = await statFileTreeEntry(options.rootPath, relPath); + const stat = await statFileTreeEntry(options.rootPath, absPath); if (!stat.success) continue; - const parentId = parentScopeFor(stat.data, options.ids); + const parentId = parentScopeFor(stat.data, options.rootPath, options.ids); if (parentId === undefined || !options.isScopeLoaded(parentId)) continue; const matchedTombstone = stat.data.devIno @@ -73,16 +73,22 @@ export async function classifyFileTreeWatchEvents( return { ops, unloadedScopes }; } -function parentScopeFor(entry: ListedEntry, ids: NodeIdAssigner): NodeId | null | undefined { - const parentPath = parentRelPath(entry.path); - if (!parentPath) return null; +function parentScopeFor( + entry: ListedEntry, + rootPath: string, + ids: NodeIdAssigner +): NodeId | null | undefined { + const parentPath = path.dirname(entry.path); + if (parentPath === entry.path || parentPath === rootPath) return null; const parent = ids.getByPath(parentPath); return parent?.type === 'directory' ? parent.id : undefined; } -function relPathFromWatchEvent(rootPath: string, event: WatchEvent): string | null { +function absolutePathFromWatchEvent(rootPath: string, event: WatchEvent): string | null { const relative = path.relative(rootPath, event.path).replace(/\\/g, '/'); - const resolved = resolveInsideRoot(rootPath, relative, { allowEmpty: true }); - if (!resolved.success || !resolved.data.relPath) return null; - return resolved.data.relPath; + if (!relative || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) { + return null; + } + const absPath = path.normalize(event.path); + return contains(rootPath, absPath) ? absPath : null; } diff --git a/packages/core/src/files/types.ts b/packages/core/src/files/types.ts index 813db58a64..094caaac79 100644 --- a/packages/core/src/files/types.ts +++ b/packages/core/src/files/types.ts @@ -6,12 +6,9 @@ import type { } from './changes/types'; import type { FileError } from './errors'; import type { IFileSystem } from './fs/types'; -import type { RelPath } from './paths'; import type { FileTreeError } from './tree/errors'; import type { FileTreeLease } from './tree/types'; -export type FileEnumeration = AsyncIterable; - export interface IFilesRuntime { openTree(rootPath: string): Promise>; watchChanges( @@ -19,8 +16,7 @@ export interface IFilesRuntime { cb: (update: FileChangeUpdate) => void, options?: FileChangeWatchOptions ): Result; - enumerate(rootPath: string): Result; - fileSystem(rootPath: string): Result; + fileSystem(): Result; dispose(): Promise; } @@ -34,6 +30,9 @@ export type { IFileChanges, } from './changes/types'; export type { + FileEnumeration, + FileGlob, + FileGlobOptions, FileStat, IFileSystem, ReadBytesResult, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b0ee183bd..e0d78be95a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -427,6 +427,9 @@ importers: '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 + glob: + specifier: ^13.0.6 + version: 13.0.6 semver: specifier: ^7.8.4 version: 7.8.4 From 9d0ead4a2961df1ee0e9bf1d15256902d144ffa6 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:00:45 -0700 Subject: [PATCH 20/37] feat(core-git): return absolute worktree paths --- packages/core/src/git/git-worktree.test.ts | 50 ++++++++++++-------- packages/core/src/git/git-worktree.ts | 54 +++++++++++++++------- 2 files changed, 67 insertions(+), 37 deletions(-) diff --git a/packages/core/src/git/git-worktree.test.ts b/packages/core/src/git/git-worktree.test.ts index ff32f31a13..5a8c8ba794 100644 --- a/packages/core/src/git/git-worktree.test.ts +++ b/packages/core/src/git/git-worktree.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; @@ -31,7 +31,7 @@ async function makeRepo(): Promise { await writeFile(path.join(repo, 'tracked.txt'), 'before\n', 'utf8'); await execFileAsync('git', ['add', 'tracked.txt'], { cwd: repo }); await execFileAsync('git', ['commit', '-m', 'init'], { cwd: repo }); - return repo; + return await realpath(repo); } async function makeRepoWithRemote(): Promise<{ repo: string; remote: string }> { @@ -66,6 +66,10 @@ function expectSuccess( return result.data; } +function repoFile(repo: string, filePath: string): string { + return path.join(repo, filePath); +} + describe('GitWorktree', () => { it('refreshes and emits worktree facts for real file and git mutations', async () => { const repo = await makeRepo(); @@ -110,7 +114,7 @@ describe('GitWorktree', () => { (update) => update.kind === 'status' && update.model.kind === 'ok' && - update.model.unstaged.some((change) => change.path === 'tracked.txt') + update.model.unstaged.some((change) => change.path === repoFile(repo, 'tracked.txt')) ) ? true : undefined @@ -119,7 +123,9 @@ describe('GitWorktree', () => { const changedStatus = await worktree.getStatus(); expect(changedStatus).toMatchObject({ kind: 'ok', - unstaged: [expect.objectContaining({ path: 'tracked.txt', status: 'modified' })], + unstaged: [ + expect.objectContaining({ path: repoFile(repo, 'tracked.txt'), status: 'modified' }), + ], }); expect(changedStatus).not.toHaveProperty('currentBranch'); expect(changedStatus).not.toHaveProperty('headKind'); @@ -127,7 +133,7 @@ describe('GitWorktree', () => { await expect(worktree.getFileAtRef('tracked.txt', 'HEAD')).resolves.toBe('before\n'); await expect(worktree.getChangedFiles({ kind: 'head' })).resolves.toEqual([ - expect.objectContaining({ path: 'tracked.txt', status: 'modified' }), + expect.objectContaining({ path: repoFile(repo, 'tracked.txt'), status: 'modified' }), ]); const stageSequences = expectSuccess(await worktree.stage(['tracked.txt'])); @@ -136,7 +142,9 @@ describe('GitWorktree', () => { expect(snapshotAfterStage.status.sequence).toBeGreaterThanOrEqual(stageSequences.status!); expect(await worktree.getStatus()).toMatchObject({ kind: 'ok', - staged: [expect.objectContaining({ path: 'tracked.txt', status: 'modified' })], + staged: [ + expect.objectContaining({ path: repoFile(repo, 'tracked.txt'), status: 'modified' }), + ], unstaged: [], stagedAdded: 1, stagedDeleted: 1, @@ -145,7 +153,7 @@ describe('GitWorktree', () => { expect(await worktree.getStatus()).not.toHaveProperty('totalDeleted'); await expect(worktree.getFileAtIndex('tracked.txt')).resolves.toBe('after\n'); await expect(worktree.getChangedFiles({ kind: 'staged' })).resolves.toEqual([ - expect.objectContaining({ path: 'tracked.txt', status: 'modified' }), + expect.objectContaining({ path: repoFile(repo, 'tracked.txt'), status: 'modified' }), ]); const commit = await worktree.commit('change tracked'); @@ -182,7 +190,7 @@ describe('GitWorktree', () => { ], }); await expect(worktree.getCommitFiles(commit.data.hash)).resolves.toEqual([ - expect.objectContaining({ path: 'tracked.txt', status: 'modified' }), + expect.objectContaining({ path: repoFile(repo, 'tracked.txt'), status: 'modified' }), ]); expect(updates.some((update) => update.kind === 'head')).toBe(true); @@ -213,7 +221,7 @@ describe('GitWorktree', () => { (update) => update.kind === 'status' && update.model.kind === 'ok' && - update.model.staged.some((change) => change.path === 'tracked.txt') + update.model.staged.some((change) => change.path === repoFile(repo, 'tracked.txt')) ) ? true : undefined @@ -275,7 +283,7 @@ describe('GitWorktree', () => { (update) => update.kind === 'status' && update.model.kind === 'ok' && - update.model.staged.some((change) => change.path === 'tracked.txt') + update.model.staged.some((change) => change.path === repoFile(repo, 'tracked.txt')) ) ? true : undefined @@ -503,9 +511,9 @@ describe('GitWorktree', () => { expect(await worktree.getStatus()).toMatchObject({ kind: 'ok', staged: expect.arrayContaining([ - expect.objectContaining({ path: 'tracked.txt', status: 'modified' }), - expect.objectContaining({ path: 'untracked.txt', status: 'added' }), - expect.objectContaining({ path: 'to-delete.txt', status: 'deleted' }), + expect.objectContaining({ path: repoFile(repo, 'tracked.txt'), status: 'modified' }), + expect.objectContaining({ path: repoFile(repo, 'untracked.txt'), status: 'added' }), + expect.objectContaining({ path: repoFile(repo, 'to-delete.txt'), status: 'deleted' }), ]), unstaged: [], }); @@ -516,14 +524,14 @@ describe('GitWorktree', () => { kind: 'ok', staged: [], unstaged: expect.arrayContaining([ - expect.objectContaining({ path: 'tracked.txt', status: 'modified' }), + expect.objectContaining({ path: repoFile(repo, 'tracked.txt'), status: 'modified' }), expect.objectContaining({ - path: 'untracked.txt', + path: repoFile(repo, 'untracked.txt'), status: 'added', additions: 1, deletions: 0, }), - expect.objectContaining({ path: 'to-delete.txt', status: 'deleted' }), + expect.objectContaining({ path: repoFile(repo, 'to-delete.txt'), status: 'deleted' }), ]), }); @@ -557,7 +565,7 @@ describe('GitWorktree', () => { kind: 'ok', unstaged: expect.arrayContaining([ expect.objectContaining({ - path: 'untracked.txt', + path: repoFile(repo, 'untracked.txt'), status: 'added', additions: 1, deletions: 0, @@ -617,7 +625,9 @@ describe('GitWorktree', () => { await expect(readFile(path.join(repo, 'tracked.txt'), 'utf8')).resolves.toBe('staged\n'); await expect(worktree.getStatus()).resolves.toMatchObject({ kind: 'ok', - staged: [expect.objectContaining({ path: 'tracked.txt', status: 'modified' })], + staged: [ + expect.objectContaining({ path: repoFile(repo, 'tracked.txt'), status: 'modified' }), + ], unstaged: [], }); @@ -667,7 +677,7 @@ describe('GitWorktree', () => { if (firstStatus.kind !== 'ok') throw new Error('Expected ok status'); const firstChange = firstStatus.staged[0]; expect(firstChange).toMatchObject({ - path: 'tracked.txt', + path: repoFile(repo, 'tracked.txt'), status: 'modified', additions: 1, deletions: 1, @@ -682,7 +692,7 @@ describe('GitWorktree', () => { expect(secondSequences.status).toBeGreaterThan(firstSequences.status!); expect(secondChange).toMatchObject({ - path: 'tracked.txt', + path: repoFile(repo, 'tracked.txt'), status: 'modified', additions: 1, deletions: 1, diff --git a/packages/core/src/git/git-worktree.ts b/packages/core/src/git/git-worktree.ts index 933b32901a..c808667cc2 100644 --- a/packages/core/src/git/git-worktree.ts +++ b/packages/core/src/git/git-worktree.ts @@ -177,10 +177,11 @@ export class GitWorktree implements IGitWorktree { } async isFileCleanlyTracked(filePath: string): Promise { + const relativePath = this.toRelativePath(filePath); try { - await this.exec.exec(['ls-files', '--error-unmatch', '--', filePath]); - await this.exec.exec(['diff', '--quiet', '--', filePath]); - await this.exec.exec(['diff', '--cached', '--quiet', '--', filePath]); + await this.exec.exec(['ls-files', '--error-unmatch', '--', relativePath]); + await this.exec.exec(['diff', '--quiet', '--', relativePath]); + await this.exec.exec(['diff', '--cached', '--quiet', '--', relativePath]); return true; } catch { return false; @@ -188,12 +189,13 @@ export class GitWorktree implements IGitWorktree { } async getFileAtRef(filePath: string, ref: string): Promise { - return this.repository.readBlobAtRef(ref, filePath); + return this.repository.readBlobAtRef(ref, this.toRelativePath(filePath)); } async getFileAtIndex(filePath: string): Promise { + const relativePath = this.toRelativePath(filePath); try { - const { stdout } = await this.exec.exec(['show', `:${filePath}`]); + const { stdout } = await this.exec.exec(['show', `:${relativePath}`]); return stdout; } catch { return null; @@ -201,11 +203,13 @@ export class GitWorktree implements IGitWorktree { } async getImageAtRef(filePath: string, ref: string): Promise { - return this.getImageBlob(filePath, `${ref}:${filePath}`); + const relativePath = this.toRelativePath(filePath); + return this.getImageBlob(relativePath, `${ref}:${relativePath}`); } async getImageAtIndex(filePath: string): Promise { - return this.getImageBlob(filePath, `:${filePath}`); + const relativePath = this.toRelativePath(filePath); + return this.getImageBlob(relativePath, `:${relativePath}`); } async getChangedFiles(base: DiffTarget): Promise { @@ -230,7 +234,7 @@ export class GitWorktree implements IGitWorktree { if (!filePath) continue; const stat = numstat.get(filePath); changes.push({ - path: filePath, + path: this.toAbsolutePath(filePath), status: mapGitChangeStatus(code), additions: stat?.additions ?? 0, deletions: stat?.deletions ?? 0, @@ -304,7 +308,7 @@ export class GitWorktree implements IGitWorktree { if (filePath) statusByPath.set(filePath, mapGitChangeStatus(code)); } return [...numstat.entries()].map(([filePath, stat]) => ({ - path: filePath, + path: this.toAbsolutePath(filePath), status: statusByPath.get(filePath) ?? 'modified', additions: stat.additions, deletions: stat.deletions, @@ -347,7 +351,7 @@ export class GitWorktree implements IGitWorktree { async stage(paths: string[]): Promise> { if (paths.length === 0) return ok({}); try { - await this.exec.exec(['add', '--', ...paths]); + await this.exec.exec(['add', '--', ...this.toRelativePaths(paths)]); return ok(await this.refreshStatus()); } catch (error) { return err(toGitCommandError(error)); @@ -366,7 +370,7 @@ export class GitWorktree implements IGitWorktree { async unstage(paths: string[]): Promise> { if (paths.length === 0) return ok({}); try { - await this.exec.exec(['reset', 'HEAD', '--', ...paths]); + await this.exec.exec(['reset', 'HEAD', '--', ...this.toRelativePaths(paths)]); return ok(await this.refreshStatus()); } catch (error) { return err(toGitCommandError(error)); @@ -386,9 +390,10 @@ export class GitWorktree implements IGitWorktree { async revert(paths: string[]): Promise> { if (paths.length === 0) return ok({}); + const relativePaths = this.toRelativePaths(paths); try { - const indexedPaths = await this.getIndexedPaths(paths); - const headPaths = await this.getHeadPaths(paths); + const indexedPaths = await this.getIndexedPaths(relativePaths); + const headPaths = await this.getHeadPaths(relativePaths); const indexedPathSet = new Set(indexedPaths); const headOnlyPaths = headPaths.filter((filePath) => !indexedPathSet.has(filePath)); if (indexedPaths.length > 0) { @@ -398,7 +403,7 @@ export class GitWorktree implements IGitWorktree { await this.exec.exec(['checkout', 'HEAD', '--', ...headOnlyPaths]); } const trackedPathSet = new Set([...indexedPaths, ...headPaths]); - const untrackedPaths = paths.filter((filePath) => !trackedPathSet.has(filePath)); + const untrackedPaths = relativePaths.filter((filePath) => !trackedPathSet.has(filePath)); if (untrackedPaths.length > 0) { await this.exec.exec(['clean', '-fd', '--', ...untrackedPaths]); } @@ -568,7 +573,7 @@ export class GitWorktree implements IGitWorktree { if (entry.x !== ' ' && entry.x !== '?') { const stat = stagedNumstat.get(filePath); staged.push({ - path: filePath, + path: this.toAbsolutePath(filePath), status, additions: stat?.additions ?? 0, deletions: stat?.deletions ?? 0, @@ -584,14 +589,14 @@ export class GitWorktree implements IGitWorktree { const deletions = unstagedNumstat.get(filePath)?.deletions ?? 0; if (additions === 0 && deletions === 0 && isUntracked) { try { - const result = await countFileLines(path.join(this.worktree, filePath), { + const result = await countFileLines(this.toAbsolutePath(filePath), { maxBytes: MAX_DIFF_CONTENT_BYTES, }); if (!result.truncated) additions = result.lines; } catch {} } - unstaged.push({ path: filePath, status, additions, deletions }); + unstaged.push({ path: this.toAbsolutePath(filePath), status, additions, deletions }); } const stagedAdded = staged.reduce((sum, change) => sum + change.additions, 0); @@ -684,6 +689,21 @@ export class GitWorktree implements IGitWorktree { return new Set(); } } + + private toAbsolutePath(filePath: string): string { + if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath)) + return path.normalize(filePath); + return path.join(this.worktree, filePath); + } + + private toRelativePath(filePath: string): string { + if (!path.isAbsolute(filePath) && !path.win32.isAbsolute(filePath)) return filePath; + return path.relative(this.worktree, filePath).replace(/\\/g, '/'); + } + + private toRelativePaths(paths: string[]): string[] { + return paths.map((filePath) => this.toRelativePath(filePath)); + } } function parseNumstat(stdout: string): Numstat { From 0621ee5441ca7451637a0396ec3be1e2ca1b4bbe Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:13:02 -0700 Subject: [PATCH 21/37] feat(desktop-files): add core-backed file RPCs --- .../main/core/files/browse-directory.test.ts | 145 +++ .../src/main/core/files/browse-directory.ts | 70 ++ .../src/main/core/files/controller.ts | 6 + .../main/core/files/file-system/controller.ts | 96 ++ .../core/files/file-system/file-errors.ts | 17 + .../core/files/file-system/image-support.ts | 58 + .../core/files/file-system/local-imports.ts | 106 ++ .../{fs => files}/file-tree/controller.ts | 0 .../src/main/core/files/path-utils.ts | 30 + .../src/main/core/fs/controller.ts | 290 ----- .../src/main/core/fs/impl/local-fs.test.ts | 530 --------- .../src/main/core/fs/impl/local-fs.ts | 682 ----------- .../src/main/core/fs/impl/ssh-fs.test.ts | 179 --- .../src/main/core/fs/impl/ssh-fs.ts | 1044 ----------------- .../main/core/fs/test-helpers/memory-fs.ts | 81 -- apps/emdash-desktop/src/main/core/fs/types.ts | 304 ----- .../src/main/core/ssh/controller.ts | 57 - apps/emdash-desktop/src/main/rpc.ts | 8 +- .../remote-directory-selector.tsx | 35 +- apps/emdash-desktop/src/shared/core/fs/fs.ts | 29 + .../emdash-desktop/src/shared/core/ssh/ssh.ts | 14 - .../src/shared/events/appEvents.ts | 2 +- .../src/shared/lib/ipc/rpc.test.ts | 12 +- 23 files changed, 589 insertions(+), 3206 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/files/browse-directory.test.ts create mode 100644 apps/emdash-desktop/src/main/core/files/browse-directory.ts create mode 100644 apps/emdash-desktop/src/main/core/files/controller.ts create mode 100644 apps/emdash-desktop/src/main/core/files/file-system/controller.ts create mode 100644 apps/emdash-desktop/src/main/core/files/file-system/file-errors.ts create mode 100644 apps/emdash-desktop/src/main/core/files/file-system/image-support.ts create mode 100644 apps/emdash-desktop/src/main/core/files/file-system/local-imports.ts rename apps/emdash-desktop/src/main/core/{fs => files}/file-tree/controller.ts (100%) create mode 100644 apps/emdash-desktop/src/main/core/files/path-utils.ts delete mode 100644 apps/emdash-desktop/src/main/core/fs/controller.ts delete mode 100644 apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts delete mode 100644 apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts delete mode 100644 apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts delete mode 100644 apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts delete mode 100644 apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts delete mode 100644 apps/emdash-desktop/src/main/core/fs/types.ts diff --git a/apps/emdash-desktop/src/main/core/files/browse-directory.test.ts b/apps/emdash-desktop/src/main/core/files/browse-directory.test.ts new file mode 100644 index 0000000000..aafd0316ef --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/browse-directory.test.ts @@ -0,0 +1,145 @@ +import path from 'node:path'; +import { ok, type Result } from '@emdash/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { browseDirectory } from './browse-directory'; + +const mocks = vi.hoisted(() => ({ + acquireRuntimeMock: vi.fn(), + runtimeReleaseMock: vi.fn(), + fileSystemMock: vi.fn(), + globMock: vi.fn(), + statMock: vi.fn(), +})); + +vi.mock('@main/core/runtime/runtime-manager', () => ({ + runtimeManager: { + acquire: mocks.acquireRuntimeMock, + }, +})); + +function expectOk(result: Result): T { + expect(result.success).toBe(true); + if (!result.success) throw new Error(`Expected success, got ${JSON.stringify(result.error)}`); + return result.data; +} + +function makeFilesRuntime() { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: () => true, + }, + fileSystem: mocks.fileSystemMock.mockImplementation(() => + ok({ + glob: mocks.globMock, + stat: mocks.statMock, + }) + ), + }; +} + +async function* directoryMatches(paths: string[]) { + for (const entry of paths) yield entry; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.acquireRuntimeMock.mockResolvedValue({ + value: { + files: makeFilesRuntime(), + git: {}, + }, + release: mocks.runtimeReleaseMock, + }); +}); + +describe('browseDirectory', () => { + it('lists directories through the machine file runtime', async () => { + const firstModifiedAt = new Date('2026-01-01T00:00:00.000Z'); + const secondModifiedAt = new Date('2026-01-02T00:00:00.000Z'); + const thirdModifiedAt = new Date('2026-01-03T00:00:00.000Z'); + + mocks.globMock.mockReturnValueOnce( + ok( + directoryMatches([ + '/remote/worktree/package.json', + '/remote/worktree/src', + '/remote/worktree/.env', + ]) + ) + ); + mocks.statMock.mockImplementation(async (filePath: string) => { + if (filePath === '/remote/worktree/src') { + return ok({ + path: '/remote/worktree/src', + type: 'directory', + size: 128, + mtime: secondModifiedAt, + ctime: secondModifiedAt, + mode: 0o755, + }); + } + if (filePath === '/remote/worktree/.env') { + return ok({ + path: '/remote/worktree/.env', + type: 'file', + size: 256, + mtime: thirdModifiedAt, + ctime: thirdModifiedAt, + mode: 0o644, + }); + } + return ok({ + path: '/remote/worktree/package.json', + type: 'file', + size: 512, + mtime: firstModifiedAt, + ctime: firstModifiedAt, + mode: 0o644, + }); + }); + + const entries = expectOk( + await browseDirectory({ + type: 'ssh', + connectionId: 'connection-id', + path: '/remote/worktree', + }) + ); + + expect(mocks.acquireRuntimeMock).toHaveBeenCalledWith({ + kind: 'ssh', + connectionId: 'connection-id', + }); + expect(mocks.fileSystemMock).toHaveBeenCalledWith(); + expect(mocks.globMock).toHaveBeenCalledWith(['*'], { cwd: '/remote/worktree', dot: true }); + expect(mocks.runtimeReleaseMock).toHaveBeenCalledTimes(1); + expect(entries).toEqual([ + { + path: '/remote/worktree/src', + name: 'src', + type: 'directory', + size: 128, + modifiedAt: secondModifiedAt, + }, + { + path: '/remote/worktree/.env', + name: '.env', + type: 'file', + size: 256, + modifiedAt: thirdModifiedAt, + }, + { + path: '/remote/worktree/package.json', + name: 'package.json', + type: 'file', + size: 512, + modifiedAt: firstModifiedAt, + }, + ]); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/files/browse-directory.ts b/apps/emdash-desktop/src/main/core/files/browse-directory.ts new file mode 100644 index 0000000000..a1f44550d6 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/browse-directory.ts @@ -0,0 +1,70 @@ +import { err, ok, withLease } from '@emdash/shared'; +import { runtimeManager } from '@main/core/runtime/runtime-manager'; +import type { MachineRef } from '@main/core/runtime/types'; +import type { + BrowseDirectoryParams, + BrowseDirectoryResult, + DirectoryEntry, +} from '@shared/core/fs/fs'; + +export async function browseDirectory( + params: BrowseDirectoryParams +): Promise { + return withLease(runtimeManager.acquire(machineForDirectory(params)), async (runtime) => { + const files = runtime.files; + if (!files.path.isAbsolute(params.path)) { + return err({ + type: 'invalid-path', + path: params.path, + message: `Expected absolute path: ${params.path}`, + }); + } + + const opened = files.fileSystem(); + if (!opened.success) { + return err({ + type: 'filesystem-error', + path: params.path, + message: opened.error.message, + }); + } + + const matched = opened.data.glob(['*'], { cwd: params.path, dot: true }); + if (!matched.success) { + return err({ + type: 'filesystem-error', + path: params.path, + message: matched.error.message, + }); + } + + const entries: DirectoryEntry[] = []; + for await (const absPath of matched.data) { + if (files.path.dirname(absPath) !== params.path) continue; + + const stat = await opened.data.stat(absPath); + if (!stat.success) continue; + + entries.push({ + path: stat.data.path, + name: files.path.basename(stat.data.path), + type: stat.data.type, + size: stat.data.size, + modifiedAt: stat.data.mtime, + }); + } + + return ok(entries.sort(compareDirectoryEntries)); + }); +} + +function machineForDirectory(params: BrowseDirectoryParams): MachineRef { + if (params.type === 'local') return { kind: 'local' }; + return { kind: 'ssh', connectionId: params.connectionId }; +} + +function compareDirectoryEntries(left: DirectoryEntry, right: DirectoryEntry): number { + if (left.type === 'directory' && right.type !== 'directory') return -1; + if (left.type !== 'directory' && right.type === 'directory') return 1; + return left.name.localeCompare(right.name); +} diff --git a/apps/emdash-desktop/src/main/core/files/controller.ts b/apps/emdash-desktop/src/main/core/files/controller.ts new file mode 100644 index 0000000000..176c28d7eb --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/controller.ts @@ -0,0 +1,6 @@ +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { browseDirectory } from './browse-directory'; + +export const machineFilesController = createRPCController({ + browseDirectory, +}); diff --git a/apps/emdash-desktop/src/main/core/files/file-system/controller.ts b/apps/emdash-desktop/src/main/core/files/file-system/controller.ts new file mode 100644 index 0000000000..219fb34792 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-system/controller.ts @@ -0,0 +1,96 @@ +import { err, ok } from '@emdash/shared'; +import { events } from '@main/lib/events'; +import { planEventChannel } from '@shared/events/appEvents'; +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { resolveWorkspace } from '../../projects/utils'; +import { fileErrorToMessage, isPermissionDenied } from './file-errors'; +import { readWorkspaceImage } from './image-support'; +import { copyLocalFilesToWorkspace } from './local-imports'; + +function resolveWorkspaceFiles(projectId: string, workspaceId: string) { + const env = resolveWorkspace(projectId, workspaceId); + if (!env) + return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); + + return ok({ env, fileSystem: env.fileSystem }); +} + +export const workspaceFileSystemController = createRPCController({ + readFile: async (projectId: string, workspaceId: string, filePath: string, maxBytes?: number) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + const result = await fileSystem.readText(filePath, { maxBytes }); + if (!result.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(result.error) }); + } + return ok(result.data); + }, + + writeFile: async (projectId: string, workspaceId: string, filePath: string, content: string) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + const result = await fileSystem.writeText(filePath, content); + if (!result.success) { + if (isPermissionDenied(result.error)) { + events.emit(planEventChannel, { + type: 'write_blocked' as const, + root: projectId, + path: filePath, + message: result.error.message, + }); + } + return err({ type: 'fs_error' as const, message: fileErrorToMessage(result.error) }); + } + return ok({ success: true as const, bytesWritten: result.data.bytesWritten }); + }, + + readImage: async (projectId: string, workspaceId: string, filePath: string) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + return await readWorkspaceImage(fileSystem, filePath); + }, + + fileExists: async (projectId: string, workspaceId: string, filePath: string) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + const result = await fileSystem.exists(filePath); + if (!result.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(result.error) }); + } + return ok({ exists: result.data }); + }, + + getAbsolutePath: async (projectId: string, workspaceId: string, filePath: string) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + const result = await fileSystem.realPath(filePath); + if (!result.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(result.error) }); + } + return ok({ path: result.data }); + }, + + copyLocalFiles: async ( + projectId: string, + workspaceId: string, + srcPaths: string[], + destDirPath: string, + options?: { overwrite?: boolean } + ) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { env, fileSystem } = resolved.data; + + return await copyLocalFilesToWorkspace(fileSystem, env.path, srcPaths, destDirPath, options); + }, +}); diff --git a/apps/emdash-desktop/src/main/core/files/file-system/file-errors.ts b/apps/emdash-desktop/src/main/core/files/file-system/file-errors.ts new file mode 100644 index 0000000000..8db1c897c0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-system/file-errors.ts @@ -0,0 +1,17 @@ +import type { FileError } from '@emdash/core/files'; + +const PERMISSION_DENIED = 'PERMISSION_DENIED'; + +export function fileErrorToMessage(error: FileError): string { + return error.message; +} + +export function isPermissionDenied(error: FileError): boolean { + if (error.type !== 'fs-error') return false; + return ( + error.code === PERMISSION_DENIED || + error.code === 'EACCES' || + error.code === 'EPERM' || + error.message.toLowerCase().includes('permission denied') + ); +} diff --git a/apps/emdash-desktop/src/main/core/files/file-system/image-support.ts b/apps/emdash-desktop/src/main/core/files/file-system/image-support.ts new file mode 100644 index 0000000000..2ba383310b --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-system/image-support.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { ok } from '@emdash/shared'; +import { fileErrorToMessage } from './file-errors'; + +export const ALLOWED_IMAGE_EXTENSIONS = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.svg', + '.bmp', + '.ico', +]); + +const IMAGE_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', +}; + +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; + +export async function readWorkspaceImage(fileSystem: IFileSystem, filePath: string) { + const ext = path.extname(filePath).toLowerCase(); + if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { + return ok({ + success: false as const, + error: `Unsupported image format: ${ext}. Allowed: ${Array.from(ALLOWED_IMAGE_EXTENSIONS).join(', ')}`, + }); + } + + const result = await fileSystem.readBytes(filePath, { maxBytes: MAX_IMAGE_SIZE }); + if (!result.success) { + return ok({ success: false as const, error: fileErrorToMessage(result.error) }); + } + if (result.data.truncated) { + return ok({ + success: false as const, + error: `Image too large: ${result.data.totalSize} bytes (max ${MAX_IMAGE_SIZE})`, + }); + } + + const mimeType = IMAGE_MIME_TYPES[ext] || 'application/octet-stream'; + const base64 = Buffer.from(result.data.bytes).toString('base64'); + return ok({ + success: true as const, + dataUrl: `data:${mimeType};base64,${base64}`, + mimeType, + size: result.data.totalSize, + }); +} diff --git a/apps/emdash-desktop/src/main/core/files/file-system/local-imports.ts b/apps/emdash-desktop/src/main/core/files/file-system/local-imports.ts new file mode 100644 index 0000000000..8448a3550d --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-system/local-imports.ts @@ -0,0 +1,106 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; +import { + containsMachinePath, + displayPathInDirectory, + isAbsoluteMachinePath, + joinMachinePath, +} from '../path-utils'; +import { fileErrorToMessage } from './file-errors'; + +function normalizeRelativePath(filePath: string, options?: { allowEmpty?: boolean }): string { + if (filePath.includes('\0')) throw new Error('Path contains a null byte'); + const normalized = path.posix.normalize(filePath.replace(/\\/g, '/')); + if (normalized === '.') { + if (options?.allowEmpty) return ''; + throw new Error('Path must not be empty'); + } + if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) { + throw new Error('Path must be relative'); + } + const parts = normalized.split('/').filter(Boolean); + if (parts.includes('..')) throw new Error('Parent path segments are not allowed'); + return parts.join('/'); +} + +function resolveWorkspacePath( + workspacePath: string, + filePath: string, + options?: { allowEmpty?: boolean } +): string { + const absPath = isAbsoluteMachinePath(filePath) + ? filePath + : (() => { + const workspaceRelativePath = normalizeRelativePath(filePath, options); + return workspaceRelativePath + ? joinMachinePath(workspacePath, workspaceRelativePath) + : workspacePath; + })(); + if (!containsMachinePath(workspacePath, absPath)) { + throw new Error('Destination path must be inside the workspace'); + } + return absPath; +} + +export async function copyLocalFilesToWorkspace( + fileSystem: IFileSystem, + workspacePath: string, + srcPaths: string[], + destDirPath: string, + options?: { overwrite?: boolean } +) { + try { + const destDirAbsPath = resolveWorkspacePath(workspacePath, destDirPath, { + allowEmpty: true, + }); + const destDirDisplayPath = displayPathInDirectory(workspacePath, destDirAbsPath); + const madeDir = await fileSystem.mkdir(destDirAbsPath, { recursive: true }); + if (!madeDir.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(madeDir.error) }); + } + + const plannedCopies = await Promise.all( + srcPaths.map(async (srcPath) => { + if (!path.isAbsolute(srcPath)) throw new Error('Source path must be absolute'); + const fileName = path.basename(srcPath); + if (!fileName) throw new Error('Source path must include a file name'); + const srcStat = await fs.stat(srcPath); + if (srcStat.isDirectory()) throw new Error(`Cannot import directories: ${srcPath}`); + const destDisplayPath = destDirDisplayPath + ? path.posix.join(destDirDisplayPath, fileName) + : fileName; + const destAbsPath = joinMachinePath(destDirAbsPath, fileName); + return { srcPath, destDisplayPath, destAbsPath }; + }) + ); + + const seenDestPaths = new Set(); + const conflicts: string[] = []; + for (const { destDisplayPath, destAbsPath } of plannedCopies) { + if (seenDestPaths.has(destDisplayPath)) { + throw new Error(`Duplicate destination: ${destDisplayPath}`); + } + seenDestPaths.add(destDisplayPath); + const exists = await fileSystem.exists(destAbsPath); + if (!exists.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(exists.error) }); + } + if (!options?.overwrite && exists.data) conflicts.push(destDisplayPath); + } + if (conflicts.length > 0) throw new Error(`Files already exist:\n${conflicts.join('\n')}`); + + for (const { srcPath, destAbsPath } of plannedCopies) { + const bytes = await fs.readFile(srcPath); + const written = await fileSystem.writeBytes(destAbsPath, bytes); + if (!written.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(written.error) }); + } + } + + return ok({ copied: srcPaths.length }); + } catch (e) { + return err({ type: 'fs_error' as const, message: String(e) }); + } +} diff --git a/apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts b/apps/emdash-desktop/src/main/core/files/file-tree/controller.ts similarity index 100% rename from apps/emdash-desktop/src/main/core/fs/file-tree/controller.ts rename to apps/emdash-desktop/src/main/core/files/file-tree/controller.ts diff --git a/apps/emdash-desktop/src/main/core/files/path-utils.ts b/apps/emdash-desktop/src/main/core/files/path-utils.ts new file mode 100644 index 0000000000..7c39efbc08 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/path-utils.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; + +export function isAbsoluteMachinePath(filePath: string): boolean { + return path.posix.isAbsolute(filePath) || path.win32.isAbsolute(filePath); +} + +export function joinMachinePath(basePath: string, ...segments: string[]): string { + let current = basePath; + for (const segment of segments) { + const normalized = segment.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized) continue; + current = + current.endsWith('/') || current.endsWith('\\') + ? `${current}${normalized}` + : `${current}/${normalized}`; + } + return current; +} + +export function containsMachinePath(parentPath: string, childPath: string): boolean { + const parent = parentPath.replace(/\\/g, '/'); + const child = childPath.replace(/\\/g, '/'); + const rel = path.posix.relative(parent, child); + return rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)); +} + +export function displayPathInDirectory(parentPath: string, childPath: string): string { + const rel = path.posix.relative(parentPath.replace(/\\/g, '/'), childPath.replace(/\\/g, '/')); + return rel === '.' ? '' : rel; +} diff --git a/apps/emdash-desktop/src/main/core/fs/controller.ts b/apps/emdash-desktop/src/main/core/fs/controller.ts deleted file mode 100644 index e7d749a199..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/controller.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { err, ok } from '@emdash/shared'; -import { events } from '@main/lib/events'; -import { planEventChannel } from '@shared/events/appEvents'; -import { createRPCController } from '@shared/lib/ipc/rpc'; -import { resolveWorkspace } from '../projects/utils'; -import { FileSystemErrorCodes, type ListOptions, type SearchOptions } from './types'; - -/** - * Legacy workspace filesystem RPC surface. - * - * Keep this for non-tree file operations: editor read/write/image/copy, - * project setup, and related workspace services. File tree and file-change - * subscriptions go through the runtime-owned file domain. - */ - -function normalizeRelativePath(filePath: string, options?: { allowEmpty?: boolean }): string { - if (!filePath && options?.allowEmpty) return ''; - const normalized = path.posix.normalize(filePath.replaceAll('\\', '/')); - if ( - !filePath || - path.isAbsolute(filePath) || - path.posix.isAbsolute(normalized) || - path.win32.isAbsolute(filePath) || - normalized === '..' || - normalized.startsWith('../') || - normalized.includes('/../') - ) { - throw new Error('Invalid file path'); - } - return normalized === '.' ? '' : normalized; -} - -function joinWorkspacePath(rootPath: string, filePath: string): string { - if (!filePath) return rootPath; - const separator = - rootPath.includes('\\') && !rootPath.includes('/') ? path.win32.sep : path.posix.sep; - return rootPath.endsWith('/') || rootPath.endsWith('\\') - ? `${rootPath}${filePath}` - : `${rootPath}${separator}${filePath}`; -} - -export const filesController = createRPCController({ - /** - * @deprecated Not used by the editor file tree. Prefer `workspace.fileTree` - * for tree data, and add narrowly-scoped file operation RPCs for other use - * cases instead of growing this generic listing route. - */ - listFiles: async ( - projectId: string, - workspaceId: string, - dirPath: string, - options?: ListOptions - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const result = await env.fs.list(dirPath, options); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - readFile: async (projectId: string, workspaceId: string, filePath: string, maxBytes?: number) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const result = await env.fs.read(filePath, maxBytes); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - writeFile: async (projectId: string, workspaceId: string, filePath: string, content: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const result = await env.fs.write(filePath, content); - return ok(result); - } catch (e) { - if ( - e instanceof Error && - (e as unknown as { code?: string }).code === FileSystemErrorCodes.PERMISSION_DENIED - ) { - events.emit(planEventChannel, { - type: 'write_blocked' as const, - root: projectId, - relPath: filePath, - message: e.message, - }); - } - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - removeFile: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - if (!env.fs.remove) { - return err({ - type: 'fs_error' as const, - message: 'remove not supported by this filesystem', - }); - } - - try { - const result = await env.fs.remove(filePath); - return ok(result); - } catch (e) { - if ( - e instanceof Error && - (e as unknown as { code?: string }).code === FileSystemErrorCodes.PERMISSION_DENIED - ) { - events.emit(planEventChannel, { - type: 'remove_blocked' as const, - root: projectId, - relPath: filePath, - message: e.message, - }); - } - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - readImage: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - if (!env.fs.readImage) { - return err({ - type: 'fs_error' as const, - message: 'readImage not supported by this filesystem', - }); - } - - try { - const result = await env.fs.readImage(filePath); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - searchFiles: async ( - projectId: string, - workspaceId: string, - query: string, - options?: SearchOptions - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const result = await env.fs.search(query, options); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - statFile: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const entry = await env.fs.stat(filePath); - return ok({ entry }); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - fileExists: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const exists = await env.fs.exists(filePath); - return ok({ exists }); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - getAbsolutePath: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - return ok({ path: joinWorkspacePath(env.path, normalizeRelativePath(filePath)) }); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - copyLocalFiles: async ( - projectId: string, - workspaceId: string, - srcPaths: string[], - destDirPath: string, - options?: { overwrite?: boolean } - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - if (!env.fs.copyLocalFile) { - return err({ - type: 'fs_error' as const, - message: 'copyLocalFile not supported by this filesystem', - }); - } - - try { - const destDir = normalizeRelativePath(destDirPath, { allowEmpty: true }); - await env.fs.mkdir(destDir || '.', { recursive: true }); - - const plannedCopies = await Promise.all( - srcPaths.map(async (srcPath) => { - if (!path.isAbsolute(srcPath)) throw new Error('Source path must be absolute'); - const fileName = path.basename(srcPath); - if (!fileName) throw new Error('Source path must include a file name'); - const srcStat = await fs.stat(srcPath); - if (srcStat.isDirectory()) throw new Error(`Cannot import directories: ${srcPath}`); - const destRelPath = destDir ? path.posix.join(destDir, fileName) : fileName; - return { srcPath, destRelPath }; - }) - ); - - const seenDestPaths = new Set(); - const conflicts: string[] = []; - for (const { destRelPath } of plannedCopies) { - if (seenDestPaths.has(destRelPath)) - throw new Error(`Duplicate destination: ${destRelPath}`); - seenDestPaths.add(destRelPath); - if (!options?.overwrite && (await env.fs.exists(destRelPath))) conflicts.push(destRelPath); - } - if (conflicts.length > 0) throw new Error(`Files already exist:\n${conflicts.join('\n')}`); - - for (const { srcPath, destRelPath } of plannedCopies) { - await env.fs.copyLocalFile(srcPath, destRelPath); - } - - return ok({ copied: srcPaths.length }); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - saveAttachment: async ( - projectId: string, - workspaceId: string, - srcPath: string, - subdir?: string - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - if (!env.fs.saveAttachment) { - return err({ - type: 'fs_error' as const, - message: 'saveAttachment not supported by this filesystem', - }); - } - - try { - const result = await env.fs.saveAttachment(srcPath, subdir); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, -}); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts deleted file mode 100644 index 1d687bf609..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { FileSystemError } from '../types'; -import { LocalFileSystem } from './local-fs'; - -describe('LocalFileSystem', () => { - let tempDir: string; - let fsService: LocalFileSystem; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-test-')); - fsService = new LocalFileSystem(tempDir); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('constructor', () => { - it('should throw error when project path is empty', () => { - expect(() => new LocalFileSystem('')).toThrow(FileSystemError); - expect(() => new LocalFileSystem('')).toThrow('Project path is required'); - }); - - it('should resolve project path', () => { - const relativePath = 'relative/project'; - const service = new LocalFileSystem(relativePath); - expect(service).toBeDefined(); - }); - }); - - describe('list', () => { - it('should list files in directory', async () => { - fs.writeFileSync(path.join(tempDir, 'file1.txt'), 'content1'); - fs.writeFileSync(path.join(tempDir, 'file2.txt'), 'content2'); - fs.mkdirSync(path.join(tempDir, 'subdir')); - - const result = await fsService.list(''); - - expect(result.entries).toHaveLength(3); - expect(result.entries.some((e) => e.path === 'file1.txt' && e.type === 'file')).toBe(true); - expect(result.entries.some((e) => e.path === 'file2.txt' && e.type === 'file')).toBe(true); - expect(result.entries.some((e) => e.path === 'subdir' && e.type === 'dir')).toBe(true); - }); - - it('should list files in subdirectory', async () => { - const subdir = path.join(tempDir, 'subdir'); - fs.mkdirSync(subdir); - fs.writeFileSync(path.join(subdir, 'nested.txt'), 'nested content'); - - const result = await fsService.list('subdir'); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0].path).toBe('subdir/nested.txt'); - }); - - it('should list recursively', async () => { - fs.mkdirSync(path.join(tempDir, 'level1')); - fs.writeFileSync(path.join(tempDir, 'level1/file1.txt'), 'content1'); - fs.mkdirSync(path.join(tempDir, 'level1/level2')); - fs.writeFileSync(path.join(tempDir, 'level1/level2/file2.txt'), 'content2'); - - const result = await fsService.list('', { recursive: true }); - - expect(result.entries.some((e) => e.path === 'level1')).toBe(true); - expect(result.entries.some((e) => e.path === 'level1/file1.txt')).toBe(true); - expect(result.entries.some((e) => e.path === 'level1/level2')).toBe(true); - expect(result.entries.some((e) => e.path === 'level1/level2/file2.txt')).toBe(true); - }); - - it('should exclude hidden files by default', async () => { - fs.writeFileSync(path.join(tempDir, 'visible.txt'), 'content'); - fs.writeFileSync(path.join(tempDir, '.hidden'), 'hidden content'); - - const result = await fsService.list(''); - - expect(result.entries.some((e) => e.path === 'visible.txt')).toBe(true); - expect(result.entries.some((e) => e.path === '.hidden')).toBe(false); - }); - - it('should include hidden files when specified', async () => { - fs.writeFileSync(path.join(tempDir, 'visible.txt'), 'content'); - fs.writeFileSync(path.join(tempDir, '.hidden'), 'hidden content'); - - const result = await fsService.list('', { includeHidden: true }); - - expect(result.entries.some((e) => e.path === '.hidden')).toBe(true); - }); - - it('should apply filter pattern', async () => { - fs.writeFileSync(path.join(tempDir, 'test.ts'), 'typescript'); - fs.writeFileSync(path.join(tempDir, 'test.js'), 'javascript'); - fs.writeFileSync(path.join(tempDir, 'readme.md'), 'markdown'); - - const result = await fsService.list('', { filter: '.*\\.ts$' }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0].path).toBe('test.ts'); - }); - - it('should truncate when maxEntries reached', async () => { - for (let i = 0; i < 10; i++) { - fs.writeFileSync(path.join(tempDir, `file${i}.txt`), 'content'); - } - - const result = await fsService.list('', { maxEntries: 5 }); - - expect(result.total).toBe(5); - expect(result.truncated).toBe(true); - expect(result.truncateReason).toBe('maxEntries'); - }); - - it('should truncate when time budget exceeded', async () => { - // Create many files to ensure time budget is exceeded - for (let i = 0; i < 1000; i++) { - fs.writeFileSync(path.join(tempDir, `file${i}.txt`), 'content'); - } - - const result = await fsService.list('', { recursive: true, timeBudgetMs: 1 }); - - expect(result.truncated).toBe(true); - expect(result.truncateReason).toBe('timeBudget'); - }); - - it('should include file metadata', async () => { - const filePath = path.join(tempDir, 'test.txt'); - fs.writeFileSync(filePath, 'test content'); - - const result = await fsService.list(''); - - expect(result.entries[0].size).toBe(12); - expect(result.entries[0].mtime).toBeInstanceOf(Date); - expect(result.entries[0].mode).toBeDefined(); - }); - }); - - describe('read', () => { - it('should read file content', async () => { - fs.writeFileSync(path.join(tempDir, 'test.txt'), 'Hello, World!'); - - const result = await fsService.read('test.txt'); - - expect(result.content).toBe('Hello, World!'); - expect(result.truncated).toBe(false); - expect(result.totalSize).toBe(13); - }); - - it('should throw error when file not found', async () => { - await expect(fsService.read('nonexistent.txt')).rejects.toThrow(FileSystemError); - await expect(fsService.read('nonexistent.txt')).rejects.toThrow('File not found'); - }); - - it('should throw error when path is directory', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - - await expect(fsService.read('subdir')).rejects.toThrow(FileSystemError); - await expect(fsService.read('subdir')).rejects.toThrow('Path is a directory'); - }); - - it('should truncate large files', async () => { - const largeContent = 'x'.repeat(300 * 1024); // 300KB - fs.writeFileSync(path.join(tempDir, 'large.txt'), largeContent); - - const result = await fsService.read('large.txt', 200 * 1024); - - expect(result.truncated).toBe(true); - expect(result.content.length).toBe(200 * 1024); - expect(result.totalSize).toBe(300 * 1024); - }); - - it('should not truncate files under maxBytes', async () => { - const content = 'Small content'; - fs.writeFileSync(path.join(tempDir, 'small.txt'), content); - - const result = await fsService.read('small.txt', 200 * 1024); - - expect(result.truncated).toBe(false); - expect(result.content).toBe(content); - }); - }); - - describe('write', () => { - it('should write file content', async () => { - const result = await fsService.write('newfile.txt', 'New content'); - - expect(result.success).toBe(true); - expect(fs.readFileSync(path.join(tempDir, 'newfile.txt'), 'utf-8')).toBe('New content'); - }); - - it('should create parent directories', async () => { - const result = await fsService.write('nested/deep/file.txt', 'Deep content'); - - expect(result.success).toBe(true); - expect(fs.existsSync(path.join(tempDir, 'nested/deep/file.txt'))).toBe(true); - }); - - it('should return bytes written', async () => { - const content = 'Test content'; - const result = await fsService.write('test.txt', content); - - expect(result.bytesWritten).toBe(Buffer.byteLength(content, 'utf-8')); - }); - - it('should throw error when cannot create directory', async () => { - // Make tempDir read-only (on Unix systems) - if (process.platform !== 'win32') { - fs.chmodSync(tempDir, 0o555); - - try { - await expect(fsService.write('readonly/test.txt', 'content')).rejects.toThrow( - FileSystemError - ); - } finally { - fs.chmodSync(tempDir, 0o755); - } - } - }); - }); - - describe('exists', () => { - it('should return true for existing file', async () => { - fs.writeFileSync(path.join(tempDir, 'exists.txt'), 'content'); - - const result = await fsService.exists('exists.txt'); - - expect(result).toBe(true); - }); - - it('should return true for existing directory', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - - const result = await fsService.exists('subdir'); - - expect(result).toBe(true); - }); - - it('should return false for non-existent path', async () => { - const result = await fsService.exists('nonexistent.txt'); - - expect(result).toBe(false); - }); - }); - - describe('stat', () => { - it('should return file entry for file', async () => { - fs.writeFileSync(path.join(tempDir, 'test.txt'), 'content'); - - const result = await fsService.stat('test.txt'); - - expect(result).not.toBeNull(); - expect(result?.path).toBe('test.txt'); - expect(result?.type).toBe('file'); - expect(result?.size).toBe(7); - }); - - it('should return file entry for directory', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - - const result = await fsService.stat('subdir'); - - expect(result).not.toBeNull(); - expect(result?.path).toBe('subdir'); - expect(result?.type).toBe('dir'); - }); - - it('should return null for non-existent path', async () => { - const result = await fsService.stat('nonexistent.txt'); - - expect(result).toBeNull(); - }); - }); - - describe('search', () => { - beforeEach(() => { - fs.writeFileSync(path.join(tempDir, 'file1.ts'), 'const foo = "bar";\nfunction test() {}'); - fs.writeFileSync(path.join(tempDir, 'file2.ts'), 'let x = 1;\nconst foo = 2;'); - fs.writeFileSync(path.join(tempDir, 'readme.md'), '# README\nThis is documentation'); - - const subdir = path.join(tempDir, 'src'); - fs.mkdirSync(subdir); - fs.writeFileSync(path.join(subdir, 'main.ts'), 'function main() {\n console.log(foo);\n}'); - }); - - it('should find matches in files', async () => { - const result = await fsService.search('foo'); - - expect(result.total).toBeGreaterThan(0); - expect(result.matches.some((m) => m.filePath === 'file1.ts')).toBe(true); - expect(result.matches.some((m) => m.filePath === 'file2.ts')).toBe(true); - expect(result.matches.some((m) => m.filePath === 'src/main.ts')).toBe(true); - }); - - it('should return match details', async () => { - const result = await fsService.search('foo'); - - const match = result.matches.find((m) => m.filePath === 'file1.ts'); - expect(match).toBeDefined(); - expect(match?.line).toBe(1); - expect(match?.column).toBeGreaterThan(0); - expect(match?.content).toContain('foo'); - }); - - it('should respect maxResults', async () => { - const result = await fsService.search('foo', { maxResults: 2 }); - - expect(result.total).toBe(2); - expect(result.truncated).toBe(true); - }); - - it('should filter by file extensions', async () => { - const result = await fsService.search('foo', { fileExtensions: ['.ts'] }); - - expect(result.matches.every((m) => m.filePath.endsWith('.ts'))).toBe(true); - }); - - it('should filter by file pattern', async () => { - const result = await fsService.search('foo', { filePattern: '*.md' }); - - expect(result.total).toBe(0); - }); - - it('should be case-insensitive by default', async () => { - const result1 = await fsService.search('FOO'); - const result2 = await fsService.search('foo'); - - expect(result1.total).toBe(result2.total); - }); - - it('should respect caseSensitive option', async () => { - const result = await fsService.search('FOO', { caseSensitive: true }); - - expect(result.total).toBe(0); - }); - - it('should skip binary files', async () => { - // Create a "binary" file with null bytes - fs.writeFileSync(path.join(tempDir, 'binary.bin'), Buffer.from([0x00, 0x01, 0x02, 0x03])); - - const result = await fsService.search('\x00'); - - expect(result.matches).toHaveLength(0); - }); - - it('should skip ignored directories', async () => { - const nodeModules = path.join(tempDir, 'node_modules'); - fs.mkdirSync(nodeModules); - fs.writeFileSync(path.join(nodeModules, 'test.ts'), 'const foo = "ignored";'); - - const result = await fsService.search('foo'); - - expect(result.matches.some((m) => m.filePath.includes('node_modules'))).toBe(false); - }); - - it('should track files searched', async () => { - const result = await fsService.search('foo'); - - expect(result.filesSearched).toBeGreaterThan(0); - }); - }); - - describe('remove', () => { - it('should remove file', async () => { - fs.writeFileSync(path.join(tempDir, 'delete.txt'), 'content'); - - const result = await fsService.remove('delete.txt'); - - expect(result.success).toBe(true); - expect(fs.existsSync(path.join(tempDir, 'delete.txt'))).toBe(false); - }); - - it('should fail when file not found', async () => { - const result = await fsService.remove('nonexistent.txt'); - - expect(result.success).toBe(false); - expect(result.error).toContain('File not found'); - }); - - it('should fail when path is directory', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - - const result = await fsService.remove('subdir'); - - expect(result.success).toBe(false); - expect(result.error).toContain('directory'); - }); - - it('should retry with chmod on permission error', async () => { - if (process.platform !== 'win32') { - const filePath = path.join(tempDir, 'readonly.txt'); - fs.writeFileSync(filePath, 'content'); - fs.chmodSync(filePath, 0o444); - - try { - const result = await fsService.remove('readonly.txt'); - expect(result.success).toBe(true); - } finally { - // Restore permissions for cleanup - try { - fs.chmodSync(filePath, 0o666); - } catch { - // Ignore - } - } - } - }); - }); - - describe('readImage', () => { - it('should read image as data URL', async () => { - // Create a minimal valid PNG file (1x1 transparent pixel) - const pngBuffer = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', - 'base64' - ); - fs.writeFileSync(path.join(tempDir, 'test.png'), pngBuffer); - - const result = await fsService.readImage('test.png'); - - expect(result.success).toBe(true); - expect(result.dataUrl).toMatch(/^data:image\/png;base64,/); - expect(result.mimeType).toBe('image/png'); - expect(result.size).toBe(pngBuffer.length); - }); - - it('should reject unsupported image formats', async () => { - // bmp is not in the allowed list - fs.writeFileSync(path.join(tempDir, 'test.xyz'), 'fake-data'); - - const result = await fsService.readImage('test.xyz'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Unsupported image format'); - }); - - it('should fail when image not found', async () => { - const result = await fsService.readImage('nonexistent.png'); - - expect(result.success).toBe(false); - expect(result.error).toContain('not found'); - }); - - it('should fail when path is directory', async () => { - fs.mkdirSync(path.join(tempDir, 'images')); - - // Directories don't have extensions, so this will fail with unsupported format - // or directory error depending on implementation order - const result = await fsService.readImage('images'); - - expect(result.success).toBe(false); - }); - - it('should reject oversized images', async () => { - // Create a fake large "image" file - const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB - fs.writeFileSync(path.join(tempDir, 'large.png'), largeBuffer); - - const result = await fsService.readImage('large.png'); - - expect(result.success).toBe(false); - expect(result.error).toContain('too large'); - }); - }); - - describe('path traversal protection', () => { - it('should block absolute path traversal', async () => { - // Absolute paths get normalized by resolvePath - await expect(fsService.read('/etc/passwd')).rejects.toThrow(); - }); - - it('should block relative path traversal', async () => { - await expect(fsService.read('../package.json')).rejects.toThrow(); - }); - - it('should block nested path traversal', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - fs.writeFileSync(path.join(tempDir, 'subdir/file.txt'), 'content'); - - await expect(fsService.read('subdir/../../../etc/passwd')).rejects.toThrow(); - }); - - it('should normalize repeated separators inside relative paths', async () => { - fs.mkdirSync(path.join(tempDir, 'nested')); - fs.writeFileSync(path.join(tempDir, 'nested/test.txt'), 'content'); - - const result = await fsService.read('nested//test.txt'); - - expect(result.content).toBe('content'); - }); - - it('should block leading double slash paths as absolute paths', async () => { - fs.writeFileSync(path.join(tempDir, 'test.txt'), 'content'); - - await expect(fsService.read('//test.txt')).rejects.toThrow('Absolute paths are not allowed'); - }); - - it('should allow valid subpaths', async () => { - fs.mkdirSync(path.join(tempDir, 'valid')); - fs.mkdirSync(path.join(tempDir, 'valid/nested')); - fs.writeFileSync(path.join(tempDir, 'valid/nested/file.txt'), 'content'); - - const result = await fsService.read('valid/nested/file.txt'); - - expect(result.content).toBe('content'); - }); - }); - - describe('large file handling', () => { - it('should handle files larger than default maxBytes', async () => { - const largeContent = 'x'.repeat(500 * 1024); // 500KB - fs.writeFileSync(path.join(tempDir, 'large.txt'), largeContent); - - const result = await fsService.read('large.txt'); - - expect(result.truncated).toBe(true); - expect(result.content.length).toBe(200 * 1024); // Default limit - }); - - it('should handle custom maxBytes limit', async () => { - const content = 'x'.repeat(100); - fs.writeFileSync(path.join(tempDir, 'medium.txt'), content); - - const result = await fsService.read('medium.txt', 50); - - expect(result.truncated).toBe(true); - expect(result.content.length).toBe(50); - }); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts deleted file mode 100644 index c23fbff601..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts +++ /dev/null @@ -1,682 +0,0 @@ -import { createReadStream, promises as fs, type Stats } from 'node:fs'; -import { basename, dirname, extname, join, relative, resolve } from 'node:path'; -import { createInterface } from 'node:readline'; -import { isIgnored, resolveInsideRoot } from '@emdash/core/files'; -import { glob } from 'glob'; -import ignore from 'ignore'; -import { log } from '@main/lib/logger'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileEntry, - type FileListResult, - type FileSystemProvider, - type ListOptions, - type ReadResult, - type SearchMatch, - type SearchOptions, - type SearchResult, - type WriteResult, -} from '../types'; - -// Binary file extensions to skip during search -const BINARY_EXTENSIONS = new Set([ - '.exe', - '.dll', - '.so', - '.dylib', - '.bin', - '.jpg', - '.jpeg', - '.png', - '.gif', - '.bmp', - '.ico', - '.svg', - '.mp3', - '.mp4', - '.avi', - '.mov', - '.wmv', - '.flv', - '.webm', - '.zip', - '.tar', - '.gz', - '.bz2', - '.7z', - '.rar', - '.pdf', - '.doc', - '.docx', - '.xls', - '.xlsx', - '.ppt', - '.pptx', - '.woff', - '.woff2', - '.ttf', - '.otf', - '.eot', - '.wasm', - '.class', - '.jar', - '.pyc', - '.o', - '.a', -]); - -// Allowed image extensions for readImage -const ALLOWED_IMAGE_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.svg', - '.bmp', - '.ico', -]); - -// MIME types for images -const IMAGE_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', -}; - -/** - * Legacy local `FileSystemProvider` implementation. - * - * Keep for non-tree workspace file operations. The editor file tree uses - * `@emdash/core/files` directly and should not add new behavior here. - */ -export class LocalFileSystem implements FileSystemProvider { - private listAbort: AbortController | null = null; - - constructor(private projectPath: string) { - if (!projectPath) { - throw new FileSystemError('Project path is required', FileSystemErrorCodes.INVALID_PATH); - } - this.projectPath = resolve(projectPath); - } - - /** - * Cancel any in-flight list() traversal. Used by the IPC layer for per-sender debouncing. - * The in-process traversal checks the abort signal and exits early on the next tick. - */ - cancelPendingList(): void { - if (this.listAbort) { - this.listAbort.abort(); - this.listAbort = null; - } - } - - private resolveWorkspacePath(relPath: string, options: { allowEmpty?: boolean } = {}): string { - const resolved = resolveInsideRoot(this.projectPath, relPath, options); - if (!resolved.success) { - const message = - resolved.error.type === 'invalid-path' ? resolved.error.message : 'Invalid file path'; - throw new FileSystemError(message, FileSystemErrorCodes.INVALID_PATH, relPath); - } - return resolved.data.absPath; - } - - /** - * Get relative path from absolute path - */ - private relPath(fullPath: string): string { - return relative(this.projectPath, fullPath).replace(/\\/g, '/'); - } - - /** - * Check if a path should be ignored during search - */ - private shouldIgnore(name: string): boolean { - return isIgnored(name); - } - - /** - * Check if file is binary by extension - */ - private isBinaryFile(filePath: string): boolean { - const ext = extname(filePath).toLowerCase(); - return BINARY_EXTENSIONS.has(ext); - } - - /** - * Convert fs.Stats to FileEntry - */ - private statToEntry(fullPath: string, stat: Stats): FileEntry { - const relPath = this.relPath(fullPath); - return { - path: relPath, - type: stat.isDirectory() ? 'dir' : 'file', - size: stat.size, - mtime: stat.mtime, - ctime: stat.ctime, - mode: stat.mode, - }; - } - - async list(path: string = '', options: ListOptions = {}): Promise { - const startTime = Date.now(); - const fullPath = this.resolveWorkspacePath(path, { allowEmpty: true }); - const entries: FileEntry[] = []; - const maxEntries = options.maxEntries || 10000; - const timeBudgetMs = options.timeBudgetMs || 30000; - - const abort = new AbortController(); - this.listAbort = abort; - - let truncated = false; - let truncateReason: 'maxEntries' | 'timeBudget' | undefined; - - const listDir = async (dirPath: string, recursive: boolean) => { - if (abort.signal.aborted) return; - - if (Date.now() - startTime > timeBudgetMs) { - truncated = true; - truncateReason = 'timeBudget'; - return; - } - - if (entries.length >= maxEntries) { - truncated = true; - truncateReason = 'maxEntries'; - return; - } - - let items; - try { - items = await fs.readdir(dirPath, { withFileTypes: true }); - } catch { - return; - } - - for (const item of items) { - if (abort.signal.aborted) return; - - if (entries.length % 100 === 0 && Date.now() - startTime > timeBudgetMs) { - truncated = true; - truncateReason = 'timeBudget'; - return; - } - - if (!options.includeHidden && item.name.startsWith('.')) { - continue; - } - - if (this.shouldIgnore(item.name)) { - continue; - } - - const itemPath = join(dirPath, item.name); - - try { - const stat = await fs.stat(itemPath); - const entry: FileEntry = { - path: this.relPath(itemPath), - type: item.isDirectory() ? 'dir' : 'file', - size: stat.size, - mtime: stat.mtime, - ctime: stat.ctime, - mode: stat.mode, - }; - - if (options.filter) { - const filterRegex = new RegExp(options.filter); - if (!filterRegex.test(item.name)) { - continue; - } - } - - entries.push(entry); - - if (entries.length >= maxEntries) { - truncated = true; - truncateReason = 'maxEntries'; - return; - } - - if (recursive && item.isDirectory()) { - await listDir(itemPath, true); - } - } catch { - // Skip entries we can't stat - } - } - }; - - await listDir(fullPath, options.recursive || false); - - if (this.listAbort === abort) { - this.listAbort = null; - } - - return { - entries, - total: entries.length, - truncated, - truncateReason, - durationMs: Date.now() - startTime, - }; - } - - async read(path: string, maxBytes: number = 200 * 1024): Promise { - const fullPath = this.resolveWorkspacePath(path); - - let stat; - try { - stat = await fs.stat(fullPath); - } catch (err) { - log.error('Failed to stat file', { path, error: err }); - throw new FileSystemError(`File not found: ${path}`, FileSystemErrorCodes.NOT_FOUND, path); - } - - if (stat.isDirectory()) { - throw new FileSystemError( - `Path is a directory: ${path}`, - FileSystemErrorCodes.IS_DIRECTORY, - path - ); - } - - // Handle large files with truncation - if (stat.size > maxBytes) { - const fd = await fs.open(fullPath, 'r'); - try { - const buffer = Buffer.alloc(maxBytes); - await fd.read(buffer, 0, maxBytes, 0); - - return { - content: buffer.toString('utf-8'), - truncated: true, - totalSize: stat.size, - }; - } finally { - await fd.close(); - } - } - - const content = await fs.readFile(fullPath, 'utf-8'); - return { - content, - truncated: false, - totalSize: stat.size, - }; - } - - async write(path: string, content: string): Promise { - const fullPath = this.resolveWorkspacePath(path); - - // Ensure directory exists - const dir = dirname(fullPath); - try { - await fs.mkdir(dir, { recursive: true }); - } catch (err) { - log.error('Failed to create directory', { dir, error: err }); - throw new FileSystemError( - `Failed to create directory: ${dir}`, - FileSystemErrorCodes.PERMISSION_DENIED, - path - ); - } - - try { - await fs.writeFile(fullPath, content, 'utf-8'); - } catch (err) { - log.error('Failed to write file', { path, error: err }); - throw new FileSystemError( - `Failed to write file: ${path}`, - FileSystemErrorCodes.PERMISSION_DENIED, - path - ); - } - - const stat = await fs.stat(fullPath); - return { - success: true, - bytesWritten: stat.size, - }; - } - - async exists(path: string): Promise { - try { - await fs.access(this.resolveWorkspacePath(path)); - return true; - } catch { - return false; - } - } - - async stat(path: string): Promise { - try { - const fullPath = this.resolveWorkspacePath(path); - const stat = await fs.stat(fullPath); - return this.statToEntry(fullPath, stat); - } catch { - return null; - } - } - - async search(query: string, options: SearchOptions = {}): Promise { - const pattern = options.pattern || query; - const matches: SearchMatch[] = []; - const maxResults = options.maxResults || 10000; - const fileExtensions = options.fileExtensions; - const caseSensitive = options.caseSensitive ?? false; - - let filesSearched = 0; - let truncated = false; - - let gitIgnore: ReturnType | undefined; - try { - const gitIgnorePath = join(this.projectPath, '.gitignore'); - const content = await fs.readFile(gitIgnorePath, 'utf-8'); - gitIgnore = ignore().add(content); - } catch { - // Ignore error reading .gitignore - } - - const searchDir = async (dirPath: string) => { - let items; - try { - items = await fs.readdir(dirPath, { withFileTypes: true }); - } catch { - return; - } - - for (const item of items) { - if (matches.length >= maxResults) { - truncated = true; - return; - } - - const itemPath = join(dirPath, item.name); - - if (item.isDirectory()) { - const relPath = this.relPath(itemPath); - if (gitIgnore && gitIgnore.ignores(relPath)) { - continue; - } - - if (!this.shouldIgnore(item.name) && !item.name.startsWith('.')) { - await searchDir(itemPath); - } - } else if (item.isFile()) { - // Skip binary files - if (this.isBinaryFile(itemPath)) { - continue; - } - - // Check file extension filter - if (fileExtensions && fileExtensions.length > 0) { - const ext = extname(item.name).toLowerCase(); - if ( - !fileExtensions.some((e) => ext === e.toLowerCase() || ext === `.${e.toLowerCase()}`) - ) { - continue; - } - } - - // Check file pattern if specified - if (options.filePattern) { - const filePatternRegex = new RegExp(options.filePattern.replace(/\*/g, '.*')); - if (!filePatternRegex.test(item.name)) { - continue; - } - } - - filesSearched++; - - try { - const fileStream = createReadStream(itemPath, { encoding: 'utf-8' }); - const rl = createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - let lineNum = 0; - for await (const line of rl) { - lineNum++; - - // Check for null bytes (binary file indicator) - if (line.includes('\0')) { - fileStream.destroy(); - break; - } - - const matchResult = caseSensitive - ? line.includes(pattern) - : line.toLowerCase().includes(pattern.toLowerCase()); - - if (matchResult) { - const column = - (caseSensitive - ? line.indexOf(pattern) - : line.toLowerCase().indexOf(pattern.toLowerCase())) + 1; - - matches.push({ - filePath: this.relPath(itemPath), - line: lineNum, - column, - content: line.trim(), - preview: line.trim().substring(0, 200), - }); - - if (matches.length >= maxResults) { - fileStream.destroy(); - truncated = true; - return; - } - } - } - } catch { - // Skip files that can't be read - } - } - } - }; - - await searchDir(this.projectPath); - - return { - matches, - total: matches.length, - truncated, - filesSearched, - }; - } - - async remove( - path: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }> { - const fullPath = this.resolveWorkspacePath(path); - - let stat; - try { - stat = await fs.stat(fullPath); - } catch { - return { success: false, error: `File not found: ${path}` }; - } - - if (stat.isDirectory()) { - if (!options?.recursive) { - return { success: false, error: `Path is a directory: ${path}` }; - } - try { - await fs.rm(fullPath, { recursive: true, force: true }); - return { success: true }; - } catch (err: unknown) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - try { - await fs.unlink(fullPath); - return { success: true }; - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - // Attempt chmod retry on permission error - if (code === 'EACCES' || code === 'EPERM') { - try { - await fs.chmod(fullPath, 0o666); - await fs.unlink(fullPath); - return { success: true }; - } catch { - return { success: false, error: `Permission denied: ${path}` }; - } - } - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - async saveAttachment( - srcPath: string, - subdir?: string - ): Promise<{ - success: boolean; - absPath?: string; - relPath?: string; - fileName?: string; - error?: string; - }> { - const ALLOWED_ATTACHMENT_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.bmp', - '.svg', - ]); - - try { - try { - await fs.access(srcPath); - } catch { - return { success: false, error: 'Source file not found' }; - } - - const ext = extname(srcPath).toLowerCase(); - if (!ALLOWED_ATTACHMENT_EXTENSIONS.has(ext)) { - return { success: false, error: 'Unsupported attachment type' }; - } - - const destDir = join(this.projectPath, '.emdash', subdir ?? 'attachments'); - await fs.mkdir(destDir, { recursive: true }); - - const baseName = basename(srcPath); - let destName = baseName; - let counter = 1; - let destAbs = join(destDir, destName); - - while (true) { - try { - await fs.access(destAbs); - // File exists — try next name - const nameWithoutExt = basename(baseName, ext); - destName = `${nameWithoutExt}-${counter}${ext}`; - destAbs = join(destDir, destName); - counter++; - } catch { - // File does not exist — safe to write here - break; - } - } - - await fs.copyFile(srcPath, destAbs); - const relPath = relative(this.projectPath, destAbs); - return { success: true, absPath: destAbs, relPath, fileName: destName }; - } catch (err: unknown) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - async readImage(path: string): Promise<{ - success: boolean; - dataUrl?: string; - mimeType?: string; - size?: number; - error?: string; - }> { - const fullPath = this.resolveWorkspacePath(path); - - // Check file extension - const ext = extname(path).toLowerCase(); - if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { - return { - success: false, - error: `Unsupported image format: ${ext}. Allowed: ${Array.from(ALLOWED_IMAGE_EXTENSIONS).join(', ')}`, - }; - } - - let stat; - try { - stat = await fs.stat(fullPath); - } catch { - return { success: false, error: `Image not found: ${path}` }; - } - - if (stat.isDirectory()) { - return { success: false, error: `Path is a directory: ${path}` }; - } - - // Size limit for images (10MB) - const MAX_IMAGE_SIZE = 10 * 1024 * 1024; - if (stat.size > MAX_IMAGE_SIZE) { - return { - success: false, - error: `Image too large: ${stat.size} bytes (max ${MAX_IMAGE_SIZE})`, - }; - } - - try { - const buffer = await fs.readFile(fullPath); - const base64 = buffer.toString('base64'); - const mimeType = IMAGE_MIME_TYPES[ext] || 'application/octet-stream'; - const dataUrl = `data:${mimeType};base64,${base64}`; - - return { - success: true, - dataUrl, - mimeType, - size: stat.size, - }; - } catch (err: unknown) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - async mkdir(dirPath: string, options?: { recursive?: boolean }): Promise { - await fs.mkdir(this.resolveWorkspacePath(dirPath, { allowEmpty: true }), { - recursive: options?.recursive ?? false, - }); - } - - async realPath(path: string): Promise { - return fs.realpath(this.resolveWorkspacePath(path, { allowEmpty: true })); - } - - async glob(pattern: string, options?: { cwd?: string; dot?: boolean }): Promise { - const cwd = options?.cwd - ? this.resolveWorkspacePath(options.cwd, { allowEmpty: true }) - : this.projectPath; - return glob(pattern, { cwd, dot: options?.dot ?? false, absolute: false }); - } - - async copyFile(src: string, dest: string): Promise { - await fs.copyFile(this.resolveWorkspacePath(src), this.resolveWorkspacePath(dest)); - } - - async copyLocalFile(localAbsPath: string, destRelPath: string): Promise { - await fs.copyFile(localAbsPath, this.resolveWorkspacePath(destRelPath)); - } -} diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts deleted file mode 100644 index 80738329be..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { FileEntry, FileListResult } from '../types'; -import { SshFileSystem } from './ssh-fs'; - -type SftpMkdirError = Error & { code?: number }; -type SftpItem = { - filename: string; - attrs: { - isDirectory: () => boolean; - size: number; - mtime: number; - atime: number; - mode: number; - }; -}; - -function listResult(entries: FileEntry[]): FileListResult { - return { entries, total: entries.length }; -} - -function fileEntry(path: string, mtimeMs: number, size = 1): FileEntry { - return { - path, - type: 'file', - size, - mtime: new Date(mtimeMs), - mode: 0o100644, - }; -} - -function makeMkdirFs(errors: Array) { - const mkdirCalls: string[] = []; - const sftp = { - on: vi.fn(), - mkdir: vi.fn((dirPath: string, callback: (error?: SftpMkdirError) => void) => { - mkdirCalls.push(dirPath); - callback(errors.shift()); - }), - }; - const proxy = { - sftp: vi.fn((callback: (error: Error | undefined, sftp: unknown) => void) => { - callback(undefined, sftp); - }), - }; - - return { - fs: new SshFileSystem(proxy as never, '/repo'), - mkdirCalls, - }; -} - -function makeListFs(rootPath: string, entriesByPath: Record) { - const sftp = { - on: vi.fn(), - readdir: vi.fn( - (dirPath: string, callback: (error: Error | null, items: SftpItem[]) => void) => { - callback(null, entriesByPath[dirPath] ?? []); - } - ), - }; - const proxy = { - sftp: vi.fn((callback: (error: Error | undefined, sftp: unknown) => void) => { - callback(undefined, sftp); - }), - }; - - return { - fs: new SshFileSystem(proxy as never, rootPath), - readdir: sftp.readdir, - }; -} - -function sftpItem(filename: string, type: 'file' | 'dir'): SftpItem { - return { - filename, - attrs: { - isDirectory: () => type === 'dir', - size: type === 'dir' ? 0 : 1, - mtime: 1, - atime: 1, - mode: type === 'dir' ? 0o040755 : 0o100644, - }, - }; -} - -describe('SshFileSystem.mkdir', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('treats lowercase file exists as idempotent during recursive mkdir', async () => { - const { fs } = makeMkdirFs([new Error('file exists')]); - - await expect(fs.mkdir('existing', { recursive: true })).resolves.toBeUndefined(); - }); - - it('treats uppercase File exists as idempotent during recursive mkdir', async () => { - const { fs } = makeMkdirFs([new Error('File exists')]); - - await expect(fs.mkdir('existing', { recursive: true })).resolves.toBeUndefined(); - }); - - it('rejects non-EEXIST errors during recursive mkdir', async () => { - const { fs } = makeMkdirFs([new Error('Permission denied')]); - - await expect(fs.mkdir('denied', { recursive: true })).rejects.toThrow('Permission denied'); - }); - - it('creates missing parents when SFTP reports lowercase no such file', async () => { - const { fs, mkdirCalls } = makeMkdirFs([new Error('no such file'), undefined, undefined]); - - await expect(fs.mkdir('parent/child', { recursive: true })).resolves.toBeUndefined(); - expect(mkdirCalls).toEqual(['/repo/parent/child', '/repo/parent', '/repo/parent/child']); - }); -}); - -describe('SshFileSystem.list', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('returns relative paths when the remote root is /', async () => { - const { fs } = makeListFs('/', { - '/': [sftpItem('repo', 'dir')], - }); - - await expect(fs.list('', { includeHidden: true })).resolves.toMatchObject({ - entries: [{ path: 'repo', type: 'dir' }], - }); - }); - - it('returns relative nested paths when the remote root is /', async () => { - const { fs } = makeListFs('/', { - '/repo': [sftpItem('src', 'dir')], - }); - - await expect(fs.list('repo', { includeHidden: true })).resolves.toMatchObject({ - entries: [{ path: 'repo/src', type: 'dir' }], - }); - }); - - it('returns relative paths under a trailing-slash remote root', async () => { - const { fs } = makeListFs('/repo/', { - '/repo/src': [sftpItem('index.ts', 'file')], - }); - - await expect(fs.list('src', { includeHidden: true })).resolves.toMatchObject({ - entries: [{ path: 'src/index.ts', type: 'file' }], - }); - }); -}); - -describe('SshFileSystem.watch', () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('emits modify events when an existing polled file changes metadata', async () => { - vi.useFakeTimers(); - - const fs = new SshFileSystem({} as never, '/repo'); - vi.spyOn(fs, 'list') - .mockResolvedValueOnce(listResult([fileEntry('notes.md', 1_000)])) - .mockResolvedValueOnce(listResult([fileEntry('notes.md', 2_000)])); - - const events: Array<{ type: string; entryType: string; path: string }> = []; - const watcher = fs.watch((batch) => events.push(...batch), { debounceMs: 10 }); - watcher.update(['']); - - await vi.advanceTimersByTimeAsync(10); - expect(events).toEqual([]); - - await vi.advanceTimersByTimeAsync(10); - expect(events).toEqual([{ type: 'modify', entryType: 'file', path: 'notes.md' }]); - - watcher.close(); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts deleted file mode 100644 index 103f06cb02..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts +++ /dev/null @@ -1,1044 +0,0 @@ -/** - * Remote FileSystem implementation - * Uses SFTP over SSH for remote filesystem operations - */ - -import type { SFTPWrapper } from 'ssh2'; -import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile'; -import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; -import { log } from '@main/lib/logger'; -import { quoteShellArg } from '@main/utils/shellEscape'; -import type { FileWatchEvent } from '@shared/core/fs/fs'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileEntry, - type FileListResult, - type FileSystemProvider, - type FileWatcher, - type ListOptions, - type ReadResult, - type SearchMatch, - type SearchOptions, - type SearchResult, - type WriteResult, -} from '../types'; - -const SFTP_STATUS = { - NO_SUCH_FILE: 2, - PERMISSION_DENIED: 3, - FAILURE: 4, -} as const; - -interface SftpError extends Error { - code?: number; -} - -/** - * Allowed image extensions for readImage - */ -const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']; - -/** - * Maximum file size for reading (100MB to prevent memory issues) - */ -const MAX_READ_SIZE = 100 * 1024 * 1024; - -/** - * Default max bytes for read operations - */ -const DEFAULT_MAX_BYTES = 200 * 1024; - -function fileEntryMetadataChanged(prev: FileEntry, next: FileEntry): boolean { - return ( - prev.type !== next.type || - prev.size !== next.size || - prev.mode !== next.mode || - prev.mtime?.getTime() !== next.mtime?.getTime() - ); -} - -/** - * Legacy SSH `FileSystemProvider` implementation using SFTP/SSH exec. - * - * This remains active for non-tree file operations and transitional SSH - * adapters. The editor file tree uses `LegacySshFilesRuntime` only as a - * temporary bridge until the `@emdash/core` file-tree runtime can run where the - * remote workspace lives. - */ -export class SshFileSystem implements FileSystemProvider { - private cachedSftp: SFTPWrapper | undefined; - - constructor( - private readonly proxy: SshClientProxy, - private readonly remotePath: string - ) { - if (!remotePath) { - throw new FileSystemError('Remote path is required', FileSystemErrorCodes.INVALID_PATH); - } - // Normalize remote path to use forward slashes - this.remotePath = remotePath.replace(/\\/g, '/'); - } - - // ─── Private helpers ────────────────────────────────────────────────────── - - private getSftp(): Promise { - if (this.cachedSftp) return Promise.resolve(this.cachedSftp); - return new Promise((resolve, reject) => { - this.proxy.sftp((err, sftp) => { - if (err) return reject(err); - this.cachedSftp = sftp; - sftp.on('close', () => { - this.cachedSftp = undefined; - }); - resolve(sftp); - }); - }); - } - - private async exec( - command: string - ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const profile = await this.proxy.getRemoteShellProfile(); - const full = buildRemoteShellCommand(profile, command); - return new Promise((resolve, reject) => { - this.proxy.exec(full, (err, stream) => { - if (err) return reject(err); - let stdout = ''; - let stderr = ''; - stream.on('close', (code: number | null) => { - resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? -1 }); - }); - stream.on('data', (d: Buffer) => { - stdout += d.toString('utf-8'); - }); - stream.stderr.on('data', (d: Buffer) => { - stderr += d.toString('utf-8'); - }); - stream.on('error', reject); - }); - }); - } - - // ─── IFileSystem ────────────────────────────────────────────────────────── - - /** - * List directory contents via SFTP - */ - async list(path: string = '', options?: ListOptions): Promise { - const startTime = Date.now(); - const fullPath = this.resolveRemotePath(path); - const sftp = await this.getSftp(); - - return new Promise((resolve, reject) => { - sftp.readdir(fullPath, (err, list) => { - if (err) { - reject(this.mapSftpError(err, fullPath)); - return; - } - - const entries: FileEntry[] = []; - const seen = new Set(); - - for (const item of list) { - // Skip hidden files if not included - if (!options?.includeHidden && item.filename.startsWith('.')) { - continue; - } - - // Apply filter if provided - if (options?.filter) { - const filterRegex = new RegExp(options.filter); - if (!filterRegex.test(item.filename)) { - continue; - } - } - - const entryPath = this.relativePath(`${fullPath}/${item.filename}`); - if (seen.has(entryPath)) { - continue; - } - seen.add(entryPath); - - const entry: FileEntry = { - path: entryPath, - type: item.attrs.isDirectory() ? 'dir' : 'file', - size: item.attrs.size, - mtime: new Date(item.attrs.mtime * 1000), - ctime: new Date(item.attrs.atime * 1000), - mode: item.attrs.mode, - }; - - entries.push(entry); - - // Handle recursive listing - if (options?.recursive && item.attrs.isDirectory()) { - // Note: Recursive listing is async and needs special handling - // For now, we note that full recursive support requires additional implementation - } - } - - // Sort entries: directories first, then files, both alphabetically - entries.sort((a, b) => { - if (a.type === b.type) { - return a.path.localeCompare(b.path); - } - return a.type === 'dir' ? -1 : 1; - }); - - let result = entries; - let truncated = false; - let truncateReason: 'maxEntries' | 'timeBudget' | undefined; - - // Apply maxEntries limit - if (options?.maxEntries && entries.length > options.maxEntries) { - result = entries.slice(0, options.maxEntries); - truncated = true; - truncateReason = 'maxEntries'; - } - - // Apply time budget - const durationMs = Date.now() - startTime; - if (options?.timeBudgetMs && durationMs > options.timeBudgetMs) { - truncated = true; - truncateReason = 'timeBudget'; - } - - resolve({ - entries: result, - total: entries.length, - truncated, - truncateReason, - durationMs, - }); - }); - }); - } - - /** - * Read file contents via SFTP - * Handles large files by respecting maxBytes limit - */ - async read(path: string, maxBytes: number = DEFAULT_MAX_BYTES): Promise { - const fullPath = this.resolveRemotePath(path); - const sftp = await this.getSftp(); - - return new Promise((resolve, reject) => { - sftp.open(fullPath, 'r', (err, handle) => { - if (err) { - reject(this.mapSftpError(err, fullPath)); - return; - } - - sftp.fstat(handle, (statErr, stats) => { - if (statErr) { - sftp.close(handle, () => {}); - reject(this.mapSftpError(statErr, fullPath)); - return; - } - - // Check if it's a directory - if (stats.isDirectory()) { - sftp.close(handle, () => {}); - reject( - new FileSystemError( - `Path is a directory: ${path}`, - FileSystemErrorCodes.IS_DIRECTORY, - path - ) - ); - return; - } - - const fileSize = stats.size; - const readSize = Math.min(fileSize, maxBytes, MAX_READ_SIZE); - - if (readSize === 0) { - sftp.close(handle, () => {}); - resolve({ content: '', truncated: false, totalSize: fileSize }); - return; - } - - const buffer = Buffer.alloc(readSize); - - sftp.read(handle, buffer, 0, readSize, 0, (readErr, bytesRead) => { - sftp.close(handle, () => {}); - - if (readErr) { - reject(this.mapSftpError(readErr, fullPath)); - return; - } - - // Convert buffer to string, handling only the bytes actually read - const content = buffer.subarray(0, bytesRead).toString('utf-8'); - - resolve({ - content, - truncated: fileSize > maxBytes, - totalSize: fileSize, - }); - }); - }); - }); - }); - } - - /** - * Write file contents via SFTP - * Creates parent directories recursively if needed - */ - async write(path: string, content: string): Promise { - const fullPath = this.resolveRemotePath(path); - const sftp = await this.getSftp(); - - // Ensure parent directory exists - const lastSlash = fullPath.lastIndexOf('/'); - if (lastSlash > 0) { - const parentDir = fullPath.substring(0, lastSlash); - await this.ensureRemoteDir(sftp, parentDir); - } - - return new Promise((resolve, reject) => { - sftp.open(fullPath, 'w', (err, handle) => { - if (err) { - reject(this.mapSftpError(err, fullPath)); - return; - } - - const buffer = Buffer.from(content, 'utf-8'); - - if (buffer.length === 0) { - sftp.close(handle, (closeErr) => { - if (closeErr) { - reject(this.mapSftpError(closeErr, fullPath)); - return; - } - resolve({ success: true, bytesWritten: 0 }); - }); - return; - } - - sftp.write(handle, buffer, 0, buffer.length, 0, (writeErr) => { - sftp.close(handle, (closeErr) => { - if (writeErr) { - reject(this.mapSftpError(writeErr, fullPath)); - return; - } - - if (closeErr) { - reject(this.mapSftpError(closeErr, fullPath)); - return; - } - - resolve({ - success: true, - bytesWritten: buffer.length, - }); - }); - }); - }); - }); - } - - /** - * Recursively list all files and directories via SSH find (single round-trip). - * Returns items in the same {path, type} format used by the local fs:list handler. - */ - async listRecursive(options?: { includeDirs?: boolean; maxEntries?: number }): Promise<{ - items: Array<{ path: string; type: 'file' | 'dir' }>; - truncated: boolean; - }> { - const includeDirs = options?.includeDirs ?? true; - const maxEntries = options?.maxEntries ?? 5000; - - // Directories to prune from the listing - const pruneNames = [ - '.git', - 'node_modules', - 'dist', - 'build', - '.next', - 'out', - '.turbo', - 'coverage', - '.nyc_output', - '.cache', - 'tmp', - 'temp', - '__pycache__', - '.pytest_cache', - 'venv', - '.venv', - 'target', - '.terraform', - '.serverless', - 'vendor', - 'bower_components', - 'worktrees', - '.worktrees', - '.DS_Store', - ]; - - // Build prune clause for find (names are hardcoded, but escape for safety) - const pruneExpr = pruneNames.map((name) => `-name ${quoteShellArg(name)}`).join(' -o '); - - // Build find command: prune ignored dirs, print files (and optionally dirs) - const typeFilter = includeDirs ? '' : '-type f'; - const command = [ - `find ${quoteShellArg(this.remotePath)}`, - `\\( ${pruneExpr} \\) -prune -o`, - typeFilter ? `${typeFilter} -print` : '-print', - `2>/dev/null`, - `| head -n ${maxEntries + 1}`, - ] - .filter(Boolean) - .join(' '); - - try { - const result = await this.exec(command); - - const lines = result.stdout.split('\n').filter((line) => line.trim()); - - // Check if we exceeded maxEntries (we asked for maxEntries+1 to detect truncation) - const truncated = lines.length > maxEntries; - const effectiveLines = truncated ? lines.slice(0, maxEntries) : lines; - - const items: Array<{ path: string; type: 'file' | 'dir' }> = []; - - for (const line of effectiveLines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - // Skip the root path itself - if (trimmed === this.remotePath || trimmed === this.remotePath + '/') continue; - - const relPath = this.relativePath(trimmed); - if (!relPath) continue; - - // Determine type: find outputs directories with trailing / when using -print, - // but standard find doesn't. We'll use a heuristic: if any other entry starts - // with this path + '/', it's a directory. For efficiency, detect trailing slash. - const isDir = trimmed.endsWith('/'); - const cleanRel = relPath.replace(/\/$/, ''); - - if (!cleanRel) continue; - - items.push({ - path: cleanRel, - type: isDir ? 'dir' : 'file', - }); - } - - // Since `find` doesn't always indicate directories clearly with just -print, - // we do a second pass: any path that is a prefix of another path is a directory. - const pathSet = new Set(items.map((i) => i.path)); - for (const item of items) { - if (item.type === 'file') { - // Check if any other path starts with this path + '/' - const prefix = item.path + '/'; - for (const otherPath of pathSet) { - if (otherPath.startsWith(prefix)) { - item.type = 'dir'; - break; - } - } - } - } - - // Filter out dirs if not requested - const finalItems = includeDirs ? items : items.filter((i) => i.type === 'file'); - - return { items: finalItems, truncated }; - } catch { - return { items: [], truncated: false }; - } - } - - /** - * Check if a path exists via SFTP - */ - async exists(path: string): Promise { - try { - const entry = await this.stat(path); - return entry !== null; - } catch { - return false; - } - } - - async mkdir(dirPath: string, options?: { recursive?: boolean }): Promise { - const fullPath = this.resolveRemotePath(dirPath); - const sftp = await this.getSftp(); - if (options?.recursive) { - await this.ensureRemoteDir(sftp, fullPath); - } else { - await new Promise((resolve, reject) => { - sftp.mkdir(fullPath, (err) => (err ? reject(this.mapSftpError(err, fullPath)) : resolve())); - }); - } - } - - async realPath(path: string): Promise { - const fullPath = this.resolveRemotePath(path); - const result = await this.exec(`realpath ${quoteShellArg(fullPath)}`); - if (result.exitCode !== 0) { - throw new Error(`realpath failed: ${result.stderr}`); - } - return result.stdout.trim(); - } - - async glob(pattern: string, options?: { cwd?: string; dot?: boolean }): Promise { - const cwd = options?.cwd ? this.resolveRemotePath(options.cwd) : this.remotePath; - const dotSetup = options?.dot ? 'shopt -s dotglob;' : ''; - const command = `${dotSetup} shopt -s nullglob; cd ${quoteShellArg(cwd)} && printf '%s\\n' ${pattern}`; - try { - const result = await this.exec(command); - if (result.exitCode !== 0) return []; - return result.stdout.trim().split('\n').filter(Boolean); - } catch { - return []; - } - } - - async copyLocalFile(localAbsPath: string, destRelPath: string): Promise { - const sftp = await this.getSftp(); - const remoteFull = this.resolveRemotePath(destRelPath); - await new Promise((resolve, reject) => { - sftp.fastPut(localAbsPath, remoteFull, (e) => (e ? reject(e) : resolve())); - }); - } - - async copyFile(src: string, dest: string): Promise { - const fullSrc = this.resolveRemotePath(src); - const fullDest = this.resolveRemotePath(dest); - const result = await this.exec(`cp ${quoteShellArg(fullSrc)} ${quoteShellArg(fullDest)}`); - if (result.exitCode !== 0) { - throw new Error(`Failed to copy file: ${result.stderr}`); - } - } - - /** - * Get file/directory metadata via SFTP - */ - async stat(path: string): Promise { - const fullPath = this.resolveRemotePath(path); - const sftp = await this.getSftp(); - - return new Promise((resolve, reject) => { - sftp.stat(fullPath, (err, stats) => { - if (err) { - // Check if file doesn't exist - const sftpErr = err as SftpError; - if ( - sftpErr.message?.includes('No such file') || - sftpErr.code === SFTP_STATUS.NO_SUCH_FILE - ) { - resolve(null); - return; - } - reject(this.mapSftpError(err, fullPath)); - return; - } - - resolve({ - path, - type: stats.isDirectory() ? 'dir' : 'file', - size: stats.size, - mtime: new Date(stats.mtime * 1000), - ctime: new Date(stats.atime * 1000), - mode: stats.mode, - }); - }); - }); - } - - /** - * Search for content in files via SSH exec (grep) - * Uses grep on the remote host for better performance on large codebases - */ - async search(query: string, options?: SearchOptions): Promise { - const searchPattern = options?.pattern || query; - const basePath = this.remotePath; - const maxResults = options?.maxResults || 10000; - const caseFlag = options?.caseSensitive ? '' : '-i'; - - // Build grep command with shell-safe escaping - const escapedPattern = quoteShellArg(searchPattern); - - // Build file extension filter if provided - let includeFilter = ''; - if (options?.fileExtensions && options.fileExtensions.length > 0) { - const extensions = options.fileExtensions.map((ext) => - ext.startsWith('.') ? ext : `.${ext}` - ); - includeFilter = extensions.map((e) => `--include=${quoteShellArg(`*${e}`)}`).join(' '); - } - - // Use grep recursively with line numbers - const command = `grep -rn ${caseFlag} ${includeFilter} -e ${escapedPattern} ${quoteShellArg(basePath)} 2>/dev/null | head -n ${maxResults}`; - - try { - const result = await this.exec(command); - - // If grep returns non-zero exit but no stderr, it just means no matches - if (result.exitCode !== 0 && result.exitCode !== 1) { - // grep exit code 1 means no matches found, which is fine - return { matches: [], total: 0, filesSearched: 0 }; - } - - const matches: SearchMatch[] = []; - const lines = result.stdout.split('\n').filter((line) => line.trim()); - const seenFiles = new Set(); - - for (const line of lines) { - // Parse grep output format: path:line:content - const firstColon = line.indexOf(':'); - if (firstColon === -1) continue; - - const filePath = line.substring(0, firstColon); - const rest = line.substring(firstColon + 1); - - const secondColon = rest.indexOf(':'); - if (secondColon === -1) continue; - - const lineNum = parseInt(rest.substring(0, secondColon), 10); - const content = rest.substring(secondColon + 1); - - if (isNaN(lineNum)) continue; - - const relPath = this.relativePath(filePath); - - // Apply file pattern filter if provided - if (options?.filePattern) { - const patternRegex = new RegExp(options.filePattern); - if (!patternRegex.test(relPath)) { - continue; - } - } - - seenFiles.add(filePath); - - // Find column by searching for the pattern in the content - const searchPat = options?.caseSensitive ? searchPattern : searchPattern.toLowerCase(); - const column = content.indexOf(searchPat) + 1; - - matches.push({ - filePath: relPath, - line: lineNum, - column: column > 0 ? column : 1, - content: content.trim(), - preview: content.trim(), - }); - } - - return { - matches, - total: matches.length, - truncated: lines.length >= maxResults, - filesSearched: seenFiles.size, - }; - } catch (error) { - log.error('Failed to search', { query, options, error }); - // If command execution fails, return empty results - return { matches: [], total: 0, filesSearched: 0 }; - } - } - - /** - * Remove a file via SFTP - * For directories, uses SSH exec with rm -rf - */ - async remove( - path: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }> { - const fullPath = this.resolveRemotePath(path); - - try { - const entry = await this.stat(path); - - if (!entry) { - return { success: false, error: `File not found: ${path}` }; - } - - const sftp = await this.getSftp(); - - if (entry.type === 'dir') { - if (!options?.recursive) { - return { success: false, error: `Path is a directory: ${path}` }; - } - const command = `rm -rf ${quoteShellArg(fullPath)}`; - const result = await this.exec(command); - - if (result.exitCode !== 0) { - return { success: false, error: result.stderr || 'Failed to remove directory' }; - } - } else { - // For files, use SFTP unlink - return new Promise((resolve) => { - sftp.unlink(fullPath, (err) => { - if (err) { - resolve({ success: false, error: err.message }); - } else { - resolve({ success: true }); - } - }); - }); - } - - return { success: true }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { success: false, error: message }; - } - } - - /** - * Read image file as base64 data URL via SFTP - */ - async readImage(path: string): Promise<{ - success: boolean; - dataUrl?: string; - mimeType?: string; - size?: number; - error?: string; - }> { - // Check file extension - const ext = path.toLowerCase().substring(path.lastIndexOf('.')); - if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) { - return { - success: false, - error: `Unsupported image format: ${ext}`, - }; - } - - const fullPath = this.resolveRemotePath(path); - const sftp = await this.getSftp(); - - return new Promise((resolve, reject) => { - sftp.open(fullPath, 'r', (err, handle) => { - if (err) { - reject(this.mapSftpError(err, fullPath)); - return; - } - - sftp.fstat(handle, (statErr, stats) => { - if (statErr) { - sftp.close(handle, () => {}); - reject(this.mapSftpError(statErr, fullPath)); - return; - } - - // Check file size limit (5MB for images) - const maxImageSize = 5 * 1024 * 1024; - if (stats.size > maxImageSize) { - sftp.close(handle, () => {}); - resolve({ - success: false, - error: `Image too large: ${stats.size} bytes (max ${maxImageSize})`, - }); - return; - } - - if (stats.size === 0) { - sftp.close(handle, () => {}); - resolve({ success: false, error: 'Image file is empty' }); - return; - } - - const buffer = Buffer.alloc(stats.size); - - sftp.read(handle, buffer, 0, stats.size, 0, (readErr) => { - sftp.close(handle, () => {}); - - if (readErr) { - reject(this.mapSftpError(readErr, fullPath)); - return; - } - - // Determine MIME type from extension - const mimeTypes: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - }; - const mimeType = mimeTypes[ext] || 'application/octet-stream'; - - // Convert to base64 - const base64 = buffer.toString('base64'); - const dataUrl = `data:${mimeType};base64,${base64}`; - - resolve({ - success: true, - dataUrl, - mimeType, - size: stats.size, - }); - }); - }); - }); - }); - } - - // ─── Private utilities ──────────────────────────────────────────────────── - - /** - * Build absolute remote path from relative path - * Provides path traversal protection - */ - private resolveRemotePath(relPath: string): string { - // Normalize path separators to forward slashes - const normalized = relPath.replace(/\\/g, '/'); - - // Handle absolute paths (should not escape base) - if (normalized.startsWith('/')) { - const resolved = this.normalizePosixPath(normalized); - // Security: ensure resolved path is within remotePath base - if (!this.isWithinBase(resolved)) { - throw new FileSystemError( - 'Path traversal detected: path escapes base directory', - FileSystemErrorCodes.PATH_ESCAPE, - relPath - ); - } - return resolved; - } - - // Join with base path and normalize away any '.' segments (e.g. when relPath is '.') - const joined = `${this.remotePath}/${normalized}`.replace(/\/+/g, '/'); - const fullPath = this.normalizePosixPath(joined); - - // Security: ensure path is within basePath - if (!this.isWithinBase(fullPath)) { - throw new FileSystemError( - 'Path traversal detected: path escapes base directory', - FileSystemErrorCodes.PATH_ESCAPE, - relPath - ); - } - - return fullPath; - } - - /** Remove single-dot segments from a POSIX path (e.g. /a/./b → /a/b). */ - private normalizePosixPath(p: string): string { - const parts = p.split('/'); - const out: string[] = []; - for (const seg of parts) { - if (seg === '.') continue; - out.push(seg); - } - // Re-join and collapse any double slashes introduced by the filter - return out.join('/').replace(/\/+/g, '/') || '/'; - } - - /** - * Check if a path is within the base directory - */ - private isWithinBase(fullPath: string): boolean { - // Normalize both paths - const normalizedPath = fullPath.replace(/\/+/g, '/').replace(/\/$/, ''); - const normalizedBase = this.remotePath.replace(/\/+/g, '/').replace(/\/$/, ''); - - // Path must start with base path - return normalizedPath === normalizedBase || normalizedPath.startsWith(`${normalizedBase}/`); - } - - /** - * Get relative path from full remote path - */ - private relativePath(fullPath: string): string { - const normalized = fullPath.replace(/\\/g, '/').replace(/\/+/g, '/'); - const normalizedBase = normalizeRemoteBasePath(this.remotePath); - - if (normalized === normalizedBase) { - return ''; - } - - const prefix = normalizedBase === '/' ? '/' : `${normalizedBase}/`; - if (normalized.startsWith(prefix)) { - return normalized.substring(prefix.length); - } - - return normalized; - } - - /** - * Recursively ensure a remote directory exists - */ - private async ensureRemoteDir(sftp: SFTPWrapper, dirPath: string): Promise { - return new Promise((resolve, reject) => { - sftp.mkdir(dirPath, (err) => { - if (!err) { - resolve(); - return; - } - - const sftpErr = err as SftpError; - const msg = sftpErr.message ?? ''; - const lowerMsg = msg.toLowerCase(); - const code = sftpErr.code; - - const isAlreadyExists = - lowerMsg.includes('already exists') || - lowerMsg.includes('file exists') || - (code === SFTP_STATUS.FAILURE && (msg === 'Failure' || msg === '')); - const isMissingParent = - code === SFTP_STATUS.NO_SUCH_FILE || lowerMsg.includes('no such file'); - - if (isAlreadyExists) { - resolve(); - return; - } - - const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/')); - if ( - isMissingParent && - parentPath && - parentPath !== dirPath && - parentPath.length >= this.remotePath.length - ) { - this.ensureRemoteDir(sftp, parentPath) - .then(() => this.ensureRemoteDir(sftp, dirPath)) - .then(resolve) - .catch(reject); - } else { - reject(this.mapSftpError(err, dirPath)); - } - }); - }); - } - - /** - * Map SFTP error codes to FileSystemError - */ - private mapSftpError(error: unknown, path?: string): FileSystemError { - const sftpErr = error as SftpError; - const message = typeof sftpErr?.message === 'string' ? sftpErr.message : String(error); - const code = sftpErr?.code; - - // Map common SFTP error codes - if (code === SFTP_STATUS.NO_SUCH_FILE || message.includes('No such file')) { - return new FileSystemError( - `File or directory not found: ${path || message}`, - FileSystemErrorCodes.NOT_FOUND, - path - ); - } - - if (code === SFTP_STATUS.PERMISSION_DENIED || message.includes('Permission denied')) { - return new FileSystemError( - `Permission denied: ${path || message}`, - FileSystemErrorCodes.PERMISSION_DENIED, - path - ); - } - - if (message.includes('is a directory')) { - return new FileSystemError( - `Path is a directory: ${path || message}`, - FileSystemErrorCodes.IS_DIRECTORY, - path - ); - } - - if (message.includes('Not a directory')) { - return new FileSystemError( - `Path is not a directory: ${path || message}`, - FileSystemErrorCodes.NOT_DIRECTORY, - path - ); - } - - if (message.includes('connection') || message.includes('Connection')) { - return new FileSystemError( - `Connection error: ${message}`, - FileSystemErrorCodes.CONNECTION_ERROR, - path - ); - } - - // Default to unknown error - return new FileSystemError(`Filesystem error: ${message}`, FileSystemErrorCodes.UNKNOWN, path); - } - - watch( - callback: (events: FileWatchEvent[]) => void, - options: { debounceMs?: number } = {} - ): FileWatcher { - const interval = options.debounceMs ?? 4000; - let watched: string[] = []; - // Map from dirPath → previous entries (keyed by relative entry path) - const snapshots = new Map>(); - - const poll = async () => { - for (const dirPath of watched) { - let result: FileListResult | null = null; - try { - result = await this.list(dirPath, { includeHidden: true }); - } catch { - continue; - } - - const currMap = new Map(result.entries.map((e) => [e.path, e])); - const prevMap = snapshots.get(dirPath); - snapshots.set(dirPath, currMap); - - if (!prevMap) continue; - - const evts: FileWatchEvent[] = []; - for (const [p, e] of currMap) { - const prev = prevMap.get(p); - if (!prev) - evts.push({ - type: 'create', - entryType: e.type === 'dir' ? 'directory' : 'file', - path: p, - }); - else if (fileEntryMetadataChanged(prev, e)) - evts.push({ - type: 'modify', - entryType: e.type === 'dir' ? 'directory' : 'file', - path: p, - }); - } - for (const [p, e] of prevMap) { - if (!currMap.has(p)) - evts.push({ - type: 'delete', - entryType: e.type === 'dir' ? 'directory' : 'file', - path: p, - }); - } - if (evts.length) callback(evts); - } - }; - - const timer = setInterval(() => { - void poll(); - }, interval); - - return { - update(paths: string[]) { - watched = paths; - for (const p of snapshots.keys()) { - if (!paths.includes(p)) snapshots.delete(p); - } - }, - close() { - clearInterval(timer); - }, - }; - } -} - -function normalizeRemoteBasePath(path: string): string { - const normalized = path.replace(/\\/g, '/').replace(/\/+/g, '/'); - if (normalized === '/') return '/'; - return normalized.replace(/\/$/, ''); -} diff --git a/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts b/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts deleted file mode 100644 index a3f76603cb..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { - FileEntry, - FileListResult, - FileSystemProvider, - ReadResult, - SearchResult, - WriteResult, -} from '../types'; - -export class MemoryFs implements FileSystemProvider { - readonly files = new Map(); - - async list(): Promise { - return { - entries: Array.from(this.files.keys()).map((path) => ({ path, type: 'file' as const })), - total: this.files.size, - }; - } - - async exists(path: string): Promise { - return this.files.has(path); - } - - async read(path: string): Promise { - const content = this.files.get(path); - if (content === undefined) { - throw new Error(`not found: ${path}`); - } - return { - content, - truncated: false, - totalSize: Buffer.byteLength(content), - }; - } - - async write(path: string, content: string): Promise { - this.files.set(path, content); - return { - success: true, - bytesWritten: Buffer.byteLength(content), - }; - } - - async stat(path: string): Promise { - const content = this.files.get(path); - if (content === undefined) return null; - return { - path, - type: 'file', - size: Buffer.byteLength(content), - }; - } - - async search(): Promise { - return { - matches: [], - total: 0, - }; - } - - async remove(path: string): Promise<{ success: boolean; error?: string }> { - this.files.delete(path); - return { success: true }; - } - - async realPath(path: string): Promise { - return path; - } - - async glob(): Promise { - return Array.from(this.files.keys()); - } - - async copyFile(src: string, dest: string): Promise { - const content = this.files.get(src); - if (content === undefined) throw new Error(`not found: ${src}`); - this.files.set(dest, content); - } - - async mkdir(): Promise {} -} diff --git a/apps/emdash-desktop/src/main/core/fs/types.ts b/apps/emdash-desktop/src/main/core/fs/types.ts deleted file mode 100644 index 42ad0caaff..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/types.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Filesystem abstraction layer types - * Provides unified interface for local and remote (SSH/SFTP) filesystem operations - */ - -/** - * Transitional SSH polling watcher handle. - * - * Runtime-owned file change feeds use this internally for the temporary SSH - * adapter; the renderer-facing legacy watch RPC has been removed. - */ -export interface FileWatcher { - update(paths: string[]): void; - close(): void; -} - -/** - * File entry metadata returned by filesystem operations - */ -export interface FileEntry { - /** Relative path from the project root */ - path: string; - /** Entry type - file or directory */ - type: 'file' | 'dir'; - /** File size in bytes (files only) */ - size?: number; - /** Last modification time */ - mtime?: Date; - /** Creation time */ - ctime?: Date; - /** File permissions (Unix mode) */ - mode?: number; -} - -/** - * Options for listing directory contents - */ -export interface ListOptions { - /** Include entries from subdirectories recursively */ - recursive?: boolean; - /** Include hidden files (starting with .) */ - includeHidden?: boolean; - /** Filter pattern (glob or regex, implementation-dependent) */ - filter?: string; - /** Maximum number of entries to return */ - maxEntries?: number; - /** Time budget in milliseconds */ - timeBudgetMs?: number; -} - -/** - * Result of a list operation - */ -export interface FileListResult { - /** File and directory entries */ - entries: FileEntry[]; - /** Total number of entries found (may be more than entries.length if truncated) */ - total: number; - /** Whether the result was truncated due to limits */ - truncated?: boolean; - /** Reason for truncation if applicable */ - truncateReason?: 'maxEntries' | 'timeBudget'; - /** Duration of the operation in milliseconds */ - durationMs?: number; -} - -/** - * Result of a file read operation - */ -export interface ReadResult { - /** File content as string */ - content: string; - /** Whether the content was truncated due to maxBytes limit */ - truncated: boolean; - /** Total file size in bytes */ - totalSize: number; -} - -/** - * Result of a file write operation - */ -export interface WriteResult { - /** Whether the write was successful */ - success: boolean; - /** Number of bytes written */ - bytesWritten: number; - /** Error message if unsuccessful */ - error?: string; -} - -/** - * Options for search operations - */ -export interface SearchOptions { - /** - * Optional override pattern. If omitted, the `query` argument to `IFileSystem.search()` is used. - */ - pattern?: string; - /** Optional file pattern filter (e.g., "*.ts") */ - filePattern?: string; - /** Maximum number of results to return */ - maxResults?: number; - /** Case-sensitive search */ - caseSensitive?: boolean; - /** File extensions to include */ - fileExtensions?: string[]; -} - -/** - * Result of a search operation - */ -export interface SearchResult { - /** Search matches found */ - matches: SearchMatch[]; - /** Total number of matches */ - total: number; - /** Whether results were truncated */ - truncated?: boolean; - /** Number of files searched */ - filesSearched?: number; -} - -/** - * Individual search match - */ -export interface SearchMatch { - /** Path to the file containing the match */ - filePath: string; - /** Line number (1-based) */ - line: number; - /** Column number (1-based) */ - column: number; - /** Match text */ - content: string; - /** Preview with context */ - preview?: string; -} - -/** - * Legacy workspace filesystem abstraction. - * - * This provider remains active for non-tree workspace file operations - * (read/write/image/copy/search/config watches/project setup). Do not extend it - * for the editor file tree; file-tree reads, scopes, and deltas live in - * `@emdash/core/files` and are exposed through `workspace.fileTree`. - * - * Longer term this desktop-side provider should disappear behind filesystem APIs - * owned by `@emdash/core`. Those APIs should run where the workspace lives and - * call `node:fs` directly: desktop imports core directly for local projects, - * while the workspace server imports the same core API and exposes it to - * desktop for remote projects. - */ -export interface FileSystemProvider { - /** - * List directory contents - * @param path - Directory path relative to project root - * @param options - Listing options - * @returns Promise resolving to file list result - */ - list(path: string, options?: ListOptions): Promise; - - /** - * Read file contents - * @param path - File path relative to project root - * @param maxBytes - Maximum bytes to read (default: 200KB) - * @returns Promise resolving to read result - */ - read(path: string, maxBytes?: number): Promise; - - /** - * Write file contents - * @param path - File path relative to project root - * @param content - Content to write - * @returns Promise resolving to write result - */ - write(path: string, content: string): Promise; - - /** - * Check if a path exists - * @param path - Path to check relative to project root - * @returns Promise resolving to true if exists - */ - exists(path: string): Promise; - - /** - * Get file/directory metadata - * @param path - Path to stat relative to project root - * @returns Promise resolving to file entry or null if not found - */ - stat(path: string): Promise; - - /** - * Search for content in files - * @param query - Search query string - * @param options - Search options - * @returns Promise resolving to search results - */ - search(query: string, options?: SearchOptions): Promise; - - /** - * Remove a file or directory. - * @param path - Path relative to project root - * @param options - Pass `{ recursive: true }` to remove directories and all contents - * @returns Promise resolving to success status - */ - remove( - path: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }>; - - /** - * Resolve a path to its absolute, canonical form (resolving symlinks). - * @param path - Path relative to project root - * @returns Promise resolving to the absolute path - */ - realPath(path: string): Promise; - - /** - * Find files matching a glob pattern. - * @param pattern - Glob pattern (e.g., ".env", ".env.*.local") - * @param options - cwd: directory to search in; dot: include dotfiles (default false) - * @returns Relative paths of matching entries - */ - glob(pattern: string, options?: { cwd?: string; dot?: boolean }): Promise; - - /** - * Copy a file from src to dest (both paths relative to project root). - * Does not create parent directories — caller must ensure they exist. - * @param src - Source path - * @param dest - Destination path - */ - copyFile(src: string, dest: string): Promise; - - /** - * Read image file as base64 data URL - * @param path - Image file path relative to project root - * @returns Promise resolving to image data - */ - readImage?(path: string): Promise<{ - success: boolean; - dataUrl?: string; - mimeType?: string; - size?: number; - error?: string; - }>; - - /** - * Copy a local file into the project's .emdash attachments directory. - * Only supported on local filesystems (srcPath is an absolute local path). - * @param srcPath - Absolute local path of the source file - * @param subdir - Subdirectory inside .emdash/ (defaults to "attachments") - * @returns Promise resolving to the saved file paths - */ - saveAttachment?( - srcPath: string, - subdir?: string - ): Promise<{ - success: boolean; - absPath?: string; - relPath?: string; - fileName?: string; - error?: string; - }>; - - mkdir(diPath: string, options?: { recursive?: boolean }): Promise; - - /** - * Copy an absolute local file into this filesystem at the given relative path. - * Caller must ensure the destination parent directory exists. - * For SSH: transfers via SFTP fastPut. For local: delegates to fs.copyFile. - * @param localAbsPath - Absolute path of the source file on the local machine - * @param destRelPath - Destination path relative to this filesystem's root - */ - copyLocalFile?(localAbsPath: string, destRelPath: string): Promise; -} - -/** - * Base error class for filesystem operations - */ -export class FileSystemError extends Error { - constructor( - message: string, - public readonly code: string, - public readonly path?: string - ) { - super(message); - this.name = 'FileSystemError'; - } -} - -/** - * Error codes for filesystem operations - */ -export const FileSystemErrorCodes = { - PATH_ESCAPE: 'PATH_ESCAPE', - NOT_FOUND: 'NOT_FOUND', - IS_DIRECTORY: 'IS_DIRECTORY', - NOT_DIRECTORY: 'NOT_DIRECTORY', - PERMISSION_DENIED: 'PERMISSION_DENIED', - INVALID_PATH: 'INVALID_PATH', - CONNECTION_ERROR: 'CONNECTION_ERROR', - TIMEOUT: 'TIMEOUT', - UNKNOWN: 'UNKNOWN', -} as const; diff --git a/apps/emdash-desktop/src/main/core/ssh/controller.ts b/apps/emdash-desktop/src/main/core/ssh/controller.ts index d13da26460..3be1f4cc52 100644 --- a/apps/emdash-desktop/src/main/core/ssh/controller.ts +++ b/apps/emdash-desktop/src/main/core/ssh/controller.ts @@ -11,7 +11,6 @@ import { telemetryService } from '@main/lib/telemetry'; import type { ConnectionState, ConnectionTestResult, - FileEntry, SshConfig, SshConfigHost, SshConnectionUsage, @@ -224,60 +223,4 @@ export const sshController = createRPCController({ .set({ name, updatedAt: new Date().toISOString() }) .where(eq(sshConnectionsTable.id, id)); }, - - /** List files/directories at a remote path via SFTP. */ - listFiles: async ({ - connectionId, - path: remotePath, - }: { - connectionId: string; - path: string; - }): Promise => { - let proxy = sshConnectionManager.getProxy(connectionId); - - if (!proxy || !proxy.isConnected) { - proxy = await sshConnectionManager.connect(connectionId); - } - - return new Promise((resolve, reject) => { - proxy!.sftp((err, sftp) => { - if (err) { - reject(new Error(`SFTP error: ${err.message}`)); - return; - } - sftp.readdir(remotePath, (readdirErr, list) => { - sftp.end(); - if (readdirErr) { - reject(new Error(`readdir error: ${readdirErr.message}`)); - return; - } - const entries: FileEntry[] = list - .map((item) => { - const mode = item.attrs.mode ?? 0; - const isDir = (mode & 0o170000) === 0o040000; - const isLink = (mode & 0o170000) === 0o120000; - const entryType: FileEntry['type'] = isLink - ? 'symlink' - : isDir - ? 'directory' - : 'file'; - const fullPath = `${remotePath.replace(/\/$/, '')}/${item.filename}`; - return { - path: fullPath, - name: item.filename, - type: entryType, - size: item.attrs.size ?? 0, - modifiedAt: new Date((item.attrs.mtime ?? 0) * 1000), - }; - }) - .sort((a, b) => { - if (a.type === 'directory' && b.type !== 'directory') return -1; - if (a.type !== 'directory' && b.type === 'directory') return 1; - return a.name.localeCompare(b.name); - }); - resolve(entries); - }); - }); - }); - }, }); diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index 4faccceb96..fe2c2979c9 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -8,9 +8,10 @@ import { browserController } from './core/browser/controller'; import { conversationController } from './core/conversations/controller'; import { editorBufferController } from './core/editor/controller'; import { featurebaseController } from './core/featurebase/controller'; +import { machineFilesController } from './core/files/controller'; +import { workspaceFileSystemController } from './core/files/file-system/controller'; +import { fileTreeController } from './core/files/file-tree/controller'; import { forgejoController } from './core/forgejo/controller'; -import { filesController } from './core/fs/controller'; -import { fileTreeController } from './core/fs/file-tree/controller'; import { gitRepositoryController } from './core/git/repository/controller'; import { gitWorktreeController } from './core/git/worktree/controller'; import { githubController } from './core/github/controller'; @@ -59,6 +60,7 @@ export const rpcRouter = createRPCRouter({ asana: asanaController, featurebase: featurebaseController, forgejo: forgejoController, + files: machineFilesController, github: githubController, gitlab: gitlabController, issues: issueController, @@ -85,7 +87,7 @@ export const rpcRouter = createRPCRouter({ projectSettings: projectSettingsController, workspace: createRPCNamespace({ gitWorktree: gitWorktreeController, - fs: filesController, + files: workspaceFileSystemController, fileTree: fileTreeController, editor: editorBufferController, }), diff --git a/apps/emdash-desktop/src/renderer/features/projects/components/add-project-modal/remote-directory-selector.tsx b/apps/emdash-desktop/src/renderer/features/projects/components/add-project-modal/remote-directory-selector.tsx index 472e81ba65..093689aa9f 100644 --- a/apps/emdash-desktop/src/renderer/features/projects/components/add-project-modal/remote-directory-selector.tsx +++ b/apps/emdash-desktop/src/renderer/features/projects/components/add-project-modal/remote-directory-selector.tsx @@ -14,7 +14,7 @@ import { Button } from '@renderer/lib/ui/button'; import { Input } from '@renderer/lib/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/lib/ui/popover'; import { cn } from '@renderer/utils/utils'; -import type { FileEntry } from '@shared/core/ssh/ssh'; +import type { BrowseDirectoryResult, DirectoryEntry } from '@shared/core/fs/fs'; interface RemoteDirectorySelectorProps { connectionId: string | undefined; @@ -83,12 +83,12 @@ function directoryHistoryReducer( function useRemoteDirectoryBrowser(connectionId: string | undefined, initialPath: string) { const [currentPath, setCurrentPath] = useState(initialPath); - const [fileEntries, setFileEntries] = useState([]); + const [fileEntries, setFileEntries] = useState([]); const [isBrowsing, setIsBrowsing] = useState(false); const [browseError, setBrowseError] = useState(null); const [loadedPath, setLoadedPath] = useState(null); - const directoryCacheRef = useRef(new Map()); - const inFlightRequestsRef = useRef(new Map>()); + const directoryCacheRef = useRef(new Map()); + const inFlightRequestsRef = useRef(new Map>()); const cacheWriteRequestIdsRef = useRef(new Map()); const latestRequestIdRef = useRef(0); @@ -123,13 +123,13 @@ function useRemoteDirectoryBrowser(connectionId: string | undefined, initialPath if (!request) { cacheWriteRequestIdsRef.current.set(cacheKey, requestId); - request = rpc.ssh - .listFiles({ connectionId, path: nextPath }) - .then((entries) => { - if (cacheWriteRequestIdsRef.current.get(cacheKey) === requestId) { - directoryCacheRef.current.set(cacheKey, entries); + request = rpc.files + .browseDirectory({ type: 'ssh', connectionId, path: nextPath }) + .then((result) => { + if (result.success && cacheWriteRequestIdsRef.current.get(cacheKey) === requestId) { + directoryCacheRef.current.set(cacheKey, result.data); } - return entries; + return result; }) .finally(() => { if (inFlightRequestsRef.current.get(cacheKey) === request) { @@ -144,9 +144,16 @@ function useRemoteDirectoryBrowser(connectionId: string | undefined, initialPath } try { - const entries = await request; + const result = await request; if (latestRequestIdRef.current !== requestId) return false; - + if (!result.success) { + setBrowseError(result.error.message); + setFileEntries([]); + setLoadedPath(null); + return false; + } + + const entries = result.data; setFileEntries(entries); setLoadedPath(nextPath); return true; @@ -228,7 +235,7 @@ export function RemoteDirectorySelector({ dispatchHistory({ type: options?.replaceHistory ? 'replace' : 'push', path: nextPath }); }; - const navigateTo = (entry: FileEntry) => { + const navigateTo = (entry: DirectoryEntry) => { if (entry.type !== 'directory') return; void navigateToPath(entry.path); }; @@ -319,8 +326,6 @@ export function RemoteDirectorySelector({ > {isDirectory ? ( - ) : entry.type === 'symlink' ? ( - ) : ( )} diff --git a/apps/emdash-desktop/src/shared/core/fs/fs.ts b/apps/emdash-desktop/src/shared/core/fs/fs.ts index 61d109eb7a..5720ccfede 100644 --- a/apps/emdash-desktop/src/shared/core/fs/fs.ts +++ b/apps/emdash-desktop/src/shared/core/fs/fs.ts @@ -1,3 +1,5 @@ +import type { Result } from '@emdash/shared'; + export type FileWatchEventType = 'create' | 'delete' | 'modify' | 'rename'; export interface FileWatchEvent { @@ -6,3 +8,30 @@ export interface FileWatchEvent { path: string; oldPath?: string; } + +export type BrowseLocalDirectoryParams = { + type: 'local'; + path: string; +}; + +export type BrowseSshDirectoryParams = { + type: 'ssh'; + path: string; + connectionId: string; +}; + +export type BrowseDirectoryParams = BrowseLocalDirectoryParams | BrowseSshDirectoryParams; + +export type DirectoryEntry = { + path: string; + name: string; + type: 'file' | 'directory'; + size: number; + modifiedAt: Date; +}; + +export type BrowseDirectoryError = + | { type: 'invalid-path'; path: string; message: string } + | { type: 'filesystem-error'; path: string; message: string }; + +export type BrowseDirectoryResult = Result; diff --git a/apps/emdash-desktop/src/shared/core/ssh/ssh.ts b/apps/emdash-desktop/src/shared/core/ssh/ssh.ts index 71a22a2f58..9c84da187d 100644 --- a/apps/emdash-desktop/src/shared/core/ssh/ssh.ts +++ b/apps/emdash-desktop/src/shared/core/ssh/ssh.ts @@ -52,19 +52,6 @@ export interface ExecResult { exitCode: number; } -/** - * File entry for SFTP operations - * Represents a file or directory on a remote host - */ -export interface FileEntry { - path: string; - name: string; - type: 'file' | 'directory' | 'symlink'; - size: number; - modifiedAt: Date; - permissions?: string; -} - /** * Test connection result * Returned when testing an SSH connection @@ -101,7 +88,6 @@ export const SSH_IPC_CHANNELS = { CONNECT: 'ssh:connect', DISCONNECT: 'ssh:disconnect', EXECUTE_COMMAND: 'ssh:executeCommand', - LIST_FILES: 'ssh:listFiles', READ_FILE: 'ssh:readFile', WRITE_FILE: 'ssh:writeFile', GET_STATE: 'ssh:getState', diff --git a/apps/emdash-desktop/src/shared/events/appEvents.ts b/apps/emdash-desktop/src/shared/events/appEvents.ts index a17cd79bf0..fdb1caf2bb 100644 --- a/apps/emdash-desktop/src/shared/events/appEvents.ts +++ b/apps/emdash-desktop/src/shared/events/appEvents.ts @@ -55,7 +55,7 @@ export const ptyStartedChannel = defineEvent<{ export type PlanEvent = { type: 'write_blocked' | 'remove_blocked'; root: string; - relPath: string; + path: string; code?: string; message?: string; }; diff --git a/apps/emdash-desktop/src/shared/lib/ipc/rpc.test.ts b/apps/emdash-desktop/src/shared/lib/ipc/rpc.test.ts index 9cbc8588e7..ba0292cf01 100644 --- a/apps/emdash-desktop/src/shared/lib/ipc/rpc.test.ts +++ b/apps/emdash-desktop/src/shared/lib/ipc/rpc.test.ts @@ -29,13 +29,13 @@ const gitRepositoryController = createRPCController({ branches: () => Promise.resolve(['main']), }); -const wsFsController = createRPCController({ +const wsFilesController = createRPCController({ list: (dir: string) => Promise.resolve([dir]), }); const workspaceNamespace = createRPCNamespace({ gitWorktree: gitWorktreeController, - fs: wsFsController, + files: wsFilesController, }); const router = createRPCRouter({ @@ -110,9 +110,9 @@ describe('createRPCClient', () => { const invoke = vi.fn().mockResolvedValue([]); const rpc = createRPCClient(invoke); - await rpc.workspace.fs.list('projects'); + await rpc.workspace.files.list('projects'); - expect(invoke).toHaveBeenCalledWith('workspace.fs.list', 'projects'); + expect(invoke).toHaveBeenCalledWith('workspace.files.list', 'projects'); }); }); @@ -137,7 +137,7 @@ describe('registerRPCRouter', () => { expect(ipc.registeredChannels()).toContain('workspace.gitWorktree.clone'); expect(ipc.registeredChannels()).toContain('gitRepository.branches'); - expect(ipc.registeredChannels()).toContain('workspace.fs.list'); + expect(ipc.registeredChannels()).toContain('workspace.files.list'); }); it('calls through to the original handler function with args', async () => { @@ -190,7 +190,7 @@ describe('IpcClient type-safety', () => { it('types a nested namespace as a sub-namespace object, not a callable', () => { expectTypeOf(rpc.workspace).toEqualTypeOf<{ - fs: { list: (dir: string) => Promise }; + files: { list: (dir: string) => Promise }; gitWorktree: { clone: (url: string) => Promise }; }>(); }); From a6b82b092c8167c443dca9c7a4731402810d5a12 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:13:14 -0700 Subject: [PATCH 22/37] feat(desktop-runtime): add core file runtime adapters --- .../src/main/core/runtime/files-helpers.ts | 89 ++ .../core/runtime/legacy/ssh-file-system.ts | 499 ++++++++ .../core/runtime/legacy/ssh-file-tree.test.ts | 20 +- .../core/runtime/legacy/ssh-files.test.ts | 27 +- .../src/main/core/runtime/legacy/ssh-files.ts | 324 +++-- .../src/main/core/runtime/legacy/ssh-git.ts | 47 +- .../runtime/legacy/ssh-legacy-fs-types.ts | 304 +++++ .../core/runtime/legacy/ssh-legacy-fs.test.ts | 179 +++ .../main/core/runtime/legacy/ssh-legacy-fs.ts | 1044 +++++++++++++++++ .../src/main/core/runtime/legacy/ssh-paths.ts | 31 + .../runtime/legacy/ssh-remote-enumerate.ts | 114 ++ .../src/main/core/runtime/runtime-manager.ts | 31 +- .../src/main/core/runtime/types.ts | 19 +- 13 files changed, 2518 insertions(+), 210 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/runtime/files-helpers.ts create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-system.ts create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs-types.ts create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.test.ts create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.ts create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-paths.ts create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-remote-enumerate.ts diff --git a/apps/emdash-desktop/src/main/core/runtime/files-helpers.ts b/apps/emdash-desktop/src/main/core/runtime/files-helpers.ts new file mode 100644 index 0000000000..f72d7b5a8c --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/files-helpers.ts @@ -0,0 +1,89 @@ +import { + isFileNotFoundError, + type FileError, + type FileStat, + type IFileSystem, + type ReadFileOptions, +} from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; +import type { IFilesRuntime } from './types'; + +export type AbsoluteDirectoryFileSystem = { + mkdir(path: string, options?: { recursive?: boolean }): Promise>; + realPath(path: string): Promise>; +}; + +export function openFileSystem(files: IFilesRuntime): Result { + return files.fileSystem(); +} + +export function absoluteDirectoryFileSystem(files: IFilesRuntime): AbsoluteDirectoryFileSystem { + return { + mkdir: (absPath, options) => ensureAbsoluteDir(files, absPath, options), + realPath: (absPath) => realPathAbsolute(files, absPath), + }; +} + +export async function ensureAbsoluteDir( + files: IFilesRuntime, + absPath: string, + options: { recursive?: boolean } = {} +): Promise> { + if (!files.path.isAbsolute(absPath)) { + return err({ + type: 'invalid-path', + path: absPath, + message: `Expected absolute path: ${absPath}`, + }); + } + + const opened = openFileSystem(files); + if (!opened.success) return opened; + return opened.data.mkdir(absPath, { + recursive: options.recursive ?? true, + }); +} + +export async function realPathAbsolute( + files: IFilesRuntime, + absPath: string +): Promise> { + if (!files.path.isAbsolute(absPath)) { + return err({ + type: 'invalid-path', + path: absPath, + message: `Expected absolute path: ${absPath}`, + }); + } + + const opened = openFileSystem(files); + if (!opened.success) return opened; + return opened.data.realPath(absPath); +} + +export async function statAbsolute( + files: IFilesRuntime, + absPath: string +): Promise<{ success: true; data: FileStat } | { success: false; error: FileError }> { + if (!files.path.isAbsolute(absPath)) { + return { + success: false, + error: { type: 'invalid-path', path: absPath, message: `Expected absolute path: ${absPath}` }, + }; + } + + const opened = openFileSystem(files); + if (!opened.success) return opened; + return opened.data.stat(absPath); +} + +export async function readTextIfExists( + fs: Pick, + absPath: string, + options?: ReadFileOptions +): Promise> { + const result = await fs.readText(absPath, options); + if (result.success) return ok(result.data.content); + if (isFileNotFoundError(result.error)) return ok(null); + return result; +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-system.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-system.ts new file mode 100644 index 0000000000..9f2bf53109 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-system.ts @@ -0,0 +1,499 @@ +import path from 'node:path'; +import { + type FileEnumeration, + type FileError, + type FileGlob, + type FileGlobOptions, + type FileStat, + type IFileSystem, + type ReadBytesResult, + type ReadFileOptions, + type ReadTextResult, + type WriteFileResult, +} from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; +import type { SFTPWrapper } from 'ssh2'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { SshFileSystem } from './ssh-legacy-fs'; +import { FileSystemError, FileSystemErrorCodes, type FileEntry } from './ssh-legacy-fs-types'; +import { normalizeRemoteAbsolutePath, normalizeRemoteRootPath } from './ssh-paths'; +import { enumerateRemoteWorkspace } from './ssh-remote-enumerate'; + +const DEFAULT_MAX_BYTES = 200 * 1024; +const MAX_READ_BYTES = 100 * 1024 * 1024; + +const SFTP_STATUS = { + NO_SUCH_FILE: 2, + PERMISSION_DENIED: 3, + FAILURE: 4, +} as const; + +type SftpError = Error & { code?: number }; + +export class LegacySshFileSystem implements IFileSystem { + private readonly legacy: SshFileSystem; + private cachedSftp: SFTPWrapper | undefined; + + constructor(private readonly proxy: SshClientProxy) { + this.legacy = new SshFileSystem(proxy, '/'); + } + + async readText( + absPath: string, + options?: ReadFileOptions + ): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + return ok(await this.legacy.read(normalized.data, options?.maxBytes)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async readBytes( + absPath: string, + options: ReadFileOptions = {} + ): Promise> { + const resolved = normalizeRemoteAbsolutePath(absPath); + if (!resolved.success) return resolved; + + try { + const sftp = await this.getSftp(); + return await this.readRemoteBytes(sftp, absPath, resolved.data, options.maxBytes); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async writeText(absPath: string, content: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + const result = await this.legacy.write(normalized.data, content); + if (!result.success) { + return err({ + type: 'fs-error', + path: absPath, + message: result.error ?? `Failed to write file: ${absPath}`, + }); + } + return ok({ bytesWritten: result.bytesWritten }); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async writeBytes( + absPath: string, + bytes: Uint8Array + ): Promise> { + const resolved = normalizeRemoteAbsolutePath(absPath); + if (!resolved.success) return resolved; + + try { + const sftp = await this.getSftp(); + const parentDir = path.posix.dirname(resolved.data); + await this.ensureRemoteDir(sftp, parentDir); + return await this.writeRemoteBytes(sftp, absPath, resolved.data, Buffer.from(bytes)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async stat(absPath: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + const entry = await this.legacy.stat(normalized.data); + if (!entry) { + return err({ + type: 'fs-error', + path: absPath, + message: `File or directory not found: ${absPath}`, + code: FileSystemErrorCodes.NOT_FOUND, + }); + } + return ok(toFileStat(entry)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async exists(absPath: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + return ok(await this.legacy.exists(normalized.data)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async mkdir( + absPath: string, + options: { recursive?: boolean } = {} + ): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + if (normalized.data === '/') return ok(); + + try { + await this.legacy.mkdir(normalized.data, options); + return ok(); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async remove( + absPath: string, + options: { recursive?: boolean } = {} + ): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + const result = await this.legacy.remove(normalized.data, options); + if (!result.success) { + return err({ + type: 'fs-error', + path: absPath, + message: result.error ?? `Failed to remove file: ${absPath}`, + }); + } + return ok(); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async realPath(absPath: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + return ok(await this.legacy.realPath(normalized.data)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async copyFile(src: string, dest: string): Promise> { + const normalizedSrc = normalizeRemoteAbsolutePath(src); + if (!normalizedSrc.success) return normalizedSrc; + const normalizedDest = normalizeRemoteAbsolutePath(dest); + if (!normalizedDest.success) return normalizedDest; + + try { + await this.legacy.copyFile(normalizedSrc.data, normalizedDest.data); + return ok(); + } catch (error) { + return err(toFileError(error, dest)); + } + } + + glob(patterns: string[], options: FileGlobOptions): Result { + const validated = validateGlobPatterns(patterns); + if (!validated.success) return validated; + const cwd = normalizeRemoteAbsolutePath(options.cwd); + if (!cwd.success) return cwd; + return ok(this.globPaths(validated.data, options)); + } + + enumerate(rootPath: string): Result { + const normalizedRoot = normalizeRemoteAbsolutePath(rootPath); + if (!normalizedRoot.success) return normalizedRoot; + return ok(enumerateRemoteWorkspace(this.proxy, normalizedRoot.data)); + } + + private getSftp(): Promise { + if (this.cachedSftp) return Promise.resolve(this.cachedSftp); + return new Promise((resolve, reject) => { + this.proxy.sftp((error, sftp) => { + if (error) { + reject(error); + return; + } + this.cachedSftp = sftp; + sftp.on('close', () => { + this.cachedSftp = undefined; + }); + resolve(sftp); + }); + }); + } + + private async *globPaths(patterns: string[], options: FileGlobOptions): FileGlob { + const cwd = normalizeRemoteAbsolutePath(options.cwd); + if (!cwd.success) return; + + const seen = new Set(); + for (const pattern of patterns) { + const matches = await this.legacy.glob(pattern, { + cwd: cwd.data, + dot: options.dot ?? false, + }); + for (const match of matches) { + const normalized = normalizeRemoteAbsolutePath(path.posix.resolve(cwd.data, match)); + if (!normalized.success || seen.has(normalized.data)) continue; + seen.add(normalized.data); + yield normalized.data; + } + } + } + + private async readRemoteBytes( + sftp: SFTPWrapper, + relPath: string, + fullPath: string, + maxBytes: number | undefined + ): Promise> { + return new Promise((resolve) => { + sftp.open(fullPath, 'r', (openError, handle) => { + if (openError) { + resolve(err(toFileError(openError, relPath))); + return; + } + + sftp.fstat(handle, (statError, stats) => { + if (statError) { + closeRemoteHandle(sftp, handle); + resolve(err(toFileError(statError, relPath))); + return; + } + + if (stats.isDirectory()) { + closeRemoteHandle(sftp, handle); + resolve( + err({ + type: 'fs-error', + path: relPath, + message: `Path is a directory: ${relPath}`, + code: FileSystemErrorCodes.IS_DIRECTORY, + }) + ); + return; + } + + const readSize = Math.min(stats.size, normalizeMaxBytes(maxBytes)); + if (readSize === 0) { + closeRemoteHandle(sftp, handle); + resolve( + ok({ + bytes: new Uint8Array(), + truncated: stats.size > readSize, + totalSize: stats.size, + }) + ); + return; + } + + const buffer = Buffer.alloc(readSize); + sftp.read(handle, buffer, 0, readSize, 0, (readError, bytesRead) => { + closeRemoteHandle(sftp, handle); + if (readError) { + resolve(err(toFileError(readError, relPath))); + return; + } + resolve( + ok({ + bytes: buffer.subarray(0, bytesRead), + truncated: stats.size > readSize, + totalSize: stats.size, + }) + ); + }); + }); + }); + }); + } + + private async writeRemoteBytes( + sftp: SFTPWrapper, + relPath: string, + fullPath: string, + buffer: Buffer + ): Promise> { + return new Promise((resolve) => { + sftp.open(fullPath, 'w', (openError, handle) => { + if (openError) { + resolve(err(toFileError(openError, relPath))); + return; + } + + sftp.write(handle, buffer, 0, buffer.length, 0, (writeError) => { + closeRemoteHandle(sftp, handle); + if (writeError) { + resolve(err(toFileError(writeError, relPath))); + return; + } + resolve(ok({ bytesWritten: buffer.byteLength })); + }); + }); + }); + } + + private async ensureRemoteDir(sftp: SFTPWrapper, dirPath: string): Promise { + const normalizedDir = normalizeRemoteRootPath(dirPath); + if (normalizedDir === '/') return; + + const kind = await this.remoteEntryKind(sftp, normalizedDir); + if (kind === 'directory') return; + if (kind === 'file') { + throw new FileSystemError( + `Path is not a directory: ${normalizedDir}`, + FileSystemErrorCodes.NOT_DIRECTORY, + normalizedDir + ); + } + + const parentDir = path.posix.dirname(normalizedDir); + if (parentDir && parentDir !== normalizedDir) await this.ensureRemoteDir(sftp, parentDir); + await this.mkdirRemote(sftp, normalizedDir); + } + + private remoteEntryKind( + sftp: SFTPWrapper, + fullPath: string + ): Promise<'file' | 'directory' | undefined> { + return new Promise((resolve, reject) => { + sftp.stat(fullPath, (error, stats) => { + if (!error) { + resolve(stats.isDirectory() ? 'directory' : 'file'); + return; + } + if (isNoSuchFile(error)) { + resolve(undefined); + return; + } + reject(error); + }); + }); + } + + private mkdirRemote(sftp: SFTPWrapper, fullPath: string): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(fullPath, (error) => { + if (!error || isAlreadyExists(error)) { + resolve(); + return; + } + reject(error); + }); + }); + } +} + +function toFileStat(entry: FileEntry): FileStat { + return { + path: entry.path, + type: entry.type === 'dir' ? 'directory' : 'file', + size: entry.size ?? 0, + mtime: entry.mtime ?? new Date(0), + ctime: entry.ctime ?? new Date(0), + mode: entry.mode ?? 0, + }; +} + +function toFileError(error: unknown, absPath: string): FileError { + if (error instanceof FileSystemError) { + return { type: 'fs-error', path: absPath, message: error.message, code: error.code }; + } + + const sftpError = error as SftpError | undefined; + const message = typeof sftpError?.message === 'string' ? sftpError.message : String(error); + const code = mapSftpErrorCode(sftpError); + return { + type: 'fs-error', + path: absPath, + message, + ...(code ? { code } : {}), + }; +} + +function mapSftpErrorCode(error: SftpError | undefined): string | undefined { + if (!error) return undefined; + if (error.code === SFTP_STATUS.NO_SUCH_FILE) return FileSystemErrorCodes.NOT_FOUND; + if (error.code === SFTP_STATUS.PERMISSION_DENIED) return FileSystemErrorCodes.PERMISSION_DENIED; + const message = error.message ?? ''; + if (message.includes('No such file')) return FileSystemErrorCodes.NOT_FOUND; + if (message.includes('Permission denied')) return FileSystemErrorCodes.PERMISSION_DENIED; + if (message.includes('is a directory')) return FileSystemErrorCodes.IS_DIRECTORY; + if (message.includes('Not a directory')) return FileSystemErrorCodes.NOT_DIRECTORY; + return undefined; +} + +function normalizeMaxBytes(maxBytes: number | undefined): number { + if (maxBytes === undefined) return DEFAULT_MAX_BYTES; + if (!Number.isFinite(maxBytes) || maxBytes < 0) return 0; + return Math.min(Math.floor(maxBytes), MAX_READ_BYTES); +} + +function validateGlobPatterns(patterns: string[]): Result { + if (patterns.length === 0) { + return err({ + type: 'invalid-path', + path: '', + message: 'At least one glob pattern is required', + }); + } + + const normalizedPatterns: string[] = []; + for (const pattern of patterns) { + if (!pattern) { + return err({ + type: 'invalid-path', + path: pattern, + message: 'Glob pattern must not be empty', + }); + } + if (pattern.includes('\0')) { + return err({ type: 'invalid-path', path: pattern, message: 'Path contains a null byte' }); + } + if (path.posix.isAbsolute(pattern) || path.win32.isAbsolute(pattern)) { + return err({ + type: 'invalid-path', + path: pattern, + message: 'Absolute paths are not allowed', + }); + } + + const parts = pattern.replace(/\\/g, '/').split('/').filter(Boolean); + if (parts.includes('..')) { + return err({ + type: 'invalid-path', + path: pattern, + message: 'Parent path segments are not allowed', + }); + } + normalizedPatterns.push(pattern.replace(/\\/g, '/')); + } + return ok(normalizedPatterns); +} + +function isNoSuchFile(error: unknown): boolean { + const sftpError = error as SftpError | undefined; + return ( + sftpError?.code === SFTP_STATUS.NO_SUCH_FILE || + (sftpError?.message ?? '').includes('No such file') + ); +} + +function isAlreadyExists(error: unknown): boolean { + const sftpError = error as SftpError | undefined; + const message = sftpError?.message ?? ''; + return ( + message.includes('already exists') || + message.includes('File exists') || + sftpError?.code === SFTP_STATUS.FAILURE + ); +} + +function closeRemoteHandle(sftp: SFTPWrapper, handle: Buffer): void { + sftp.close(handle, () => {}); +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts index 883d443d0b..ce4f6d8888 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileEntry, FileListResult } from '@main/core/fs/types'; import { LegacySshFilesRuntime } from './ssh-files'; +import { SshFileSystem } from './ssh-legacy-fs'; +import type { FileEntry, FileListResult } from './ssh-legacy-fs-types'; function listResult(entries: FileEntry[]): FileListResult { return { entries, total: entries.length }; @@ -33,9 +33,9 @@ describe('LegacySshFilesRuntime file tree', () => { }); it('loads children for expanded remote directory scopes', async () => { - vi.spyOn(SshFileSystem.prototype, 'list').mockImplementation(async (dirPath = '') => { - if (dirPath === '') return listResult([dirEntry('src')]); - if (dirPath === 'src') return listResult([fileEntry('src/index.ts')]); + vi.spyOn(SshFileSystem.prototype, 'list').mockImplementation(async (dirPath = '/repo') => { + if (dirPath === '/repo') return listResult([dirEntry('/repo/src')]); + if (dirPath === '/repo/src') return listResult([fileEntry('/repo/src/index.ts')]); return listResult([]); }); @@ -49,8 +49,8 @@ describe('LegacySshFilesRuntime file tree', () => { expect(rootSnapshot.success).toBe(true); if (!rootSnapshot.success) return; - const src = rootSnapshot.data.entries.find(([, node]) => node.path === 'src')?.[1]; - expect(src).toMatchObject({ path: 'src', type: 'directory', parentId: null }); + const src = rootSnapshot.data.entries.find(([, node]) => node.path === '/repo/src')?.[1]; + expect(src).toMatchObject({ path: '/repo/src', type: 'directory', parentId: null }); expect(src).toBeDefined(); if (!src) return; @@ -62,11 +62,11 @@ describe('LegacySshFilesRuntime file tree', () => { if (!expandedSnapshot.success) return; expect(expandedSnapshot.data.entries.map(([, node]) => node.path).sort()).toEqual([ - 'src', - 'src/index.ts', + '/repo/src', + '/repo/src/index.ts', ]); expect( - expandedSnapshot.data.entries.find(([, node]) => node.path === 'src/index.ts')?.[1] + expandedSnapshot.data.entries.find(([, node]) => node.path === '/repo/src/index.ts')?.[1] ).toMatchObject({ parentId: src.id }); await opened.data.release(); diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts index 46decc7dee..8628483bd0 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts @@ -1,10 +1,10 @@ import { EventEmitter } from 'node:events'; import type { ClientChannel } from 'ssh2'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import type { FileWatchEvent } from '@shared/core/fs/fs'; import { LegacySshFilesRuntime } from './ssh-files'; +import { SshFileSystem } from './ssh-legacy-fs'; type SnapshotRecord = { kind: 'file' | 'directory'; @@ -35,20 +35,20 @@ describe('LegacySshFilesRuntime', () => { const runtime = new LegacySshFilesRuntime({} as never); const updates: unknown[] = []; const subscription = runtime.watchChanges('/repo', (update) => updates.push(update), { - paths: ['src'], + paths: ['/repo/src'], }); expect(subscription.success).toBe(true); - expect(update).toHaveBeenCalledWith(['src']); + expect(update).toHaveBeenCalledWith(['/repo/src']); emitLegacyEvents?.([ - { type: 'modify', entryType: 'file', path: 'src/notes.md' }, - { type: 'modify', entryType: 'file', path: 'src/node_modules/pkg/index.js' }, + { type: 'modify', entryType: 'file', path: '/repo/src/notes.md' }, + { type: 'modify', entryType: 'file', path: '/repo/src/node_modules/pkg/index.js' }, ]); expect(updates).toEqual([ { kind: 'changes', - changes: [{ kind: 'update', entryType: 'file', path: 'src/notes.md' }], + changes: [{ kind: 'update', entryType: 'file', path: '/repo/src/notes.md' }], }, ]); @@ -76,7 +76,6 @@ describe('LegacySshFilesRuntime', () => { const runtime = new LegacySshFilesRuntime(proxy); const updates: unknown[] = []; const subscription = runtime.watchChanges('/repo', (update) => updates.push(update), { - paths: [''], debounceMs: 100, }); @@ -95,9 +94,9 @@ describe('LegacySshFilesRuntime', () => { { kind: 'changes', changes: [ - { kind: 'update', path: 'src/a.ts', entryType: 'file' }, - { kind: 'create', path: 'src/b.ts', entryType: 'file' }, - { kind: 'delete', path: 'README.md', entryType: 'file' }, + { kind: 'update', path: '/repo/src/a.ts', entryType: 'file' }, + { kind: 'create', path: '/repo/src/b.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/README.md', entryType: 'file' }, ], }, ]); @@ -112,11 +111,15 @@ describe('LegacySshFilesRuntime', () => { ]); const runtime = new LegacySshFilesRuntime(proxy); - const result = runtime.enumerate('/repo'); + const fileSystem = runtime.fileSystem(); + expect(fileSystem.success).toBe(true); + if (!fileSystem.success) return; + + const result = fileSystem.data.enumerate('/repo'); expect(result.success).toBe(true); if (!result.success) return; - await expect(collect(result.data)).resolves.toEqual(['README.md', 'src/a.ts']); + await expect(collect(result.data)).resolves.toEqual(['/repo/README.md', '/repo/src/a.ts']); expect(exec).toHaveBeenCalledTimes(1); await runtime.dispose(); diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts index 5d82b18296..a771e769c4 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts @@ -1,12 +1,7 @@ import path from 'node:path'; -import { StringDecoder } from 'node:string_decoder'; import { - IGNORED_PATH_SEGMENTS, isIgnored, - normalizeRelPath, - normalizeRelPaths, type FileChange, - type FileEnumeration, type FileChangeSubscription, type FileChangeUpdate, type FileChangeWatchOptions, @@ -18,23 +13,31 @@ import { type FileTreeSequences, type FileTreeSnapshot, type FileTreeUpdate, + type IFileSystem, type IFileTree, - type IFilesRuntime, type NodeId, - type RelPath, type SubscribedSnapshot, } from '@emdash/core/files'; import { LiveCollection, ResourceMap, type KeyedOp } from '@emdash/core/lib'; import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; import type { ClientChannel } from 'ssh2'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import { FileSystemError, FileSystemErrorCodes } from '@main/core/fs/types'; -import type { FileEntry } from '@main/core/fs/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import { log } from '@main/lib/logger'; import { quoteShellArg } from '@main/utils/shellEscape'; import type { FileWatchEvent } from '@shared/core/fs/fs'; +import { LegacySshFileSystem } from './ssh-file-system'; +import { SshFileSystem } from './ssh-legacy-fs'; +import { FileSystemError, FileSystemErrorCodes } from './ssh-legacy-fs-types'; +import type { FileEntry } from './ssh-legacy-fs-types'; +import { + containsRemotePath, + normalizeRemoteAbsolutePath, + normalizeRemoteRootPath, + toRemoteAbsolutePath, +} from './ssh-paths'; +import { buildFindPruneExpression } from './ssh-remote-enumerate'; const SSH_FILE_TREE_POLL_MS = 4_000; const SSH_FILE_CHANGE_POLL_MS = 4_000; @@ -62,6 +65,8 @@ type LegacySshSnapshotEntry = { * behavior until the core runtime can run inside the remote workspace server. */ export class LegacySshFilesRuntime implements IFilesRuntime { + readonly path: IFilesRuntime['path'] = posixMachinePath; + private readonly trees: ResourceMap; private readonly changeFeeds = new Set(); private disposeRequested = false; @@ -78,17 +83,18 @@ export class LegacySshFilesRuntime implements IFilesRuntime { } async openTree(rootPath: string): Promise> { - const normalizedRoot = normalizeRemoteRootPath(rootPath); + const normalizedRoot = normalizeRemoteAbsolutePath(rootPath); + if (!normalizedRoot.success) return err(normalizedRoot.error); if (this.disposeRequested) { return err({ type: 'fs-error', - path: '', + path: normalizedRoot.data, message: 'LegacySshFilesRuntime disposed', }); } - const lease = await this.trees.acquire(normalizedRoot, async () => { - return new LegacySshFileTree(this.proxy, normalizedRoot, (context, error) => + const lease = await this.trees.acquire(normalizedRoot.data, async () => { + return new LegacySshFileTree(this.proxy, normalizedRoot.data, (context, error) => log.warn('LegacySshFilesRuntime: background error', { context, error: String(error), @@ -109,16 +115,51 @@ export class LegacySshFilesRuntime implements IFilesRuntime { } } - enumerate(rootPath: string): Result { - const normalizedRoot = normalizeRemoteRootPath(rootPath); + fileSystem(): Result { if (this.disposeRequested) { return err({ type: 'fs-error', - path: normalizedRoot, + path: '', message: 'LegacySshFilesRuntime disposed', }); } - return ok(enumerateRemoteWorkspace(this.proxy, normalizedRoot)); + return ok(new LegacySshFileSystem(this.proxy)); + } + + async copyFile(src: string, dest: string): Promise> { + const sourcePath = normalizeRemoteAbsolutePath(src); + if (!sourcePath.success) return sourcePath; + const destPath = normalizeRemoteAbsolutePath(dest); + if (!destPath.success) return destPath; + + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: destPath.data, + message: 'LegacySshFilesRuntime disposed', + }); + } + + try { + const destParent = path.posix.dirname(destPath.data); + const result = await execRemoteBuffer( + this.proxy, + [ + `mkdir -p ${quoteShellArg(destParent)}`, + `cp -p ${quoteShellArg(sourcePath.data)} ${quoteShellArg(destPath.data)}`, + ].join(' && ') + ); + if (result.exitCode !== 0) { + return err({ + type: 'fs-error', + path: destPath.data, + message: result.stderr || `Remote copy exited with code ${result.exitCode}`, + }); + } + return ok(); + } catch (error) { + return err(toFileError(error, destPath.data)); + } } watchChanges( @@ -126,22 +167,23 @@ export class LegacySshFilesRuntime implements IFilesRuntime { cb: (update: FileChangeUpdate) => void, options: FileChangeWatchOptions = {} ): Result { - const normalizedRoot = normalizeRemoteRootPath(rootPath); + const normalizedRoot = normalizeRemoteAbsolutePath(rootPath); + if (!normalizedRoot.success) return normalizedRoot; if (this.disposeRequested) { return err({ type: 'fs-error', - path: normalizedRoot, + path: normalizedRoot.data, message: 'LegacySshFilesRuntime disposed', }); } - const paths = normalizeWatchedPaths(options.paths); + const paths = normalizeWatchedPaths(normalizedRoot.data, options.paths); if (!paths.success) return paths; - if (watchesWholeRoot(paths.data)) { + if (watchesWholeRoot(normalizedRoot.data, paths.data)) { const feed = new LegacySshRecursiveChangeFeed( this.proxy, - normalizedRoot, + normalizedRoot.data, cb, (context, error) => log.warn('LegacySshFilesRuntime: background error', { @@ -166,10 +208,10 @@ export class LegacySshFilesRuntime implements IFilesRuntime { }); } - const fs = new SshFileSystem(this.proxy, normalizedRoot); + const fs = new SshFileSystem(this.proxy, '/'); const watcher = fs.watch( (events) => { - const changes = eventsToChanges(events); + const changes = eventsToChanges('/', events); if (changes.length > 0) cb({ kind: 'changes', changes }); }, { debounceMs: options.debounceMs } @@ -200,6 +242,18 @@ export class LegacySshFilesRuntime implements IFilesRuntime { } } +const posixMachinePath: IFilesRuntime['path'] = { + join: (...parts) => path.posix.join(...parts), + dirname: (value) => path.posix.dirname(value), + basename: (value) => path.posix.basename(value), + isAbsolute: (value) => path.posix.isAbsolute(value), + relative: (from, to) => path.posix.relative(from, to), + contains: (parent, child) => { + const rel = path.posix.relative(parent, child); + return rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)); + }, +}; + class LegacySshRecursiveChangeFeed implements ChangeFeedHandle { private snapshot: Map | null = null; private timer: ReturnType | undefined; @@ -278,7 +332,7 @@ class LegacySshRecursiveChangeFeed implements ChangeFeedHandle { message: result.stderr || `Remote file snapshot exited with code ${result.exitCode}`, }); } - return ok(parseRecursiveSnapshot(result.stdout)); + return ok(parseRecursiveSnapshot(this.rootPath, result.stdout)); } catch (error) { return err(toFileError(error, this.rootPath)); } @@ -369,15 +423,25 @@ class LegacySshFileTree implements IFileTree { async revealPath(pathToReveal: string): Promise> { const ready = await this.ready(); if (!ready.success) return err(ready.error); - const normalized = normalizeRelPath(pathToReveal); + const normalized = normalizeRemoteAbsolutePath(pathToReveal); if (!normalized.success) return normalized; + if (!containsRemotePath(this.rootPath, normalized.data)) { + return err({ + type: 'invalid-path', + path: pathToReveal, + message: 'Path is outside the file-tree root', + }); + } - const parts = normalized.data.split('/').filter(Boolean); + const relPath = path.posix.relative(this.rootPath, normalized.data); + const parts = relPath.split('/').filter(Boolean); let sequences: FileTreeSequences = {}; for (let index = 0; index < parts.length; index += 1) { - const relPath = parts.slice(0, index + 1).join('/'); - const node = this.getByPath(relPath); - if (!node) return err({ type: 'not-found', path: relPath }); + const absPath = normalizeRemoteRootPath( + path.posix.join(this.rootPath, ...parts.slice(0, index + 1)) + ); + const node = this.getByPath(absPath); + if (!node) return err({ type: 'not-found', path: absPath }); const shouldExpand = index < parts.length - 1 || node.type === 'directory'; if (!shouldExpand) continue; if (node.type !== 'directory') { @@ -443,7 +507,7 @@ class LegacySshFileTree implements IFileTree { return err({ type: 'not-directory', id: dirNode.id, path: dirNode.path }); } - const dirPath = dirNode?.path ?? ''; + const dirPath = dirNode?.path ?? this.rootPath; const listed = await this.listChildren(dirPath); if (!listed.success) return listed; @@ -468,17 +532,24 @@ class LegacySshFileTree implements IFileTree { } private async listChildren(dirPath: string): Promise> { - const normalized = normalizeRelPath(dirPath, { allowEmpty: true }); + const normalized = normalizeRemoteAbsolutePath(dirPath); if (!normalized.success) return normalized; + if (!containsRemotePath(this.rootPath, normalized.data)) { + return err({ + type: 'invalid-path', + path: dirPath, + message: 'Path is outside the file-tree root', + }); + } try { const result = await this.fs.list(normalized.data, { includeHidden: true }); const entries: LegacyListedEntry[] = []; for (const entry of result.entries) { - const relPath = entry.path.replace(/\\/g, '/'); - if (isIgnored(relPath)) continue; + const absPath = toRemoteAbsolutePath(this.rootPath, entry.path); + if (isIgnored(absPath)) continue; if (entry.type !== 'dir' && entry.type !== 'file') continue; - entries.push(toListedEntry(entry)); + entries.push(toListedEntry(this.rootPath, entry)); } entries.sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; @@ -612,46 +683,63 @@ class LegacySshFileTree implements IFileTree { } } -function normalizeWatchedPaths(paths: string[] | undefined): Result { - if (!paths || paths.length === 0) return ok(['']); - return normalizeRelPaths(paths, { allowEmpty: true }); +function normalizeWatchedPaths( + rootPath: string, + paths: string[] | undefined +): Result { + if (!paths || paths.length === 0) return ok([rootPath]); + + const normalizedPaths: string[] = []; + for (const pathValue of paths) { + if (pathValue.includes('\0')) { + return err({ type: 'invalid-path', path: pathValue, message: 'Path contains a null byte' }); + } + + const normalized = normalizeRemoteAbsolutePath(pathValue); + if (!normalized.success) return normalized; + + if (!containsRemotePath(rootPath, normalized.data)) { + return err({ + type: 'invalid-path', + path: pathValue, + message: 'Path is outside the watch root', + }); + } + + normalizedPaths.push(normalized.data); + } + + return ok(normalizedPaths); } -function watchesWholeRoot(paths: string[]): boolean { - return paths.includes(''); +function watchesWholeRoot(rootPath: string, paths: string[]): boolean { + return paths.includes(rootPath); } -function eventsToChanges(events: FileWatchEvent[]): FileChange[] { +function eventsToChanges(rootPath: string, events: FileWatchEvent[]): FileChange[] { const changes: FileChange[] = []; for (const event of events) { - if (isIgnored(event.path)) continue; + const eventPath = toRemoteAbsolutePath(rootPath, event.path); + if (isIgnored(eventPath)) continue; if (event.type === 'rename') { - if (event.oldPath && !isIgnored(event.oldPath)) { - changes.push({ kind: 'delete', path: event.oldPath, entryType: event.entryType }); + if (event.oldPath) { + const oldPath = toRemoteAbsolutePath(rootPath, event.oldPath); + if (!isIgnored(oldPath)) { + changes.push({ kind: 'delete', path: oldPath, entryType: event.entryType }); + } } - changes.push({ kind: 'create', path: event.path, entryType: event.entryType }); + changes.push({ kind: 'create', path: eventPath, entryType: event.entryType }); continue; } changes.push({ kind: event.type === 'modify' ? 'update' : event.type, - path: event.path, + path: eventPath, entryType: event.entryType, }); } return changes; } -async function* enumerateRemoteWorkspace( - proxy: SshClientProxy, - rootPath: string -): AsyncIterable { - for await (const rawPath of execRemoteNulFields(proxy, buildRemoteEnumerationCommand(rootPath))) { - const normalized = normalizeRelPath(rawPath); - if (!normalized.success || isIgnored(normalized.data)) continue; - yield normalized.data; - } -} - function diffRecursiveSnapshots( previous: Map, next: Map @@ -739,95 +827,6 @@ function readExecStream( }); } -async function* execRemoteNulFields(proxy: SshClientProxy, command: string): AsyncIterable { - const profile = await proxy.getRemoteShellProfile(); - const fullCommand = buildRemoteShellCommand(profile, command); - const decoder = new StringDecoder('utf8'); - const queue: string[] = []; - let stream: ClientChannel | undefined; - let pending = ''; - let stderr = ''; - let done = false; - let error: unknown; - let notify: (() => void) | undefined; - - const wake = () => { - notify?.(); - notify = undefined; - }; - const waitForEvent = () => - new Promise((resolve) => { - notify = resolve; - }); - - await new Promise((resolve, reject) => { - proxy.exec(fullCommand, (err, channel) => { - if (err) { - reject(err); - return; - } - - stream = channel; - channel.on('data', (chunk: Buffer) => { - const text = pending + decoder.write(chunk); - const parts = text.split('\0'); - pending = parts.pop() ?? ''; - queue.push(...parts); - wake(); - }); - channel.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString('utf8'); - }); - channel.on('close', (exitCode: number | null) => { - const tail = pending + decoder.end(); - if (tail) queue.push(tail); - pending = ''; - if ((exitCode ?? 0) !== 0) { - error = new Error(stderr.trim() || `Remote command exited with code ${exitCode}`); - } - done = true; - wake(); - }); - channel.on('error', (streamError: Error) => { - error = streamError; - done = true; - wake(); - }); - resolve(); - }); - }); - - try { - while (!done || queue.length > 0) { - while (queue.length > 0) { - const item = queue.shift(); - if (item) yield item; - } - if (error) throw error; - if (!done) await waitForEvent(); - } - if (error) throw error; - } finally { - if (!done) stream?.destroy(); - } -} - -function buildRemoteEnumerationCommand(rootPath: string): string { - const pruneExpression = buildFindPruneExpression(); - const enumerateScript = ` -for p do - rel=\${p#./} - [ "$rel" = "." ] && continue - printf '%s\\0' "$rel" -done -`.trim(); - - return [ - `cd ${quoteShellArg(rootPath)} || exit 1`, - `find . ${pruneExpression}-type f -exec sh -c ${quoteShellArg(enumerateScript)} sh {} +`, - ].join('\n'); -} - function buildRecursiveSnapshotCommand(rootPath: string): string { const pruneExpression = buildFindPruneExpression(); const snapshotScript = ` @@ -857,14 +856,10 @@ done ].join('\n'); } -function buildFindPruneExpression(): string { - const ignoredNames = IGNORED_PATH_SEGMENTS.map((name) => `-name ${quoteShellArg(name)}`).join( - ' -o ' - ); - return ignoredNames ? `\\( ${ignoredNames} \\) -prune -o ` : ''; -} - -function parseRecursiveSnapshot(stdout: Buffer): Map { +function parseRecursiveSnapshot( + rootPath: string, + stdout: Buffer +): Map { const entries = new Map(); const fields = stdout.toString('utf8').split('\0'); @@ -874,10 +869,10 @@ function parseRecursiveSnapshot(stdout: Buffer): Map return null; } -function toListedEntry(entry: FileEntry): LegacyListedEntry { - const relPath = entry.path.replace(/\\/g, '/'); +function toListedEntry(rootPath: string, entry: FileEntry): LegacyListedEntry { + const absPath = toRemoteAbsolutePath(rootPath, entry.path); return { - path: relPath, - name: path.posix.basename(relPath), + path: absPath, + name: path.posix.basename(absPath), type: entry.type === 'dir' ? 'directory' : 'file', }; } @@ -931,11 +926,6 @@ function toFileError(error: unknown, path: string): FileError { return { type: 'fs-error', path, message: String(error) }; } -function normalizeRemoteRootPath(rootPath: string): string { - const normalized = path.posix.normalize(rootPath.replace(/\\/g, '/')); - return path.posix.isAbsolute(normalized) ? normalized : path.posix.resolve('/', normalized); -} - function mergeSequences(left: FileTreeSequences, right: FileTreeSequences): FileTreeSequences { return { tree: Math.max(left.tree ?? 0, right.tree ?? 0) || undefined }; } diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts index fe6e634724..2052b03abd 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts @@ -48,11 +48,11 @@ import type { import { LiveModel, ResourceMap } from '@emdash/core/lib'; import { err, ok, type Lease, type Result, type Unsubscribe } from '@emdash/shared'; import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { GitService } from '@main/core/git/legacy/git-service'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import { log } from '@main/lib/logger'; import type { ImageReadResult as LegacyImageReadResult } from '@shared/core/git/types'; +import { LegacySshFileSystem } from './ssh-file-system'; const STATUS_POLL_MS = 10_000; const UNTRACKED_STATUS_POLL_MS = 30_000; @@ -224,7 +224,7 @@ export class LegacySshGitRuntime implements IGitRuntime { } private createGit(root: string): GitService { - const fs = new SshFileSystem(this.proxy, root); + const fs = new LegacySshFileSystem(this.proxy); const ctx = new SshExecutionContext(this.proxy, { root }); return new GitService(ctx, fs); } @@ -485,27 +485,28 @@ class LegacySshGitWorktree implements IGitWorktree { } isFileCleanlyTracked(filePath: string): Promise { - return this.git.isFileCleanlyTracked(filePath); + return this.git.isFileCleanlyTracked(this.toGitPath(filePath)); } - getChangedFiles(base: DiffTarget): Promise { - return this.git.getChangedFiles(base) as Promise; + async getChangedFiles(base: DiffTarget): Promise { + return (await this.git.getChangedFiles(base)).map((change) => this.toAbsChange(change)); } getFileAtRef(filePath: string, ref: string): Promise { - return this.git.getFileAtRef(filePath, ref); + return this.git.getFileAtRef(this.toGitPath(filePath), ref); } getFileAtIndex(filePath: string): Promise { - return this.git.getFileAtIndex(filePath); + return this.git.getFileAtIndex(this.toGitPath(filePath)); } async getImageAtRef(filePath: string, ref: string): Promise { - return mapImageReadResult(await this.git.getImageAtRef(filePath, ref)); + const gitPath = this.toGitPath(filePath); + return mapImageReadResult(await this.git.getImageAtRef(gitPath, ref)); } async getImageAtIndex(filePath: string): Promise { - return mapImageReadResult(await this.git.getImageAtIndex(filePath)); + return mapImageReadResult(await this.git.getImageAtIndex(this.toGitPath(filePath))); } getLog(options?: GitLogOptions): Promise { @@ -518,7 +519,7 @@ class LegacySshGitWorktree implements IGitWorktree { async stage(paths: string[]): Promise> { try { - await this.git.stageFiles(paths); + await this.git.stageFiles(this.toGitPaths(paths)); return ok(await this.refreshStatus()); } catch (error) { return err(toGitCommandError(error)); @@ -536,7 +537,7 @@ class LegacySshGitWorktree implements IGitWorktree { async unstage(paths: string[]): Promise> { try { - await this.git.unstageFiles(paths); + await this.git.unstageFiles(this.toGitPaths(paths)); return ok(await this.refreshStatus()); } catch (error) { return err(toGitCommandError(error)); @@ -554,7 +555,7 @@ class LegacySshGitWorktree implements IGitWorktree { async revert(paths: string[]): Promise> { try { - await this.git.revertFiles(paths); + await this.git.revertFiles(this.toGitPaths(paths)); return ok(await this.refreshStatus()); } catch (error) { return err(toGitCommandError(error)); @@ -604,8 +605,8 @@ class LegacySshGitWorktree implements IGitWorktree { const status = await this.git.getFullStatus(); return { kind: 'ok', - staged: status.staged, - unstaged: status.unstaged, + staged: status.staged.map((change) => this.toAbsChange(change)), + unstaged: status.unstaged.map((change) => this.toAbsChange(change)), stagedAdded: status.totalAdded, stagedDeleted: status.totalDeleted, }; @@ -621,6 +622,24 @@ class LegacySshGitWorktree implements IGitWorktree { return this.git.getHeadInfo(); } + private toAbsChange(change: GitChange): GitChange { + return { ...change, path: this.toAbsPath(change.path) }; + } + + private toAbsPath(filePath: string): string { + if (path.posix.isAbsolute(filePath)) return path.posix.normalize(filePath); + return path.posix.join(this.worktree, filePath); + } + + private toGitPath(filePath: string): string { + if (!path.posix.isAbsolute(filePath)) return filePath; + return path.posix.relative(this.worktree, filePath); + } + + private toGitPaths(paths: string[]): string[] { + return paths.map((filePath) => this.toGitPath(filePath)); + } + private async refreshStatus(): Promise { const value = await this.statusModel.refresh(); return { status: value.sequence }; diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs-types.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs-types.ts new file mode 100644 index 0000000000..8307040f60 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs-types.ts @@ -0,0 +1,304 @@ +/** + * Filesystem abstraction layer types + * Provides unified interface for local and remote (SSH/SFTP) filesystem operations + */ + +/** + * Transitional SSH polling watcher handle. + * + * Runtime-owned file change feeds use this internally for the temporary SSH + * adapter; the renderer-facing legacy watch RPC has been removed. + */ +export interface FileWatcher { + update(paths: string[]): void; + close(): void; +} + +/** + * File entry metadata returned by filesystem operations + */ +export interface FileEntry { + /** Relative path from the project root */ + path: string; + /** Entry type - file or directory */ + type: 'file' | 'dir'; + /** File size in bytes (files only) */ + size?: number; + /** Last modification time */ + mtime?: Date; + /** Creation time */ + ctime?: Date; + /** File permissions (Unix mode) */ + mode?: number; +} + +/** + * Options for listing directory contents + */ +export interface ListOptions { + /** Include entries from subdirectories recursively */ + recursive?: boolean; + /** Include hidden files (starting with .) */ + includeHidden?: boolean; + /** Filter pattern (glob or regex, implementation-dependent) */ + filter?: string; + /** Maximum number of entries to return */ + maxEntries?: number; + /** Time budget in milliseconds */ + timeBudgetMs?: number; +} + +/** + * Result of a list operation + */ +export interface FileListResult { + /** File and directory entries */ + entries: FileEntry[]; + /** Total number of entries found (may be more than entries.length if truncated) */ + total: number; + /** Whether the result was truncated due to limits */ + truncated?: boolean; + /** Reason for truncation if applicable */ + truncateReason?: 'maxEntries' | 'timeBudget'; + /** Duration of the operation in milliseconds */ + durationMs?: number; +} + +/** + * Result of a file read operation + */ +export interface ReadResult { + /** File content as string */ + content: string; + /** Whether the content was truncated due to maxBytes limit */ + truncated: boolean; + /** Total file size in bytes */ + totalSize: number; +} + +/** + * Result of a file write operation + */ +export interface WriteResult { + /** Whether the write was successful */ + success: boolean; + /** Number of bytes written */ + bytesWritten: number; + /** Error message if unsuccessful */ + error?: string; +} + +/** + * Options for search operations + */ +export interface SearchOptions { + /** + * Optional override pattern. If omitted, the `query` argument to `IFileSystem.search()` is used. + */ + pattern?: string; + /** Optional file pattern filter (e.g., "*.ts") */ + filePattern?: string; + /** Maximum number of results to return */ + maxResults?: number; + /** Case-sensitive search */ + caseSensitive?: boolean; + /** File extensions to include */ + fileExtensions?: string[]; +} + +/** + * Result of a search operation + */ +export interface SearchResult { + /** Search matches found */ + matches: SearchMatch[]; + /** Total number of matches */ + total: number; + /** Whether results were truncated */ + truncated?: boolean; + /** Number of files searched */ + filesSearched?: number; +} + +/** + * Individual search match + */ +export interface SearchMatch { + /** Path to the file containing the match */ + filePath: string; + /** Line number (1-based) */ + line: number; + /** Column number (1-based) */ + column: number; + /** Match text */ + content: string; + /** Preview with context */ + preview?: string; +} + +/** + * Legacy workspace filesystem abstraction. + * + * This provider remains active for non-tree workspace file operations + * (read/write/image/copy/search/config watches/project setup). Do not extend it + * for the editor file tree; file-tree reads, scopes, and deltas live in + * `@emdash/core/files` and are exposed through `workspace.fileTree`. + * + * Longer term this desktop-side provider should disappear behind filesystem APIs + * owned by `@emdash/core`. Those APIs should run where the workspace lives and + * call `node:fs` directly: desktop imports core directly for local projects, + * while the workspace server imports the same core API and exposes it to + * desktop for remote projects. + */ +export interface LegacySshFileOperations { + /** + * List directory contents + * @param path - Directory path relative to project root + * @param options - Listing options + * @returns Promise resolving to file list result + */ + list(path: string, options?: ListOptions): Promise; + + /** + * Read file contents + * @param path - File path relative to project root + * @param maxBytes - Maximum bytes to read (default: 200KB) + * @returns Promise resolving to read result + */ + read(path: string, maxBytes?: number): Promise; + + /** + * Write file contents + * @param path - File path relative to project root + * @param content - Content to write + * @returns Promise resolving to write result + */ + write(path: string, content: string): Promise; + + /** + * Check if a path exists + * @param path - Path to check relative to project root + * @returns Promise resolving to true if exists + */ + exists(path: string): Promise; + + /** + * Get file/directory metadata + * @param path - Path to stat relative to project root + * @returns Promise resolving to file entry or null if not found + */ + stat(path: string): Promise; + + /** + * Search for content in files + * @param query - Search query string + * @param options - Search options + * @returns Promise resolving to search results + */ + search(query: string, options?: SearchOptions): Promise; + + /** + * Remove a file or directory. + * @param path - Path relative to project root + * @param options - Pass `{ recursive: true }` to remove directories and all contents + * @returns Promise resolving to success status + */ + remove( + path: string, + options?: { recursive?: boolean } + ): Promise<{ success: boolean; error?: string }>; + + /** + * Resolve a path to its absolute, canonical form (resolving symlinks). + * @param path - Path relative to project root + * @returns Promise resolving to the absolute path + */ + realPath(path: string): Promise; + + /** + * Find files matching a glob pattern. + * @param pattern - Glob pattern (e.g., ".env", ".env.*.local") + * @param options - cwd: directory to search in; dot: include dotfiles (default false) + * @returns Relative paths of matching entries + */ + glob(pattern: string, options?: { cwd?: string; dot?: boolean }): Promise; + + /** + * Copy a file from src to dest (both paths relative to project root). + * Does not create parent directories — caller must ensure they exist. + * @param src - Source path + * @param dest - Destination path + */ + copyFile(src: string, dest: string): Promise; + + /** + * Read image file as base64 data URL + * @param path - Image file path relative to project root + * @returns Promise resolving to image data + */ + readImage?(path: string): Promise<{ + success: boolean; + dataUrl?: string; + mimeType?: string; + size?: number; + error?: string; + }>; + + /** + * Copy a local file into the project's .emdash attachments directory. + * Only supported on local filesystems (srcPath is an absolute local path). + * @param srcPath - Absolute local path of the source file + * @param subdir - Subdirectory inside .emdash/ (defaults to "attachments") + * @returns Promise resolving to the saved file paths + */ + saveAttachment?( + srcPath: string, + subdir?: string + ): Promise<{ + success: boolean; + absPath?: string; + relPath?: string; + fileName?: string; + error?: string; + }>; + + mkdir(diPath: string, options?: { recursive?: boolean }): Promise; + + /** + * Copy an absolute local file into this filesystem at the given relative path. + * Caller must ensure the destination parent directory exists. + * For SSH: transfers via SFTP fastPut. For local: delegates to fs.copyFile. + * @param localAbsPath - Absolute path of the source file on the local machine + * @param destRelPath - Destination path relative to this filesystem's root + */ + copyLocalFile?(localAbsPath: string, destRelPath: string): Promise; +} + +/** + * Base error class for filesystem operations + */ +export class FileSystemError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly path?: string + ) { + super(message); + this.name = 'FileSystemError'; + } +} + +/** + * Error codes for filesystem operations + */ +export const FileSystemErrorCodes = { + PATH_ESCAPE: 'PATH_ESCAPE', + NOT_FOUND: 'NOT_FOUND', + IS_DIRECTORY: 'IS_DIRECTORY', + NOT_DIRECTORY: 'NOT_DIRECTORY', + PERMISSION_DENIED: 'PERMISSION_DENIED', + INVALID_PATH: 'INVALID_PATH', + CONNECTION_ERROR: 'CONNECTION_ERROR', + TIMEOUT: 'TIMEOUT', + UNKNOWN: 'UNKNOWN', +} as const; diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.test.ts new file mode 100644 index 0000000000..561e8faefc --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SshFileSystem } from './ssh-legacy-fs'; +import type { FileEntry, FileListResult } from './ssh-legacy-fs-types'; + +type SftpMkdirError = Error & { code?: number }; +type SftpItem = { + filename: string; + attrs: { + isDirectory: () => boolean; + size: number; + mtime: number; + atime: number; + mode: number; + }; +}; + +function listResult(entries: FileEntry[]): FileListResult { + return { entries, total: entries.length }; +} + +function fileEntry(path: string, mtimeMs: number, size = 1): FileEntry { + return { + path, + type: 'file', + size, + mtime: new Date(mtimeMs), + mode: 0o100644, + }; +} + +function makeMkdirFs(errors: Array) { + const mkdirCalls: string[] = []; + const sftp = { + on: vi.fn(), + mkdir: vi.fn((dirPath: string, callback: (error?: SftpMkdirError) => void) => { + mkdirCalls.push(dirPath); + callback(errors.shift()); + }), + }; + const proxy = { + sftp: vi.fn((callback: (error: Error | undefined, sftp: unknown) => void) => { + callback(undefined, sftp); + }), + }; + + return { + fs: new SshFileSystem(proxy as never, '/repo'), + mkdirCalls, + }; +} + +function makeListFs(rootPath: string, entriesByPath: Record) { + const sftp = { + on: vi.fn(), + readdir: vi.fn( + (dirPath: string, callback: (error: Error | null, items: SftpItem[]) => void) => { + callback(null, entriesByPath[dirPath] ?? []); + } + ), + }; + const proxy = { + sftp: vi.fn((callback: (error: Error | undefined, sftp: unknown) => void) => { + callback(undefined, sftp); + }), + }; + + return { + fs: new SshFileSystem(proxy as never, rootPath), + readdir: sftp.readdir, + }; +} + +function sftpItem(filename: string, type: 'file' | 'dir'): SftpItem { + return { + filename, + attrs: { + isDirectory: () => type === 'dir', + size: type === 'dir' ? 0 : 1, + mtime: 1, + atime: 1, + mode: type === 'dir' ? 0o040755 : 0o100644, + }, + }; +} + +describe('SshFileSystem.mkdir', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('treats lowercase file exists as idempotent during recursive mkdir', async () => { + const { fs } = makeMkdirFs([new Error('file exists')]); + + await expect(fs.mkdir('existing', { recursive: true })).resolves.toBeUndefined(); + }); + + it('treats uppercase File exists as idempotent during recursive mkdir', async () => { + const { fs } = makeMkdirFs([new Error('File exists')]); + + await expect(fs.mkdir('existing', { recursive: true })).resolves.toBeUndefined(); + }); + + it('rejects non-EEXIST errors during recursive mkdir', async () => { + const { fs } = makeMkdirFs([new Error('Permission denied')]); + + await expect(fs.mkdir('denied', { recursive: true })).rejects.toThrow('Permission denied'); + }); + + it('creates missing parents when SFTP reports lowercase no such file', async () => { + const { fs, mkdirCalls } = makeMkdirFs([new Error('no such file'), undefined, undefined]); + + await expect(fs.mkdir('parent/child', { recursive: true })).resolves.toBeUndefined(); + expect(mkdirCalls).toEqual(['/repo/parent/child', '/repo/parent', '/repo/parent/child']); + }); +}); + +describe('SshFileSystem.list', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns relative paths when the remote root is /', async () => { + const { fs } = makeListFs('/', { + '/': [sftpItem('repo', 'dir')], + }); + + await expect(fs.list('', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'repo', type: 'dir' }], + }); + }); + + it('returns relative nested paths when the remote root is /', async () => { + const { fs } = makeListFs('/', { + '/repo': [sftpItem('src', 'dir')], + }); + + await expect(fs.list('repo', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'repo/src', type: 'dir' }], + }); + }); + + it('returns relative paths under a trailing-slash remote root', async () => { + const { fs } = makeListFs('/repo/', { + '/repo/src': [sftpItem('index.ts', 'file')], + }); + + await expect(fs.list('src', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'src/index.ts', type: 'file' }], + }); + }); +}); + +describe('SshFileSystem.watch', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('emits modify events when an existing polled file changes metadata', async () => { + vi.useFakeTimers(); + + const fs = new SshFileSystem({} as never, '/repo'); + vi.spyOn(fs, 'list') + .mockResolvedValueOnce(listResult([fileEntry('notes.md', 1_000)])) + .mockResolvedValueOnce(listResult([fileEntry('notes.md', 2_000)])); + + const events: Array<{ type: string; entryType: string; path: string }> = []; + const watcher = fs.watch((batch) => events.push(...batch), { debounceMs: 10 }); + watcher.update(['']); + + await vi.advanceTimersByTimeAsync(10); + expect(events).toEqual([]); + + await vi.advanceTimersByTimeAsync(10); + expect(events).toEqual([{ type: 'modify', entryType: 'file', path: 'notes.md' }]); + + watcher.close(); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.ts new file mode 100644 index 0000000000..d9cef454e2 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.ts @@ -0,0 +1,1044 @@ +/** + * Remote FileSystem implementation + * Uses SFTP over SSH for remote filesystem operations + */ + +import type { SFTPWrapper } from 'ssh2'; +import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { log } from '@main/lib/logger'; +import { quoteShellArg } from '@main/utils/shellEscape'; +import type { FileWatchEvent } from '@shared/core/fs/fs'; +import { + FileSystemError, + FileSystemErrorCodes, + type FileEntry, + type FileListResult, + type LegacySshFileOperations, + type FileWatcher, + type ListOptions, + type ReadResult, + type SearchMatch, + type SearchOptions, + type SearchResult, + type WriteResult, +} from './ssh-legacy-fs-types'; + +const SFTP_STATUS = { + NO_SUCH_FILE: 2, + PERMISSION_DENIED: 3, + FAILURE: 4, +} as const; + +interface SftpError extends Error { + code?: number; +} + +/** + * Allowed image extensions for readImage + */ +const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']; + +/** + * Maximum file size for reading (100MB to prevent memory issues) + */ +const MAX_READ_SIZE = 100 * 1024 * 1024; + +/** + * Default max bytes for read operations + */ +const DEFAULT_MAX_BYTES = 200 * 1024; + +function fileEntryMetadataChanged(prev: FileEntry, next: FileEntry): boolean { + return ( + prev.type !== next.type || + prev.size !== next.size || + prev.mode !== next.mode || + prev.mtime?.getTime() !== next.mtime?.getTime() + ); +} + +/** + * Legacy SSH `LegacySshFileOperations` implementation using SFTP/SSH exec. + * + * This remains active for non-tree file operations and transitional SSH + * adapters. The editor file tree uses `LegacySshFilesRuntime` only as a + * temporary bridge until the `@emdash/core` file-tree runtime can run where the + * remote workspace lives. + */ +export class SshFileSystem implements LegacySshFileOperations { + private cachedSftp: SFTPWrapper | undefined; + + constructor( + private readonly proxy: SshClientProxy, + private readonly remotePath: string + ) { + if (!remotePath) { + throw new FileSystemError('Remote path is required', FileSystemErrorCodes.INVALID_PATH); + } + // Normalize remote path to use forward slashes + this.remotePath = remotePath.replace(/\\/g, '/'); + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private getSftp(): Promise { + if (this.cachedSftp) return Promise.resolve(this.cachedSftp); + return new Promise((resolve, reject) => { + this.proxy.sftp((err, sftp) => { + if (err) return reject(err); + this.cachedSftp = sftp; + sftp.on('close', () => { + this.cachedSftp = undefined; + }); + resolve(sftp); + }); + }); + } + + private async exec( + command: string + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const profile = await this.proxy.getRemoteShellProfile(); + const full = buildRemoteShellCommand(profile, command); + return new Promise((resolve, reject) => { + this.proxy.exec(full, (err, stream) => { + if (err) return reject(err); + let stdout = ''; + let stderr = ''; + stream.on('close', (code: number | null) => { + resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? -1 }); + }); + stream.on('data', (d: Buffer) => { + stdout += d.toString('utf-8'); + }); + stream.stderr.on('data', (d: Buffer) => { + stderr += d.toString('utf-8'); + }); + stream.on('error', reject); + }); + }); + } + + // ─── IFileSystem ────────────────────────────────────────────────────────── + + /** + * List directory contents via SFTP + */ + async list(path: string = '', options?: ListOptions): Promise { + const startTime = Date.now(); + const fullPath = this.resolveRemotePath(path); + const sftp = await this.getSftp(); + + return new Promise((resolve, reject) => { + sftp.readdir(fullPath, (err, list) => { + if (err) { + reject(this.mapSftpError(err, fullPath)); + return; + } + + const entries: FileEntry[] = []; + const seen = new Set(); + + for (const item of list) { + // Skip hidden files if not included + if (!options?.includeHidden && item.filename.startsWith('.')) { + continue; + } + + // Apply filter if provided + if (options?.filter) { + const filterRegex = new RegExp(options.filter); + if (!filterRegex.test(item.filename)) { + continue; + } + } + + const entryPath = this.relativePath(`${fullPath}/${item.filename}`); + if (seen.has(entryPath)) { + continue; + } + seen.add(entryPath); + + const entry: FileEntry = { + path: entryPath, + type: item.attrs.isDirectory() ? 'dir' : 'file', + size: item.attrs.size, + mtime: new Date(item.attrs.mtime * 1000), + ctime: new Date(item.attrs.atime * 1000), + mode: item.attrs.mode, + }; + + entries.push(entry); + + // Handle recursive listing + if (options?.recursive && item.attrs.isDirectory()) { + // Note: Recursive listing is async and needs special handling + // For now, we note that full recursive support requires additional implementation + } + } + + // Sort entries: directories first, then files, both alphabetically + entries.sort((a, b) => { + if (a.type === b.type) { + return a.path.localeCompare(b.path); + } + return a.type === 'dir' ? -1 : 1; + }); + + let result = entries; + let truncated = false; + let truncateReason: 'maxEntries' | 'timeBudget' | undefined; + + // Apply maxEntries limit + if (options?.maxEntries && entries.length > options.maxEntries) { + result = entries.slice(0, options.maxEntries); + truncated = true; + truncateReason = 'maxEntries'; + } + + // Apply time budget + const durationMs = Date.now() - startTime; + if (options?.timeBudgetMs && durationMs > options.timeBudgetMs) { + truncated = true; + truncateReason = 'timeBudget'; + } + + resolve({ + entries: result, + total: entries.length, + truncated, + truncateReason, + durationMs, + }); + }); + }); + } + + /** + * Read file contents via SFTP + * Handles large files by respecting maxBytes limit + */ + async read(path: string, maxBytes: number = DEFAULT_MAX_BYTES): Promise { + const fullPath = this.resolveRemotePath(path); + const sftp = await this.getSftp(); + + return new Promise((resolve, reject) => { + sftp.open(fullPath, 'r', (err, handle) => { + if (err) { + reject(this.mapSftpError(err, fullPath)); + return; + } + + sftp.fstat(handle, (statErr, stats) => { + if (statErr) { + sftp.close(handle, () => {}); + reject(this.mapSftpError(statErr, fullPath)); + return; + } + + // Check if it's a directory + if (stats.isDirectory()) { + sftp.close(handle, () => {}); + reject( + new FileSystemError( + `Path is a directory: ${path}`, + FileSystemErrorCodes.IS_DIRECTORY, + path + ) + ); + return; + } + + const fileSize = stats.size; + const readSize = Math.min(fileSize, maxBytes, MAX_READ_SIZE); + + if (readSize === 0) { + sftp.close(handle, () => {}); + resolve({ content: '', truncated: false, totalSize: fileSize }); + return; + } + + const buffer = Buffer.alloc(readSize); + + sftp.read(handle, buffer, 0, readSize, 0, (readErr, bytesRead) => { + sftp.close(handle, () => {}); + + if (readErr) { + reject(this.mapSftpError(readErr, fullPath)); + return; + } + + // Convert buffer to string, handling only the bytes actually read + const content = buffer.subarray(0, bytesRead).toString('utf-8'); + + resolve({ + content, + truncated: fileSize > maxBytes, + totalSize: fileSize, + }); + }); + }); + }); + }); + } + + /** + * Write file contents via SFTP + * Creates parent directories recursively if needed + */ + async write(path: string, content: string): Promise { + const fullPath = this.resolveRemotePath(path); + const sftp = await this.getSftp(); + + // Ensure parent directory exists + const lastSlash = fullPath.lastIndexOf('/'); + if (lastSlash > 0) { + const parentDir = fullPath.substring(0, lastSlash); + await this.ensureRemoteDir(sftp, parentDir); + } + + return new Promise((resolve, reject) => { + sftp.open(fullPath, 'w', (err, handle) => { + if (err) { + reject(this.mapSftpError(err, fullPath)); + return; + } + + const buffer = Buffer.from(content, 'utf-8'); + + if (buffer.length === 0) { + sftp.close(handle, (closeErr) => { + if (closeErr) { + reject(this.mapSftpError(closeErr, fullPath)); + return; + } + resolve({ success: true, bytesWritten: 0 }); + }); + return; + } + + sftp.write(handle, buffer, 0, buffer.length, 0, (writeErr) => { + sftp.close(handle, (closeErr) => { + if (writeErr) { + reject(this.mapSftpError(writeErr, fullPath)); + return; + } + + if (closeErr) { + reject(this.mapSftpError(closeErr, fullPath)); + return; + } + + resolve({ + success: true, + bytesWritten: buffer.length, + }); + }); + }); + }); + }); + } + + /** + * Recursively list all files and directories via SSH find (single round-trip). + * Returns items in the same {path, type} format used by the local fs:list handler. + */ + async listRecursive(options?: { includeDirs?: boolean; maxEntries?: number }): Promise<{ + items: Array<{ path: string; type: 'file' | 'dir' }>; + truncated: boolean; + }> { + const includeDirs = options?.includeDirs ?? true; + const maxEntries = options?.maxEntries ?? 5000; + + // Directories to prune from the listing + const pruneNames = [ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + 'out', + '.turbo', + 'coverage', + '.nyc_output', + '.cache', + 'tmp', + 'temp', + '__pycache__', + '.pytest_cache', + 'venv', + '.venv', + 'target', + '.terraform', + '.serverless', + 'vendor', + 'bower_components', + 'worktrees', + '.worktrees', + '.DS_Store', + ]; + + // Build prune clause for find (names are hardcoded, but escape for safety) + const pruneExpr = pruneNames.map((name) => `-name ${quoteShellArg(name)}`).join(' -o '); + + // Build find command: prune ignored dirs, print files (and optionally dirs) + const typeFilter = includeDirs ? '' : '-type f'; + const command = [ + `find ${quoteShellArg(this.remotePath)}`, + `\\( ${pruneExpr} \\) -prune -o`, + typeFilter ? `${typeFilter} -print` : '-print', + `2>/dev/null`, + `| head -n ${maxEntries + 1}`, + ] + .filter(Boolean) + .join(' '); + + try { + const result = await this.exec(command); + + const lines = result.stdout.split('\n').filter((line) => line.trim()); + + // Check if we exceeded maxEntries (we asked for maxEntries+1 to detect truncation) + const truncated = lines.length > maxEntries; + const effectiveLines = truncated ? lines.slice(0, maxEntries) : lines; + + const items: Array<{ path: string; type: 'file' | 'dir' }> = []; + + for (const line of effectiveLines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Skip the root path itself + if (trimmed === this.remotePath || trimmed === this.remotePath + '/') continue; + + const relPath = this.relativePath(trimmed); + if (!relPath) continue; + + // Determine type: find outputs directories with trailing / when using -print, + // but standard find doesn't. We'll use a heuristic: if any other entry starts + // with this path + '/', it's a directory. For efficiency, detect trailing slash. + const isDir = trimmed.endsWith('/'); + const cleanRel = relPath.replace(/\/$/, ''); + + if (!cleanRel) continue; + + items.push({ + path: cleanRel, + type: isDir ? 'dir' : 'file', + }); + } + + // Since `find` doesn't always indicate directories clearly with just -print, + // we do a second pass: any path that is a prefix of another path is a directory. + const pathSet = new Set(items.map((i) => i.path)); + for (const item of items) { + if (item.type === 'file') { + // Check if any other path starts with this path + '/' + const prefix = item.path + '/'; + for (const otherPath of pathSet) { + if (otherPath.startsWith(prefix)) { + item.type = 'dir'; + break; + } + } + } + } + + // Filter out dirs if not requested + const finalItems = includeDirs ? items : items.filter((i) => i.type === 'file'); + + return { items: finalItems, truncated }; + } catch { + return { items: [], truncated: false }; + } + } + + /** + * Check if a path exists via SFTP + */ + async exists(path: string): Promise { + try { + const entry = await this.stat(path); + return entry !== null; + } catch { + return false; + } + } + + async mkdir(dirPath: string, options?: { recursive?: boolean }): Promise { + const fullPath = this.resolveRemotePath(dirPath); + const sftp = await this.getSftp(); + if (options?.recursive) { + await this.ensureRemoteDir(sftp, fullPath); + } else { + await new Promise((resolve, reject) => { + sftp.mkdir(fullPath, (err) => (err ? reject(this.mapSftpError(err, fullPath)) : resolve())); + }); + } + } + + async realPath(path: string): Promise { + const fullPath = this.resolveRemotePath(path); + const result = await this.exec(`realpath ${quoteShellArg(fullPath)}`); + if (result.exitCode !== 0) { + throw new Error(`realpath failed: ${result.stderr}`); + } + return result.stdout.trim(); + } + + async glob(pattern: string, options?: { cwd?: string; dot?: boolean }): Promise { + const cwd = options?.cwd ? this.resolveRemotePath(options.cwd) : this.remotePath; + const dotSetup = options?.dot ? 'shopt -s dotglob;' : ''; + const command = `${dotSetup} shopt -s nullglob; cd ${quoteShellArg(cwd)} && printf '%s\\n' ${pattern}`; + try { + const result = await this.exec(command); + if (result.exitCode !== 0) return []; + return result.stdout.trim().split('\n').filter(Boolean); + } catch { + return []; + } + } + + async copyLocalFile(localAbsPath: string, destRelPath: string): Promise { + const sftp = await this.getSftp(); + const remoteFull = this.resolveRemotePath(destRelPath); + await new Promise((resolve, reject) => { + sftp.fastPut(localAbsPath, remoteFull, (e) => (e ? reject(e) : resolve())); + }); + } + + async copyFile(src: string, dest: string): Promise { + const fullSrc = this.resolveRemotePath(src); + const fullDest = this.resolveRemotePath(dest); + const result = await this.exec(`cp ${quoteShellArg(fullSrc)} ${quoteShellArg(fullDest)}`); + if (result.exitCode !== 0) { + throw new Error(`Failed to copy file: ${result.stderr}`); + } + } + + /** + * Get file/directory metadata via SFTP + */ + async stat(path: string): Promise { + const fullPath = this.resolveRemotePath(path); + const sftp = await this.getSftp(); + + return new Promise((resolve, reject) => { + sftp.stat(fullPath, (err, stats) => { + if (err) { + // Check if file doesn't exist + const sftpErr = err as SftpError; + if ( + sftpErr.message?.includes('No such file') || + sftpErr.code === SFTP_STATUS.NO_SUCH_FILE + ) { + resolve(null); + return; + } + reject(this.mapSftpError(err, fullPath)); + return; + } + + resolve({ + path, + type: stats.isDirectory() ? 'dir' : 'file', + size: stats.size, + mtime: new Date(stats.mtime * 1000), + ctime: new Date(stats.atime * 1000), + mode: stats.mode, + }); + }); + }); + } + + /** + * Search for content in files via SSH exec (grep) + * Uses grep on the remote host for better performance on large codebases + */ + async search(query: string, options?: SearchOptions): Promise { + const searchPattern = options?.pattern || query; + const basePath = this.remotePath; + const maxResults = options?.maxResults || 10000; + const caseFlag = options?.caseSensitive ? '' : '-i'; + + // Build grep command with shell-safe escaping + const escapedPattern = quoteShellArg(searchPattern); + + // Build file extension filter if provided + let includeFilter = ''; + if (options?.fileExtensions && options.fileExtensions.length > 0) { + const extensions = options.fileExtensions.map((ext) => + ext.startsWith('.') ? ext : `.${ext}` + ); + includeFilter = extensions.map((e) => `--include=${quoteShellArg(`*${e}`)}`).join(' '); + } + + // Use grep recursively with line numbers + const command = `grep -rn ${caseFlag} ${includeFilter} -e ${escapedPattern} ${quoteShellArg(basePath)} 2>/dev/null | head -n ${maxResults}`; + + try { + const result = await this.exec(command); + + // If grep returns non-zero exit but no stderr, it just means no matches + if (result.exitCode !== 0 && result.exitCode !== 1) { + // grep exit code 1 means no matches found, which is fine + return { matches: [], total: 0, filesSearched: 0 }; + } + + const matches: SearchMatch[] = []; + const lines = result.stdout.split('\n').filter((line) => line.trim()); + const seenFiles = new Set(); + + for (const line of lines) { + // Parse grep output format: path:line:content + const firstColon = line.indexOf(':'); + if (firstColon === -1) continue; + + const filePath = line.substring(0, firstColon); + const rest = line.substring(firstColon + 1); + + const secondColon = rest.indexOf(':'); + if (secondColon === -1) continue; + + const lineNum = parseInt(rest.substring(0, secondColon), 10); + const content = rest.substring(secondColon + 1); + + if (isNaN(lineNum)) continue; + + const relPath = this.relativePath(filePath); + + // Apply file pattern filter if provided + if (options?.filePattern) { + const patternRegex = new RegExp(options.filePattern); + if (!patternRegex.test(relPath)) { + continue; + } + } + + seenFiles.add(filePath); + + // Find column by searching for the pattern in the content + const searchPat = options?.caseSensitive ? searchPattern : searchPattern.toLowerCase(); + const column = content.indexOf(searchPat) + 1; + + matches.push({ + filePath: relPath, + line: lineNum, + column: column > 0 ? column : 1, + content: content.trim(), + preview: content.trim(), + }); + } + + return { + matches, + total: matches.length, + truncated: lines.length >= maxResults, + filesSearched: seenFiles.size, + }; + } catch (error) { + log.error('Failed to search', { query, options, error }); + // If command execution fails, return empty results + return { matches: [], total: 0, filesSearched: 0 }; + } + } + + /** + * Remove a file via SFTP + * For directories, uses SSH exec with rm -rf + */ + async remove( + path: string, + options?: { recursive?: boolean } + ): Promise<{ success: boolean; error?: string }> { + const fullPath = this.resolveRemotePath(path); + + try { + const entry = await this.stat(path); + + if (!entry) { + return { success: false, error: `File not found: ${path}` }; + } + + const sftp = await this.getSftp(); + + if (entry.type === 'dir') { + if (!options?.recursive) { + return { success: false, error: `Path is a directory: ${path}` }; + } + const command = `rm -rf ${quoteShellArg(fullPath)}`; + const result = await this.exec(command); + + if (result.exitCode !== 0) { + return { success: false, error: result.stderr || 'Failed to remove directory' }; + } + } else { + // For files, use SFTP unlink + return new Promise((resolve) => { + sftp.unlink(fullPath, (err) => { + if (err) { + resolve({ success: false, error: err.message }); + } else { + resolve({ success: true }); + } + }); + }); + } + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + } + + /** + * Read image file as base64 data URL via SFTP + */ + async readImage(path: string): Promise<{ + success: boolean; + dataUrl?: string; + mimeType?: string; + size?: number; + error?: string; + }> { + // Check file extension + const ext = path.toLowerCase().substring(path.lastIndexOf('.')); + if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) { + return { + success: false, + error: `Unsupported image format: ${ext}`, + }; + } + + const fullPath = this.resolveRemotePath(path); + const sftp = await this.getSftp(); + + return new Promise((resolve, reject) => { + sftp.open(fullPath, 'r', (err, handle) => { + if (err) { + reject(this.mapSftpError(err, fullPath)); + return; + } + + sftp.fstat(handle, (statErr, stats) => { + if (statErr) { + sftp.close(handle, () => {}); + reject(this.mapSftpError(statErr, fullPath)); + return; + } + + // Check file size limit (5MB for images) + const maxImageSize = 5 * 1024 * 1024; + if (stats.size > maxImageSize) { + sftp.close(handle, () => {}); + resolve({ + success: false, + error: `Image too large: ${stats.size} bytes (max ${maxImageSize})`, + }); + return; + } + + if (stats.size === 0) { + sftp.close(handle, () => {}); + resolve({ success: false, error: 'Image file is empty' }); + return; + } + + const buffer = Buffer.alloc(stats.size); + + sftp.read(handle, buffer, 0, stats.size, 0, (readErr) => { + sftp.close(handle, () => {}); + + if (readErr) { + reject(this.mapSftpError(readErr, fullPath)); + return; + } + + // Determine MIME type from extension + const mimeTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + }; + const mimeType = mimeTypes[ext] || 'application/octet-stream'; + + // Convert to base64 + const base64 = buffer.toString('base64'); + const dataUrl = `data:${mimeType};base64,${base64}`; + + resolve({ + success: true, + dataUrl, + mimeType, + size: stats.size, + }); + }); + }); + }); + }); + } + + // ─── Private utilities ──────────────────────────────────────────────────── + + /** + * Build absolute remote path from relative path + * Provides path traversal protection + */ + private resolveRemotePath(relPath: string): string { + // Normalize path separators to forward slashes + const normalized = relPath.replace(/\\/g, '/'); + + // Handle absolute paths (should not escape base) + if (normalized.startsWith('/')) { + const resolved = this.normalizePosixPath(normalized); + // Security: ensure resolved path is within remotePath base + if (!this.isWithinBase(resolved)) { + throw new FileSystemError( + 'Path traversal detected: path escapes base directory', + FileSystemErrorCodes.PATH_ESCAPE, + relPath + ); + } + return resolved; + } + + // Join with base path and normalize away any '.' segments (e.g. when relPath is '.') + const joined = `${this.remotePath}/${normalized}`.replace(/\/+/g, '/'); + const fullPath = this.normalizePosixPath(joined); + + // Security: ensure path is within basePath + if (!this.isWithinBase(fullPath)) { + throw new FileSystemError( + 'Path traversal detected: path escapes base directory', + FileSystemErrorCodes.PATH_ESCAPE, + relPath + ); + } + + return fullPath; + } + + /** Remove single-dot segments from a POSIX path (e.g. /a/./b → /a/b). */ + private normalizePosixPath(p: string): string { + const parts = p.split('/'); + const out: string[] = []; + for (const seg of parts) { + if (seg === '.') continue; + out.push(seg); + } + // Re-join and collapse any double slashes introduced by the filter + return out.join('/').replace(/\/+/g, '/') || '/'; + } + + /** + * Check if a path is within the base directory + */ + private isWithinBase(fullPath: string): boolean { + // Normalize both paths + const normalizedPath = fullPath.replace(/\/+/g, '/').replace(/\/$/, ''); + const normalizedBase = this.remotePath.replace(/\/+/g, '/').replace(/\/$/, ''); + + // Path must start with base path + return normalizedPath === normalizedBase || normalizedPath.startsWith(`${normalizedBase}/`); + } + + /** + * Get relative path from full remote path + */ + private relativePath(fullPath: string): string { + const normalized = fullPath.replace(/\\/g, '/').replace(/\/+/g, '/'); + const normalizedBase = normalizeRemoteBasePath(this.remotePath); + + if (normalized === normalizedBase) { + return ''; + } + + const prefix = normalizedBase === '/' ? '/' : `${normalizedBase}/`; + if (normalized.startsWith(prefix)) { + return normalized.substring(prefix.length); + } + + return normalized; + } + + /** + * Recursively ensure a remote directory exists + */ + private async ensureRemoteDir(sftp: SFTPWrapper, dirPath: string): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(dirPath, (err) => { + if (!err) { + resolve(); + return; + } + + const sftpErr = err as SftpError; + const msg = sftpErr.message ?? ''; + const lowerMsg = msg.toLowerCase(); + const code = sftpErr.code; + + const isAlreadyExists = + lowerMsg.includes('already exists') || + lowerMsg.includes('file exists') || + (code === SFTP_STATUS.FAILURE && (msg === 'Failure' || msg === '')); + const isMissingParent = + code === SFTP_STATUS.NO_SUCH_FILE || lowerMsg.includes('no such file'); + + if (isAlreadyExists) { + resolve(); + return; + } + + const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/')); + if ( + isMissingParent && + parentPath && + parentPath !== dirPath && + parentPath.length >= this.remotePath.length + ) { + this.ensureRemoteDir(sftp, parentPath) + .then(() => this.ensureRemoteDir(sftp, dirPath)) + .then(resolve) + .catch(reject); + } else { + reject(this.mapSftpError(err, dirPath)); + } + }); + }); + } + + /** + * Map SFTP error codes to FileSystemError + */ + private mapSftpError(error: unknown, path?: string): FileSystemError { + const sftpErr = error as SftpError; + const message = typeof sftpErr?.message === 'string' ? sftpErr.message : String(error); + const code = sftpErr?.code; + + // Map common SFTP error codes + if (code === SFTP_STATUS.NO_SUCH_FILE || message.includes('No such file')) { + return new FileSystemError( + `File or directory not found: ${path || message}`, + FileSystemErrorCodes.NOT_FOUND, + path + ); + } + + if (code === SFTP_STATUS.PERMISSION_DENIED || message.includes('Permission denied')) { + return new FileSystemError( + `Permission denied: ${path || message}`, + FileSystemErrorCodes.PERMISSION_DENIED, + path + ); + } + + if (message.includes('is a directory')) { + return new FileSystemError( + `Path is a directory: ${path || message}`, + FileSystemErrorCodes.IS_DIRECTORY, + path + ); + } + + if (message.includes('Not a directory')) { + return new FileSystemError( + `Path is not a directory: ${path || message}`, + FileSystemErrorCodes.NOT_DIRECTORY, + path + ); + } + + if (message.includes('connection') || message.includes('Connection')) { + return new FileSystemError( + `Connection error: ${message}`, + FileSystemErrorCodes.CONNECTION_ERROR, + path + ); + } + + // Default to unknown error + return new FileSystemError(`Filesystem error: ${message}`, FileSystemErrorCodes.UNKNOWN, path); + } + + watch( + callback: (events: FileWatchEvent[]) => void, + options: { debounceMs?: number } = {} + ): FileWatcher { + const interval = options.debounceMs ?? 4000; + let watched: string[] = []; + // Map from dirPath → previous entries (keyed by relative entry path) + const snapshots = new Map>(); + + const poll = async () => { + for (const dirPath of watched) { + let result: FileListResult | null = null; + try { + result = await this.list(dirPath, { includeHidden: true }); + } catch { + continue; + } + + const currMap = new Map(result.entries.map((e) => [e.path, e])); + const prevMap = snapshots.get(dirPath); + snapshots.set(dirPath, currMap); + + if (!prevMap) continue; + + const evts: FileWatchEvent[] = []; + for (const [p, e] of currMap) { + const prev = prevMap.get(p); + if (!prev) + evts.push({ + type: 'create', + entryType: e.type === 'dir' ? 'directory' : 'file', + path: p, + }); + else if (fileEntryMetadataChanged(prev, e)) + evts.push({ + type: 'modify', + entryType: e.type === 'dir' ? 'directory' : 'file', + path: p, + }); + } + for (const [p, e] of prevMap) { + if (!currMap.has(p)) + evts.push({ + type: 'delete', + entryType: e.type === 'dir' ? 'directory' : 'file', + path: p, + }); + } + if (evts.length) callback(evts); + } + }; + + const timer = setInterval(() => { + void poll(); + }, interval); + + return { + update(paths: string[]) { + watched = paths; + for (const p of snapshots.keys()) { + if (!paths.includes(p)) snapshots.delete(p); + } + }, + close() { + clearInterval(timer); + }, + }; + } +} + +function normalizeRemoteBasePath(path: string): string { + const normalized = path.replace(/\\/g, '/').replace(/\/+/g, '/'); + if (normalized === '/') return '/'; + return normalized.replace(/\/$/, ''); +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-paths.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-paths.ts new file mode 100644 index 0000000000..7d7c5a58df --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-paths.ts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import type { FileError } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; + +export function normalizeRemoteRootPath(rootPath: string): string { + const normalized = path.posix.normalize(rootPath.replace(/\\/g, '/')); + return path.posix.isAbsolute(normalized) ? normalized : path.posix.resolve('/', normalized); +} + +export function normalizeRemoteAbsolutePath(value: string | undefined): Result { + const raw = value ?? ''; + if (raw.includes('\0')) { + return err({ type: 'invalid-path', path: raw, message: 'Path contains a null byte' }); + } + const normalized = path.posix.normalize(raw.replace(/\\/g, '/')); + if (!path.posix.isAbsolute(normalized)) { + return err({ type: 'invalid-path', path: raw, message: 'Path must be absolute' }); + } + return ok(normalized); +} + +export function toRemoteAbsolutePath(rootPath: string, value: string): string { + const normalized = value.replace(/\\/g, '/'); + if (path.posix.isAbsolute(normalized)) return normalizeRemoteRootPath(normalized); + return normalizeRemoteRootPath(path.posix.join(rootPath, normalized)); +} + +export function containsRemotePath(parent: string, child: string): boolean { + const rel = path.posix.relative(parent, child); + return rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)); +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-remote-enumerate.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-remote-enumerate.ts new file mode 100644 index 0000000000..db8657f150 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-remote-enumerate.ts @@ -0,0 +1,114 @@ +import { StringDecoder } from 'node:string_decoder'; +import { IGNORED_PATH_SEGMENTS, isIgnored } from '@emdash/core/files'; +import type { ClientChannel } from 'ssh2'; +import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { quoteShellArg } from '@main/utils/shellEscape'; +import { toRemoteAbsolutePath } from './ssh-paths'; + +export async function* enumerateRemoteWorkspace( + proxy: SshClientProxy, + rootPath: string +): AsyncIterable { + for await (const rawPath of execRemoteNulFields(proxy, buildRemoteEnumerationCommand(rootPath))) { + const absPath = toRemoteAbsolutePath(rootPath, rawPath); + if (isIgnored(absPath)) continue; + yield absPath; + } +} + +export function buildFindPruneExpression(): string { + const ignoredNames = IGNORED_PATH_SEGMENTS.map((name) => `-name ${quoteShellArg(name)}`).join( + ' -o ' + ); + return ignoredNames ? `\\( ${ignoredNames} \\) -prune -o ` : ''; +} + +function buildRemoteEnumerationCommand(rootPath: string): string { + const pruneExpression = buildFindPruneExpression(); + const enumerateScript = ` +for p do + rel=\${p#./} + [ "$rel" = "." ] && continue + printf '%s\\0' "$rel" +done +`.trim(); + + return [ + `cd ${quoteShellArg(rootPath)} || exit 1`, + `find . ${pruneExpression}-type f -exec sh -c ${quoteShellArg(enumerateScript)} sh {} +`, + ].join('\n'); +} + +async function* execRemoteNulFields(proxy: SshClientProxy, command: string): AsyncIterable { + const profile = await proxy.getRemoteShellProfile(); + const fullCommand = buildRemoteShellCommand(profile, command); + const decoder = new StringDecoder('utf8'); + const queue: string[] = []; + let stream: ClientChannel | undefined; + let pending = ''; + let stderr = ''; + let done = false; + let error: unknown; + let notify: (() => void) | undefined; + + const wake = () => { + notify?.(); + notify = undefined; + }; + const waitForEvent = () => + new Promise((resolve) => { + notify = resolve; + }); + + await new Promise((resolve, reject) => { + proxy.exec(fullCommand, (err, channel) => { + if (err) { + reject(err); + return; + } + + stream = channel; + channel.on('data', (chunk: Buffer) => { + const text = pending + decoder.write(chunk); + const parts = text.split('\0'); + pending = parts.pop() ?? ''; + queue.push(...parts); + wake(); + }); + channel.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + channel.on('close', (exitCode: number | null) => { + const tail = pending + decoder.end(); + if (tail) queue.push(tail); + pending = ''; + if ((exitCode ?? 0) !== 0) { + error = new Error(stderr.trim() || `Remote command exited with code ${exitCode}`); + } + done = true; + wake(); + }); + channel.on('error', (streamError: Error) => { + error = streamError; + done = true; + wake(); + }); + resolve(); + }); + }); + + try { + while (!done || queue.length > 0) { + while (queue.length > 0) { + const item = queue.shift(); + if (item) yield item; + } + if (error) throw error; + if (!done) await waitForEvent(); + } + if (error) throw error; + } finally { + if (!done) stream?.destroy(); + } +} diff --git a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts index 4f7c708d57..5c55170dd1 100644 --- a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts +++ b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts @@ -1,4 +1,5 @@ -import { FilesRuntime } from '@emdash/core/files'; +import nodePath from 'node:path'; +import { contains, FilesRuntime } from '@emdash/core/files'; import { GitRuntime } from '@emdash/core/git'; import { ResourceMap } from '@emdash/core/lib'; import type { Lease } from '@emdash/shared'; @@ -7,14 +8,32 @@ import { log } from '@main/lib/logger'; import { ConstantHealthSource } from './health'; import { LegacySshFilesRuntime } from './legacy/ssh-files'; import { LegacySshGitRuntime } from './legacy/ssh-git'; -import { machineKey, type MachineRef, type MachineRuntime, type RuntimeManager } from './types'; +import { + machineKey, + type MachineRef, + type MachineRuntime, + type RuntimeManager, + type RuntimePath, +} from './types'; + +const nativeRuntimePath: RuntimePath = { + join: (...parts) => nodePath.join(...parts), + dirname: (p) => nodePath.dirname(p), + basename: (p) => nodePath.basename(p), + isAbsolute: (p) => nodePath.isAbsolute(p), + relative: (from, to) => nodePath.relative(from, to), + contains, +}; class LocalMachineRuntime implements MachineRuntime { readonly machine: MachineRef = { kind: 'local' }; - readonly files = new FilesRuntime({ - onError: (context, error) => - log.warn('Local file runtime background error', { context, error: String(error) }), - }); + readonly files = Object.assign( + new FilesRuntime({ + onError: (context, error) => + log.warn('Local file runtime background error', { context, error: String(error) }), + }), + { path: nativeRuntimePath } + ); readonly git = new GitRuntime({ onError: (context, error) => log.warn('Local GitRuntime background error', { context, error: String(error) }), diff --git a/apps/emdash-desktop/src/main/core/runtime/types.ts b/apps/emdash-desktop/src/main/core/runtime/types.ts index b53d484f26..08b3ccdeea 100644 --- a/apps/emdash-desktop/src/main/core/runtime/types.ts +++ b/apps/emdash-desktop/src/main/core/runtime/types.ts @@ -1,4 +1,5 @@ -import type { IFilesRuntime } from '@emdash/core/files'; +/** Transitional named import, remove when workspace server runs */ +import type { IFilesRuntime as CoreFilesRuntime } from '@emdash/core/files'; import type { IGitRuntime } from '@emdash/core/git'; import type { IDisposable, Lease, Unsubscribe } from '@emdash/shared'; @@ -14,6 +15,22 @@ export interface HealthSource { subscribe(cb: (health: RuntimeHealth) => void): Unsubscribe; } +/** + * Transitional: per-runtime path algebra bound to the machine that owns the files. + * Removed when the workspace server moves remote path math into core. + */ +export type RuntimePath = { + join(...parts: string[]): string; + dirname(path: string): string; + basename(path: string): string; + isAbsolute(path: string): boolean; + relative(from: string, to: string): string; + contains(parent: string, child: string): boolean; +}; + +/** Transitional named import, remove when workspace server runs */ +export type IFilesRuntime = CoreFilesRuntime & { readonly path: RuntimePath }; + export interface MachineRuntime extends IDisposable { readonly machine: MachineRef; readonly files: IFilesRuntime; From 91a6954c2ade27fdd511a7d97546c8a46ff12e34 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:13:35 -0700 Subject: [PATCH 23/37] feat(workspaces): wire core file capabilities --- .../project-setup/repository-setup.test.ts | 85 ++++--- .../core/project-setup/repository-setup.ts | 71 +++--- .../core/projects/create-project-provider.ts | 155 ++++++++---- .../operations/create-local-project.ts | 30 ++- .../projects/operations/create-ssh-project.ts | 59 ++--- .../projects/operations/createProject.test.ts | 83 +++++-- .../src/main/core/projects/path-utils.ts | 9 +- .../src/main/core/projects/project-manager.ts | 3 +- .../main/core/projects/project-provider.ts | 65 ++++- .../hosts/local-worktree-host.test.ts | 125 ---------- .../worktrees/hosts/local-worktree-host.ts | 198 --------------- .../worktrees/hosts/ssh-worktree-host.test.ts | 47 ---- .../worktrees/hosts/ssh-worktree-host.ts | 68 ----- .../projects/worktrees/hosts/worktree-host.ts | 19 -- .../worktrees/worktree-service.test.ts | 232 ++++++++++++------ .../projects/worktrees/worktree-service.ts | 189 ++++++++------ .../src/main/core/tasks/task-builder.ts | 5 +- .../workspaces/byoi/provision-byoi-task.ts | 13 +- .../workspaces/project-settings-controller.ts | 18 +- .../main/core/workspaces/recovery-strategy.ts | 24 +- .../setup-steps/copy-preserved-files.ts | 76 ++++-- .../workspaces/setup-steps/git-fetch.test.ts | 8 +- .../workspaces/setup-steps/step-context.ts | 11 +- .../workspace-bootstrap-service.db.test.ts | 8 +- .../workspaces/workspace-bootstrap-service.ts | 36 +-- .../main/core/workspaces/workspace-factory.ts | 64 ++--- .../workspaces/workspace-registry.test.ts | 15 +- .../core/workspaces/workspace-registry.ts | 23 +- .../src/main/core/workspaces/workspace.ts | 6 +- 29 files changed, 786 insertions(+), 959 deletions(-) delete mode 100644 apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts delete mode 100644 apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.ts delete mode 100644 apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts delete mode 100644 apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts delete mode 100644 apps/emdash-desktop/src/main/core/projects/worktrees/hosts/worktree-host.ts diff --git a/apps/emdash-desktop/src/main/core/project-setup/repository-setup.test.ts b/apps/emdash-desktop/src/main/core/project-setup/repository-setup.test.ts index 863610b670..8750e78e29 100644 --- a/apps/emdash-desktop/src/main/core/project-setup/repository-setup.test.ts +++ b/apps/emdash-desktop/src/main/core/project-setup/repository-setup.test.ts @@ -1,11 +1,12 @@ +import { ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cloneProjectRepository, initializeProjectRepository } from './repository-setup'; const mocks = vi.hoisted(() => { const cloneRepository = vi.fn(); const commit = vi.fn(); - const connect = vi.fn(); const exists = vi.fn(); + const fileSystem = vi.fn(); const getHead = vi.fn(); const mkdir = vi.fn(); const openWorktree = vi.fn(); @@ -14,57 +15,61 @@ const mocks = vi.hoisted(() => { const releaseWorktree = vi.fn(); const runtimeAcquire = vi.fn(); const stage = vi.fn(); + const stat = vi.fn(); const write = vi.fn(); return { cloneRepository, commit, - connect, exists, + fileSystem, getHead, - localFileSystem: vi.fn(function () { - return { exists, mkdir, write }; - }), mkdir, openWorktree, publishBranch, releaseRuntime, releaseWorktree, runtimeAcquire, - sshFileSystem: vi.fn(function () { - return { exists, mkdir, write }; - }), stage, + stat, write, }; }); -vi.mock('@main/core/fs/impl/local-fs', () => ({ - LocalFileSystem: mocks.localFileSystem, -})); - -vi.mock('@main/core/fs/impl/ssh-fs', () => ({ - SshFileSystem: mocks.sshFileSystem, -})); - vi.mock('@main/core/runtime/runtime-manager', () => ({ runtimeManager: { acquire: mocks.runtimeAcquire, }, })); -vi.mock('@main/core/ssh/lifecycle/production-ssh-connection-manager', () => ({ - sshConnectionManager: { - connect: mocks.connect, - }, -})); +function makeFilesRuntime() { + return { + path: { + join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'), + dirname: (value: string) => value.slice(0, value.lastIndexOf('/')) || '/', + basename: (value: string) => value.slice(value.lastIndexOf('/') + 1), + isAbsolute: (value: string) => value.startsWith('/'), + relative: (_from: string, to: string) => to, + contains: () => true, + }, + fileSystem: mocks.fileSystem.mockImplementation(() => + ok({ + exists: mocks.exists, + mkdir: mocks.mkdir, + stat: mocks.stat, + writeText: mocks.write, + }) + ), + }; +} describe('cloneProjectRepository', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.mkdir.mockResolvedValue(undefined); + mocks.exists.mockResolvedValue(ok(false)); + mocks.mkdir.mockResolvedValue(ok()); mocks.runtimeAcquire.mockResolvedValue({ - value: { git: { cloneRepository: mocks.cloneRepository } }, + value: { files: makeFilesRuntime(), git: { cloneRepository: mocks.cloneRepository } }, release: mocks.releaseRuntime, }); }); @@ -82,8 +87,8 @@ describe('cloneProjectRepository', () => { }) ).resolves.toEqual({ success: true }); - expect(mocks.localFileSystem).toHaveBeenCalledWith('/work'); - expect(mocks.mkdir).toHaveBeenCalledWith('.', { recursive: true }); + expect(mocks.fileSystem).toHaveBeenCalledWith(); + expect(mocks.mkdir).toHaveBeenCalledWith('/work', { recursive: true }); expect(mocks.runtimeAcquire).toHaveBeenCalledWith({ kind: 'local' }); expect(mocks.cloneRepository).toHaveBeenCalledWith( 'https://github.com/acme/repo.git', @@ -92,9 +97,7 @@ describe('cloneProjectRepository', () => { expect(mocks.releaseRuntime).toHaveBeenCalledOnce(); }); - it('uses the ssh filesystem and ssh machine runtime for remote clones', async () => { - const proxy = { connectionId: 'conn-1' }; - mocks.connect.mockResolvedValue(proxy); + it('uses the ssh machine runtime for remote clones', async () => { mocks.cloneRepository.mockResolvedValue({ success: true, data: { kind: 'repository', rootPath: '/home/jona/repo', baseRef: 'main' }, @@ -108,8 +111,6 @@ describe('cloneProjectRepository', () => { }) ).resolves.toEqual({ success: true }); - expect(mocks.connect).toHaveBeenCalledWith('conn-1'); - expect(mocks.sshFileSystem).toHaveBeenCalledWith(proxy, '/home/jona'); expect(mocks.runtimeAcquire).toHaveBeenCalledWith({ kind: 'ssh', connectionId: 'conn-1' }); expect(mocks.releaseRuntime).toHaveBeenCalledOnce(); }); @@ -136,8 +137,8 @@ describe('cloneProjectRepository', () => { describe('initializeProjectRepository', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.exists.mockResolvedValue(true); - mocks.write.mockResolvedValue({ success: true, bytesWritten: 20 }); + mocks.stat.mockResolvedValue(ok({ path: '/work/repo', type: 'directory' })); + mocks.write.mockResolvedValue(ok({ bytesWritten: 20 })); mocks.stage.mockResolvedValue({ success: true, data: {} }); mocks.commit.mockResolvedValue({ success: true, data: { hash: 'abc123', sequences: {} } }); mocks.getHead.mockResolvedValue({ kind: 'branch', name: 'main', oid: 'abc123' }); @@ -155,7 +156,7 @@ describe('initializeProjectRepository', () => { release: mocks.releaseWorktree, }); mocks.runtimeAcquire.mockResolvedValue({ - value: { git: { openWorktree: mocks.openWorktree } }, + value: { files: makeFilesRuntime(), git: { openWorktree: mocks.openWorktree } }, release: mocks.releaseRuntime, }); }); @@ -169,12 +170,12 @@ describe('initializeProjectRepository', () => { }) ).resolves.toEqual({ success: true }); - expect(mocks.localFileSystem).toHaveBeenCalledWith('/work/repo'); - expect(mocks.exists).toHaveBeenCalledWith('.'); - expect(mocks.write).toHaveBeenCalledWith('README.md', '# Repo\n\nDescription\n'); + expect(mocks.fileSystem).toHaveBeenCalledWith(); + expect(mocks.stat).toHaveBeenCalledWith('/work/repo'); + expect(mocks.write).toHaveBeenCalledWith('/work/repo/README.md', '# Repo\n\nDescription\n'); expect(mocks.runtimeAcquire).toHaveBeenCalledWith({ kind: 'local' }); expect(mocks.openWorktree).toHaveBeenCalledWith('/work/repo'); - expect(mocks.stage).toHaveBeenCalledWith(['README.md']); + expect(mocks.stage).toHaveBeenCalledWith(['/work/repo/README.md']); expect(mocks.commit).toHaveBeenCalledWith('Initial commit'); expect(mocks.publishBranch).toHaveBeenCalledWith('main', 'origin'); expect(mocks.releaseWorktree).toHaveBeenCalledOnce(); @@ -191,12 +192,15 @@ describe('initializeProjectRepository', () => { }) ).resolves.toEqual({ success: true }); - expect(mocks.write).toHaveBeenCalledWith('README.md', '# Repo\n'); + expect(mocks.write).toHaveBeenCalledWith('/work/repo/README.md', '# Repo\n'); expect(mocks.publishBranch).toHaveBeenCalledWith('trunk', 'origin'); }); it('returns a setup failure when the target path does not exist', async () => { - mocks.exists.mockResolvedValue(false); + mocks.stat.mockResolvedValue({ + success: false, + error: { type: 'fs-error', path: '/work/repo', message: 'missing', code: 'ENOENT' }, + }); await expect( initializeProjectRepository({ @@ -205,7 +209,8 @@ describe('initializeProjectRepository', () => { }) ).resolves.toEqual({ success: false, error: 'Local path does not exist' }); - expect(mocks.runtimeAcquire).not.toHaveBeenCalled(); + expect(mocks.runtimeAcquire).toHaveBeenCalledWith({ kind: 'local' }); + expect(mocks.releaseRuntime).toHaveBeenCalledOnce(); }); it('returns a setup failure when the initial commit fails', async () => { diff --git a/apps/emdash-desktop/src/main/core/project-setup/repository-setup.ts b/apps/emdash-desktop/src/main/core/project-setup/repository-setup.ts index 1ae33cb4f5..30b91d428e 100644 --- a/apps/emdash-desktop/src/main/core/project-setup/repository-setup.ts +++ b/apps/emdash-desktop/src/main/core/project-setup/repository-setup.ts @@ -1,11 +1,10 @@ import path from 'node:path'; +import type { FileError, IFileSystem } from '@emdash/core/files'; import type { CloneRepositoryError, GitHeadModel, IGitWorktree } from '@emdash/core/git'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; +import { ensureAbsoluteDir, openFileSystem, statAbsolute } from '@main/core/runtime/files-helpers'; import { runtimeManager } from '@main/core/runtime/runtime-manager'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import type { MachineRef } from '@main/core/runtime/types'; -import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; export type GitRepositorySetupResult = { success: true } | { success: false; error: string }; @@ -30,14 +29,6 @@ function parentPathForMachine(targetPath: string, machine: MachineRef): string { return machine.kind === 'ssh' ? path.posix.dirname(targetPath) : path.dirname(targetPath); } -async function createProjectFs(root: string, machine: MachineRef): Promise { - if (machine.kind === 'ssh') { - const proxy = await sshConnectionManager.connect(machine.connectionId); - return new SshFileSystem(proxy, root); - } - return new LocalFileSystem(root); -} - function cloneRepositoryErrorMessage(error: CloneRepositoryError): string { switch (error.type) { case 'target_exists': @@ -49,6 +40,10 @@ function cloneRepositoryErrorMessage(error: CloneRepositoryError): string { } } +function fileErrorMessage(error: FileError): string { + return error.message; +} + function initialReadmeContent(name: string, description: string | undefined): string { return description ? `# ${name}\n\n${description}\n` : `# ${name}\n`; } @@ -75,11 +70,15 @@ export async function cloneProjectRepository( params: CloneProjectRepositoryParams ): Promise { const machine = machineForConnection(params.connectionId); - const parentFs = await createProjectFs(parentPathForMachine(params.targetPath, machine), machine); - await parentFs.mkdir('.', { recursive: true }); - const runtimeLease = await runtimeManager.acquire(machine); try { + const madeParentDir = await ensureAbsoluteDir( + runtimeLease.value.files, + parentPathForMachine(params.targetPath, machine) + ); + if (!madeParentDir.success) { + return { success: false, error: fileErrorMessage(madeParentDir.error) }; + } const result = await runtimeLease.value.git.cloneRepository( params.repositoryUrl, params.targetPath @@ -97,25 +96,23 @@ export async function initializeProjectRepository( params: InitializeProjectRepositoryParams ): Promise { const machine = machineForConnection(params.connectionId); - const projectFs = await createProjectFs(params.targetPath, machine); - - if (!(await projectFs.exists('.'))) { - return { success: false, error: 'Local path does not exist' }; - } - - const writeResult = await projectFs.write( - 'README.md', - initialReadmeContent(params.name, params.description) - ); - if (!writeResult.success) { - return { success: false, error: writeResult.error || 'Failed to write README.md' }; - } - const runtimeLease = await runtimeManager.acquire(machine); try { + const projectFs = await ensureProjectDirectory(runtimeLease.value.files, params.targetPath); + if (!projectFs.success) return { success: false, error: projectFs.error }; + + const readmePath = runtimeLease.value.files.path.join(params.targetPath, 'README.md'); + const writeResult = await projectFs.data.writeText( + readmePath, + initialReadmeContent(params.name, params.description) + ); + if (!writeResult.success) { + return { success: false, error: fileErrorMessage(writeResult.error) }; + } + const worktreeLease = await runtimeLease.value.git.openWorktree(params.targetPath); try { - const stageResult = await worktreeLease.value.stage(['README.md']); + const stageResult = await worktreeLease.value.stage([readmePath]); if (!stageResult.success) return { success: false, error: stageResult.error.message }; const commitResult = await worktreeLease.value.commit('Initial commit'); if (!commitResult.success) return { success: false, error: commitResult.error.message }; @@ -127,3 +124,17 @@ export async function initializeProjectRepository( await runtimeLease.release(); } } + +async function ensureProjectDirectory( + files: IFilesRuntime, + targetPath: string +): Promise<{ success: true; data: IFileSystem } | { success: false; error: string }> { + const stat = await statAbsolute(files, targetPath); + if (!stat.success) return { success: false, error: 'Local path does not exist' }; + if (stat.data.type !== 'directory') { + return { success: false, error: `Path is not a directory: ${targetPath}` }; + } + const opened = openFileSystem(files); + if (!opened.success) return { success: false, error: fileErrorMessage(opened.error) }; + return { success: true, data: opened.data }; +} diff --git a/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts b/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts index 0140fb1227..2ba18a75ec 100644 --- a/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts @@ -1,19 +1,22 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; import type { IGitRepository, IGitRuntime } from '@emdash/core/git'; -import type { Lease } from '@emdash/shared'; +import { err, ok, type Lease, type Result } from '@emdash/shared'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; import { GitRepositoryService } from '@main/core/git/repository/service'; import { projectGitHubAccountBackfillService } from '@main/core/github/services/project-github-account-backfill-instance'; +import { + absoluteDirectoryFileSystem, + ensureAbsoluteDir, + openFileSystem, +} from '@main/core/runtime/files-helpers'; import { runtimeManager } from '@main/core/runtime/runtime-manager'; import type { MachineRef, MachineRuntime } from '@main/core/runtime/types'; import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import type { SshConnectionManagerEvent } from '@main/core/ssh/lifecycle/ssh-connection-manager'; +import { LocalWorkspaceSetupExecutor } from '@main/core/workspaces/local-workspace-setup-executor'; +import { applyRecovery } from '@main/core/workspaces/recovery-strategy'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { gitRepoUpdateChannel } from '@shared/core/git/events'; @@ -23,38 +26,48 @@ import { ProjectProvider, type ProjectProviderTransport } from './project-provid import type { ProjectSettingsProvider } from './settings/provider'; import { LocalProjectSettingsProvider } from './settings/providers/local-project-settings-provider'; import { SshProjectSettingsProvider } from './settings/providers/ssh-project-settings-provider'; -import { LocalWorktreeHost } from './worktrees/hosts/local-worktree-host'; -import { SshWorktreeHost } from './worktrees/hosts/ssh-worktree-host'; -import type { WorktreeHost } from './worktrees/hosts/worktree-host'; import { WorktreeService } from './worktrees/worktree-service'; -export async function createProvider(project: LocalProject | SshProject): Promise { - if (project.type === 'ssh') { - return createSshProvider(project); - } - return createLocalProvider(project); +export type CreateProviderError = { message: string }; + +export async function createProvider( + project: LocalProject | SshProject +): Promise> { + return project.type === 'ssh' ? createSshProvider(project) : createLocalProvider(project); } -async function createLocalProvider(project: LocalProject): Promise { - const localFs = new LocalFileSystem(project.path); +async function createLocalProvider( + project: LocalProject +): Promise> { const ctx = new LocalExecutionContext({ root: project.path }); const projectMachine: MachineRef = { kind: 'local' }; const runtimeLease = await runtimeManager.acquire(projectMachine); - const settings = new LocalProjectSettingsProvider(project.id, project.path, project.baseRef); - try { + const projectFileSystem = openFileSystem(runtimeLease.value.files); + if (!projectFileSystem.success) { + await runtimeLease.release(); + return err({ message: projectFileSystem.error.message }); + } + const settings = new LocalProjectSettingsProvider( + project.id, + project.path, + project.baseRef, + projectFileSystem.data + ); await runLegacyProjectSettingsMigration(settings, runtimeLease.value.git, project.path); const worktreeDirectory = await settings.getWorktreeDirectory(); - await fs.promises.mkdir(worktreeDirectory, { recursive: true }); - const worktreeHost = await LocalWorktreeHost.create({ - allowedRoots: [project.path, worktreeDirectory], - }); + const madeWorktreeDir = await ensureAbsoluteDir(runtimeLease.value.files, worktreeDirectory); + if (!madeWorktreeDir.success) { + await runtimeLease.release(); + return err({ message: madeWorktreeDir.error.message }); + } const resolveWorktreePoolPath = async () => { const directory = await settings.getWorktreeDirectory(); - await fs.promises.mkdir(directory, { recursive: true }); - await worktreeHost.allowRoot(directory); - return path.join(directory, safePathSegment(project.name, project.id)); + return runtimeLease.value.files.path.join( + directory, + safePathSegment(project.name, project.id) + ); }; const repoLease = await runtimeLease.value.git.openRepository(project.path); @@ -69,42 +82,47 @@ async function createLocalProvider(project: LocalProject): Promise {}, runtimeLease, repoLease ); await backfillGitHubAccount(provider); - return provider; + return ok(provider); } catch (error) { await repoLease.release(); throw error; } } catch (error) { await runtimeLease.release(); - throw error; + return err(toCreateProviderError(error)); } } -async function createSshProvider(project: SshProject): Promise { +async function createSshProvider( + project: SshProject +): Promise> { try { const proxy = await sshConnectionManager.connect(project.connectionId); - const rootFs = new SshFileSystem(proxy, '/'); - const projectFs = new SshFileSystem(proxy, project.path); const baseCtx = new SshExecutionContext(proxy, { root: project.path }); const ctx = baseCtx; const projectMachine: MachineRef = { kind: 'ssh', connectionId: project.connectionId }; const runtimeLease = await runtimeManager.acquire(projectMachine); + const projectFileSystem = openFileSystem(runtimeLease.value.files); + if (!projectFileSystem.success) { + await runtimeLease.release(); + return err({ message: projectFileSystem.error.message }); + } const settings = new SshProjectSettingsProvider( project.id, - projectFs, + projectFileSystem.data, project.baseRef, - rootFs, + absoluteDirectoryFileSystem(runtimeLease.value.files), project.path, baseCtx ); @@ -112,11 +130,14 @@ async function createSshProvider(project: SshProject): Promise try { await runLegacyProjectSettingsMigration(settings, runtimeLease.value.git, project.path); const worktreeDirectory = await settings.getWorktreeDirectory(); - const worktreePoolPath = path.posix.join(worktreeDirectory, project.name); - const worktreeHost = new SshWorktreeHost(rootFs); - await worktreeHost.mkdirAbsolute(worktreePoolPath, { recursive: true }); + const worktreePoolPath = runtimeLease.value.files.path.join(worktreeDirectory, project.name); + const madeWorktreePool = await ensureAbsoluteDir(runtimeLease.value.files, worktreePoolPath); + if (!madeWorktreePool.success) { + await runtimeLease.release(); + return err({ message: madeWorktreePool.error.message }); + } const resolveWorktreePoolPath = async () => - path.posix.join(await settings.getWorktreeDirectory(), project.name); + runtimeLease.value.files.path.join(await settings.getWorktreeDirectory(), project.name); let provider: ProjectProvider | undefined; const handler = (evt: SshConnectionManagerEvent) => { @@ -140,9 +161,9 @@ async function createSshProvider(project: SshProject): Promise defaultWorkspaceMachine: projectMachine, ctx, }, - projectFs, + runtimeLease.value.files, + projectFileSystem.data, settings, - worktreeHost, resolveWorktreePoolPath, dispose, runtimeLease, @@ -153,7 +174,7 @@ async function createSshProvider(project: SshProject): Promise // Wire reconnect handler after provider is built so gitRepositoryFetchService is available. sshConnectionManager.on('connection-event', handler); - return provider; + return ok(provider); } catch (error) { await repoLease.release(); throw error; @@ -168,10 +189,14 @@ async function createSshProvider(project: SshProject): Promise error: error instanceof Error ? error.message : String(error), }); sshConnectionManager.reportChannelError(project.connectionId, error); - throw error; + return err(toCreateProviderError(error)); } } +function toCreateProviderError(error: unknown): CreateProviderError { + return { message: error instanceof Error ? error.message : String(error) }; +} + async function runLegacyProjectSettingsMigration( settings: LocalProjectSettingsProvider | SshProjectSettingsProvider, git: IGitRuntime, @@ -203,9 +228,9 @@ function buildProvider( ProjectProviderTransport, 'kind' | 'projectMachine' | 'defaultWorkspaceType' | 'defaultWorkspaceMachine' | 'ctx' >, - projectFs: FileSystemProvider, + files: MachineRuntime['files'], + projectFileSystem: IFileSystem, settings: ProjectSettingsProvider, - worktreeHost: WorktreeHost, resolveWorktreePoolPath: () => Promise, dispose: () => void | Promise, runtimeLease: Lease, @@ -213,21 +238,44 @@ function buildProvider( ): ProjectProvider { const { ctx } = transportMeta; - const transport: ProjectProviderTransport = { - ...transportMeta, - fs: projectFs, - settings, - worktreeHost, - }; - const gitRepository = new GitRepositoryService(repoLease.value, settings); const worktreeService = new WorktreeService({ repoPath, projectSettings: settings, ctx, - host: worktreeHost, + files, resolveWorktreePoolPath, }); + const transport: ProjectProviderTransport = { + ...transportMeta, + fileSystem: projectFileSystem, + projectConfigPath: files.path.join(repoPath, '.emdash.json'), + resolveProjectPath: (relativePath) => files.path.join(repoPath, relativePath), + configPathForDirectory: (directoryPath) => files.path.join(directoryPath, '.emdash.json'), + runWorkspaceSetup: async ({ spec, worktreePoolPath }) => { + const stepCtx = { + ctx, + repoPath, + worktreePoolPath, + files, + projectSettings: settings, + worktreeService, + }; + const executor = new LocalWorkspaceSetupExecutor(stepCtx); + let setupResult = await executor.execute(spec); + if (!setupResult.success) { + const recovery = await applyRecovery(setupResult.error, stepCtx); + + if (recovery.kind === 'resolved') { + setupResult = ok({ path: recovery.path, warnings: [] }); + } else if (recovery.kind === 'retry') { + setupResult = await executor.execute(spec); + } + } + return setupResult; + }, + settings, + }; const gitRepositoryFetchService = new GitRepositoryFetchService(gitRepository, () => gitRepository.getBaseRemote() ); @@ -242,7 +290,7 @@ function buildProvider( await runtimeLease.release(); }; - return new ProjectProvider( + const provider = new ProjectProvider( projectId, repoPath, transport, @@ -255,4 +303,5 @@ function buildProvider( await dispose(); } ); + return provider; } diff --git a/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts b/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts index fd85df02da..2f02f38db9 100644 --- a/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts +++ b/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts @@ -1,8 +1,10 @@ import { randomUUID } from 'node:crypto'; +import { isFileNotFoundCode } from '@emdash/core/files'; import { err, ok, withLease } from '@emdash/shared'; import { sql } from 'drizzle-orm'; import { projectEvents } from '@main/core/projects/project-events'; import { projectManager } from '@main/core/projects/project-manager'; +import { statAbsolute } from '@main/core/runtime/files-helpers'; import { runtimeManager } from '@main/core/runtime/runtime-manager'; import { db } from '@main/db/client'; import { projects } from '@main/db/schema'; @@ -84,20 +86,24 @@ export async function createLocalProject( } export async function getLocalProjectPathStatus(path: string): Promise { - const directoryStatus = getDirectoryStatus(path); - if (directoryStatus.kind === 'inspect-failed') { - return { - isDirectory: false, - isGitRepo: false, - error: { type: 'inspect-failed', path, message: directoryStatus.message }, - }; - } - if (directoryStatus.kind !== 'directory') { - return { isDirectory: false, isGitRepo: false }; - } - const runtimeLease = await runtimeManager.acquire({ kind: 'local' }); try { + const pathEntry = await statAbsolute(runtimeLease.value.files, path); + if (!pathEntry.success) { + const code = 'code' in pathEntry.error ? pathEntry.error.code : undefined; + if (isFileNotFoundCode(code)) { + return { isDirectory: false, isGitRepo: false }; + } + return { + isDirectory: false, + isGitRepo: false, + error: { type: 'inspect-failed', path, message: pathEntry.error.message }, + }; + } + if (pathEntry.data.type !== 'directory') { + return { isDirectory: false, isGitRepo: false }; + } + const inspection = await runtimeLease.value.git.inspectPath(path); if (inspection.kind === 'inspect-failed') { return { diff --git a/apps/emdash-desktop/src/main/core/projects/operations/create-ssh-project.ts b/apps/emdash-desktop/src/main/core/projects/operations/create-ssh-project.ts index 3a47ff2fc9..2ed151cbb8 100644 --- a/apps/emdash-desktop/src/main/core/projects/operations/create-ssh-project.ts +++ b/apps/emdash-desktop/src/main/core/projects/operations/create-ssh-project.ts @@ -1,11 +1,10 @@ import { randomUUID } from 'node:crypto'; -import { err, ok, withLease } from '@emdash/shared'; +import { err, ok } from '@emdash/shared'; import { sql } from 'drizzle-orm'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { projectEvents } from '@main/core/projects/project-events'; import { projectManager } from '@main/core/projects/project-manager'; +import { statAbsolute } from '@main/core/runtime/files-helpers'; import { runtimeManager } from '@main/core/runtime/runtime-manager'; -import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import { db } from '@main/db/client'; import { projects } from '@main/db/schema'; import { log } from '@main/lib/logger'; @@ -24,26 +23,32 @@ export type CreateSshProjectParams = { export async function createSshProject( params: CreateSshProjectParams ): Promise { - const sshProxy = await sshConnectionManager.connect(params.connectionId); + const runtimeLease = await runtimeManager.acquire({ + kind: 'ssh', + connectionId: params.connectionId, + }); - const sshFs = new SshFileSystem(sshProxy, params.path); - const pathEntry = await sshFs.stat(''); - if (!pathEntry || pathEntry.type !== 'dir') { - return err({ - type: 'invalid-directory', - path: params.path, - message: 'Invalid directory', - }); + let gitInfo; + try { + const pathEntry = await statAbsolute(runtimeLease.value.files, params.path); + if (!pathEntry.success || pathEntry.data.type !== 'directory') { + return err({ + type: 'invalid-directory', + path: params.path, + message: 'Invalid directory', + }); + } + + const repositoryResult = await ensureProjectRepository( + runtimeLease.value.git, + params.path, + params.initGitRepository + ); + if (!repositoryResult.success) return repositoryResult; + gitInfo = repositoryResult.data; + } finally { + await runtimeLease.release(); } - const repositoryResult = await withLease( - runtimeManager.acquire({ - kind: 'ssh', - connectionId: params.connectionId, - }), - (runtime) => ensureProjectRepository(runtime.git, params.path, params.initGitRepository) - ); - if (!repositoryResult.success) return repositoryResult; - const gitInfo = repositoryResult.data; const [row] = await db .insert(projects) @@ -91,15 +96,13 @@ export async function getSshProjectPathStatus( connectionId: string ): Promise { try { - const sshProxy = await sshConnectionManager.connect(connectionId); - const sshFs = new SshFileSystem(sshProxy, path); - const pathEntry = await sshFs.stat(''); - if (!pathEntry || pathEntry.type !== 'dir') { - return { isDirectory: false, isGitRepo: false }; - } - const runtimeLease = await runtimeManager.acquire({ kind: 'ssh', connectionId }); try { + const pathEntry = await statAbsolute(runtimeLease.value.files, path); + if (!pathEntry.success || pathEntry.data.type !== 'directory') { + return { isDirectory: false, isGitRepo: false }; + } + const inspection = await runtimeLease.value.git.inspectPath(path); if (inspection.kind === 'inspect-failed') { return { diff --git a/apps/emdash-desktop/src/main/core/projects/operations/createProject.test.ts b/apps/emdash-desktop/src/main/core/projects/operations/createProject.test.ts index 71fb5a921f..62a5f0d70d 100644 --- a/apps/emdash-desktop/src/main/core/projects/operations/createProject.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/operations/createProject.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { Result } from '@emdash/shared'; +import { ok, type Result } from '@emdash/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createLocalProject, getLocalProjectPathStatus } from './create-local-project'; import { createSshProject, getSshProjectPathStatus } from './create-ssh-project'; @@ -20,8 +20,8 @@ const mocks = vi.hoisted(() => ({ insertMock: vi.fn(), valuesMock: vi.fn(), returningMock: vi.fn(), - sshConnectMock: vi.fn(), - sshStatMock: vi.fn(), + fileSystemMock: vi.fn(), + statMock: vi.fn(), })); vi.mock('@main/core/runtime/runtime-manager', () => ({ @@ -30,20 +30,6 @@ vi.mock('@main/core/runtime/runtime-manager', () => ({ }, })); -vi.mock('@main/core/fs/impl/ssh-fs', () => ({ - SshFileSystem: vi.fn(function MockSshFileSystem() { - return { - stat: mocks.sshStatMock, - }; - }), -})); - -vi.mock('@main/core/ssh/lifecycle/production-ssh-connection-manager', () => ({ - sshConnectionManager: { - connect: mocks.sshConnectMock, - }, -})); - vi.mock('@main/core/projects/project-manager', () => ({ projectManager: { openProject: mocks.openProjectMock, @@ -63,6 +49,24 @@ function expectOk(result: Result): T { return result.data; } +function makeFilesRuntime() { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: () => true, + }, + fileSystem: mocks.fileSystemMock.mockImplementation(() => + ok({ + stat: mocks.statMock, + }) + ), + }; +} + beforeEach(() => { vi.clearAllMocks(); @@ -72,6 +76,7 @@ beforeEach(() => { mocks.getProjectMock.mockReturnValue(undefined); mocks.acquireRuntimeMock.mockResolvedValue({ value: { + files: makeFilesRuntime(), git: { ensureRepository: mocks.ensureRepositoryMock, inspectPath: mocks.inspectPathMock, @@ -98,8 +103,7 @@ beforeEach(() => { }); mocks.repoGetRefsMock.mockResolvedValue({ branches: [] }); mocks.repoGetDefaultBranchMock.mockResolvedValue('main'); - mocks.sshConnectMock.mockResolvedValue({ id: 'ssh-proxy' }); - mocks.sshStatMock.mockResolvedValue({ path: '', type: 'dir' }); + mocks.statMock.mockResolvedValue(ok({ path: 'worktree', type: 'directory' })); }); describe('createLocalProject', () => { @@ -384,6 +388,40 @@ describe('getLocalProjectPathStatus', () => { }); expect(mocks.inspectPathMock).toHaveBeenCalledWith(projectPath); }); + + it('does not inspect git status for local paths that are not directories', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + tempDirs.push(projectPath); + mocks.statMock.mockResolvedValueOnce(ok({ path: path.basename(projectPath), type: 'file' })); + + const status = await getLocalProjectPathStatus(projectPath); + + expect(status).toEqual({ isDirectory: false, isGitRepo: false }); + expect(mocks.inspectPathMock).not.toHaveBeenCalled(); + }); + + it('returns local stat failures as inspection failures', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + tempDirs.push(projectPath); + mocks.statMock.mockResolvedValueOnce({ + success: false, + error: { + type: 'fs-error', + path: projectPath, + message: 'Permission denied', + code: 'EACCES', + }, + }); + + const status = await getLocalProjectPathStatus(projectPath); + + expect(status).toEqual({ + isDirectory: false, + isGitRepo: false, + error: { type: 'inspect-failed', path: projectPath, message: 'Permission denied' }, + }); + expect(mocks.inspectPathMock).not.toHaveBeenCalled(); + }); }); describe('createSshProject', () => { @@ -415,11 +453,12 @@ describe('createSshProject', () => { }) ); - expect(mocks.sshStatMock).toHaveBeenCalledWith(''); expect(mocks.acquireRuntimeMock).toHaveBeenCalledWith({ kind: 'ssh', connectionId: 'connection-id', }); + expect(mocks.fileSystemMock).toHaveBeenCalledWith(); + expect(mocks.statMock).toHaveBeenCalledWith(projectPath); expect(mocks.ensureRepositoryMock).toHaveBeenCalledWith(projectPath, { initIfMissing: true, }); @@ -466,7 +505,7 @@ describe('createSshProject', () => { }); it('rejects invalid remote directories', async () => { - mocks.sshStatMock.mockResolvedValueOnce(null); + mocks.statMock.mockResolvedValueOnce(ok({ path: 'worktree', type: 'file' })); await expect( createSshProject({ @@ -526,7 +565,7 @@ describe('getSshProjectPathStatus', () => { const projectPath = '/remote/worktree'; it('returns invalid status when remote directory does not exist', async () => { - mocks.sshStatMock.mockResolvedValueOnce(null); + mocks.statMock.mockResolvedValueOnce(ok({ path: 'worktree', type: 'file' })); const status = await getSshProjectPathStatus(projectPath, 'connection-id'); diff --git a/apps/emdash-desktop/src/main/core/projects/path-utils.ts b/apps/emdash-desktop/src/main/core/projects/path-utils.ts index b9d7ad80fa..d4d02bd057 100644 --- a/apps/emdash-desktop/src/main/core/projects/path-utils.ts +++ b/apps/emdash-desktop/src/main/core/projects/path-utils.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { isFileNotFoundException } from '@emdash/core/files'; export type DirectoryStatus = | { kind: 'directory' } @@ -9,7 +10,7 @@ export function getDirectoryStatus(path: string): DirectoryStatus { try { return fs.statSync(path).isDirectory() ? { kind: 'directory' } : { kind: 'not-directory' }; } catch (error) { - if (isMissingPathError(error)) return { kind: 'not-directory' }; + if (isFileNotFoundException(error)) return { kind: 'not-directory' }; return { kind: 'inspect-failed', message: error instanceof Error ? error.message : String(error), @@ -20,9 +21,3 @@ export function getDirectoryStatus(path: string): DirectoryStatus { export function checkIsValidDirectory(path: string): boolean { return getDirectoryStatus(path).kind === 'directory'; } - -function isMissingPathError(error: unknown): boolean { - if (!error || typeof error !== 'object' || !('code' in error)) return false; - const code = (error as { code?: unknown }).code; - return code === 'ENOENT' || code === 'ENOTDIR'; -} diff --git a/apps/emdash-desktop/src/main/core/projects/project-manager.ts b/apps/emdash-desktop/src/main/core/projects/project-manager.ts index 15ea3e2576..c05a12bd98 100644 --- a/apps/emdash-desktop/src/main/core/projects/project-manager.ts +++ b/apps/emdash-desktop/src/main/core/projects/project-manager.ts @@ -58,7 +58,8 @@ class ProjectSessionManager createProvider(project), project.type === 'ssh' ? SSH_PROVIDER_TIMEOUT_MS : LOCAL_PROVIDER_TIMEOUT_MS ); - return ok(provider); + if (!provider.success) return err({ type: 'error', message: provider.error.message }); + return ok(provider.data); } catch (e) { const initError = toInitError(e); log.error('ProjectManager: error during project initialization', { diff --git a/apps/emdash-desktop/src/main/core/projects/project-provider.ts b/apps/emdash-desktop/src/main/core/projects/project-provider.ts index a99e41eb38..83522aab8e 100644 --- a/apps/emdash-desktop/src/main/core/projects/project-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/project-provider.ts @@ -1,3 +1,4 @@ +import type { IFileSystem } from '@emdash/core/files'; import type { FetchError, GitBranchRef, @@ -7,20 +8,20 @@ import type { } from '@emdash/core/git'; import type { IDisposable, IReleasable, Result } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; import type { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; import type { GitRepositoryService } from '@main/core/git/repository/service'; import { previewServerService } from '@main/core/preview-servers/preview-server-service-instance'; import type { MachineRef } from '@main/core/runtime/types'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import type { SetupResult } from '@main/core/workspaces/workspace-setup-executor'; import type { WorkspaceProviderData } from '@shared/core/workspaces/workspace-provider-data'; +import type { WorkspaceSetupSpec } from '@shared/core/workspaces/workspace-setup-spec'; import type { ProjectRemoteState } from '@shared/projects'; import type { ConversationProvider } from '../conversations/types'; import { taskSessionManager } from '../tasks/task-session-manager'; import type { TerminalProvider } from '../terminals/terminal-provider'; import type { WorkspaceType } from '../workspaces/workspace-factory'; import type { ProjectSettingsProvider } from './settings/provider'; -import type { WorktreeHost } from './worktrees/hosts/worktree-host'; import type { WorktreeService } from './worktrees/worktree-service'; export type { WorkspaceProviderData }; @@ -44,6 +45,11 @@ export interface TaskProvider { readonly terminals: TerminalProvider; } +type RunWorkspaceSetup = (args: { + spec: WorkspaceSetupSpec; + worktreePoolPath: string; +}) => Promise; + /** * Transport-specific dependencies: the only things that differ between local and SSH. * Pure data — no lifecycle methods. @@ -54,9 +60,25 @@ export type ProjectProviderTransport = { readonly defaultWorkspaceType: WorkspaceType; readonly defaultWorkspaceMachine: MachineRef; readonly ctx: IExecutionContext; - readonly fs: FileSystemProvider; + readonly fileSystem: IFileSystem; + readonly projectConfigPath: string; + /** + * Transitional desktop-owned path helper. Remove once project config reads/writes + * are served by the workspace server/core boundary instead of main-process adapters. + */ + readonly resolveProjectPath: (relativePath: string) => string; + /** + * Transitional desktop-owned path helper. Remove with resolveProjectPath when + * config target resolution moves behind the workspace server/core boundary. + */ + readonly configPathForDirectory: (directoryPath: string) => string; + /** + * Transitional provisioning hook. Workspace setup currently still runs in the + * desktop app with direct access to the machine runtime; this should move behind + * the workspace server/core boundary and disappear from ProjectProvider. + */ + readonly runWorkspaceSetup: RunWorkspaceSetup; readonly settings: ProjectSettingsProvider; - readonly worktreeHost: WorktreeHost; }; export class ProjectProvider implements IReleasable, IDisposable { @@ -66,15 +88,18 @@ export class ProjectProvider implements IReleasable, IDisposable { readonly projectMachine: MachineRef; readonly settings: ProjectSettingsProvider; readonly gitRepository: GitRepositoryService; - readonly fs: FileSystemProvider; + readonly fileSystem: IFileSystem; + readonly projectConfigPath: string; readonly worktreeService: WorktreeService; readonly gitRepositoryFetchService: GitRepositoryFetchService; /** Workspace type for standard worktree tasks. BYOI tasks use their own remote workspace type. */ readonly defaultWorkspaceType: WorkspaceType; readonly defaultWorkspaceMachine: MachineRef; - readonly worktreeHost: WorktreeHost; private readonly _ctx: IExecutionContext; + private readonly _resolveProjectPath: (relativePath: string) => string; + private readonly _configPathForDirectory: (directoryPath: string) => string; + private readonly _runWorkspaceSetup: RunWorkspaceSetup; constructor( projectId: string, @@ -92,19 +117,43 @@ export class ProjectProvider implements IReleasable, IDisposable { this.projectMachine = transport.projectMachine; this._ctx = transport.ctx; this.settings = transport.settings; - this.fs = transport.fs; + this.fileSystem = transport.fileSystem; + this.projectConfigPath = transport.projectConfigPath; + this._resolveProjectPath = transport.resolveProjectPath; + this._configPathForDirectory = transport.configPathForDirectory; + this._runWorkspaceSetup = transport.runWorkspaceSetup; this.gitRepository = gitRepository; this.worktreeService = worktreeService; this.gitRepositoryFetchService = gitRepositoryFetchService; this.defaultWorkspaceType = transport.defaultWorkspaceType; this.defaultWorkspaceMachine = transport.defaultWorkspaceMachine; - this.worktreeHost = transport.worktreeHost; } get ctx(): IExecutionContext { return this._ctx; } + /** + * Transitional desktop-owned path helper. See ProjectProviderTransport. + */ + resolveProjectPath(relativePath: string): string { + return this._resolveProjectPath(relativePath); + } + + /** + * Transitional desktop-owned path helper. See ProjectProviderTransport. + */ + configPathForDirectory(directoryPath: string): string { + return this._configPathForDirectory(directoryPath); + } + + /** + * Transitional provisioning hook. See ProjectProviderTransport. + */ + runWorkspaceSetup(spec: WorkspaceSetupSpec, worktreePoolPath: string): Promise { + return this._runWorkspaceSetup({ spec, worktreePoolPath }); + } + getRemoteState(): Promise { return this.gitRepository.getRemoteState(); } diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts deleted file mode 100644 index 2fcd74c68e..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { FileSystemErrorCodes } from '@main/core/fs/types'; -import { isPathInsideRoot, LocalWorktreeHost } from './local-worktree-host'; - -describe('LocalWorktreeHost', () => { - let repoDir: string; - let worktreeDir: string; - let outsideDir: string; - - beforeEach(() => { - repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-repo-')); - worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-worktrees-')); - outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-outside-')); - }); - - afterEach(() => { - fs.rmSync(repoDir, { recursive: true, force: true }); - fs.rmSync(worktreeDir, { recursive: true, force: true }); - fs.rmSync(outsideDir, { recursive: true, force: true }); - }); - - async function makeHost(): Promise { - return LocalWorktreeHost.create({ - allowedRoots: [repoDir, worktreeDir], - }); - } - - it('copies files between separate allowed roots using absolute paths', async () => { - const host = await makeHost(); - const src = path.join(repoDir, '.env'); - const dest = path.join(worktreeDir, 'task-1', '.env'); - fs.writeFileSync(src, 'SECRET=abc'); - - await host.mkdirAbsolute(path.dirname(dest), { recursive: true }); - await host.copyFileAbsolute(src, dest); - - expect(fs.readFileSync(dest, 'utf8')).toBe('SECRET=abc'); - }); - - it('rejects relative paths', async () => { - const host = await makeHost(); - - await expect(host.mkdirAbsolute('relative/path', { recursive: true })).rejects.toMatchObject({ - code: FileSystemErrorCodes.INVALID_PATH, - }); - }); - - it('rejects paths outside the allowed roots', async () => { - const host = await makeHost(); - const src = path.join(outsideDir, 'secret.txt'); - const dest = path.join(worktreeDir, 'secret.txt'); - fs.writeFileSync(src, 'outside'); - - await expect(host.copyFileAbsolute(src, dest)).rejects.toMatchObject({ - code: FileSystemErrorCodes.PATH_ESCAPE, - }); - }); - - it('allows adding a new trusted worktree root', async () => { - const host = await makeHost(); - const nextWorktreeDir = path.join(outsideDir, 'next-worktrees'); - fs.mkdirSync(nextWorktreeDir); - const target = path.join(nextWorktreeDir, 'task-1'); - - await expect(host.mkdirAbsolute(target, { recursive: true })).rejects.toMatchObject({ - code: FileSystemErrorCodes.PATH_ESCAPE, - }); - - await host.allowRoot(nextWorktreeDir); - await host.mkdirAbsolute(target, { recursive: true }); - - expect(fs.existsSync(target)).toBe(true); - }); - - it('rejects symlink escapes outside the allowed roots', async () => { - if (process.platform === 'win32') { - return; - } - - const host = await makeHost(); - const secret = path.join(outsideDir, 'passwords.txt'); - const escape = path.join(worktreeDir, 'escape'); - fs.writeFileSync(secret, 'outside'); - fs.symlinkSync(outsideDir, escape); - - await expect(host.realPathAbsolute(path.join(escape, 'passwords.txt'))).rejects.toMatchObject({ - code: FileSystemErrorCodes.PATH_ESCAPE, - }); - }); - - it('returns false/null for out-of-scope existence checks', async () => { - const host = await makeHost(); - const outside = path.join(outsideDir, 'file.txt'); - fs.writeFileSync(outside, 'outside'); - - await expect(host.existsAbsolute(outside)).resolves.toBe(false); - await expect(host.statAbsolute(outside)).resolves.toBeNull(); - }); - - it('matches Windows paths by drive-aware containment rules', () => { - expect( - isPathInsideRoot(String.raw`C:\repo\.env`, String.raw`C:\repo`, { - pathApi: path.win32, - }) - ).toBe(true); - expect( - isPathInsideRoot(String.raw`C:\repo2\.env`, String.raw`C:\repo`, { - pathApi: path.win32, - }) - ).toBe(false); - expect( - isPathInsideRoot(String.raw`D:\repo\.env`, String.raw`C:\repo`, { - pathApi: path.win32, - }) - ).toBe(false); - expect( - isPathInsideRoot(String.raw`c:\repo\.env`, String.raw`C:\Repo`, { - pathApi: path.win32, - }) - ).toBe(true); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.ts deleted file mode 100644 index d46d766087..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { glob } from 'glob'; -import { FileSystemError, FileSystemErrorCodes, type FileEntry } from '@main/core/fs/types'; -import type { WorktreeHost } from './worktree-host'; - -type PathApi = Pick; - -export function isPathInsideRoot( - child: string, - parent: string, - options: { pathApi?: PathApi } = {} -): boolean { - const pathApi = options.pathApi ?? path; - const rel = pathApi.relative(parent, child); - return rel === '' || (!rel.startsWith('..') && !pathApi.isAbsolute(rel)); -} - -function isNotFound(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException).code; - return code === 'ENOENT' || code === 'ENOTDIR'; -} - -export class LocalWorktreeHost implements WorktreeHost { - readonly pathApi = path; - - private constructor(private readonly roots: string[]) {} - - private static async resolveAllowedRoot(root: string): Promise { - const resolved = path.resolve(root); - if (!path.isAbsolute(resolved)) { - throw new FileSystemError( - `Expected absolute allowed root: ${root}`, - FileSystemErrorCodes.INVALID_PATH, - root - ); - } - return fs.realpath(resolved); - } - - static async create(args: { allowedRoots: string[] }): Promise { - if (args.allowedRoots.length === 0) { - throw new FileSystemError( - 'At least one allowed root is required', - FileSystemErrorCodes.INVALID_PATH - ); - } - - const roots = await Promise.all( - args.allowedRoots.map((root) => LocalWorktreeHost.resolveAllowedRoot(root)) - ); - - return new LocalWorktreeHost(roots); - } - - async allowRoot(root: string): Promise { - const resolved = await LocalWorktreeHost.resolveAllowedRoot(root); - if (!this.roots.some((existing) => existing === resolved)) { - this.roots.push(resolved); - } - } - - private assertAbsolute(input: string): string { - const resolved = path.resolve(input); - if (!path.isAbsolute(input)) { - throw new FileSystemError( - `Expected absolute path: ${input}`, - FileSystemErrorCodes.INVALID_PATH, - input - ); - } - return resolved; - } - - private assertInsideAllowedRoots(resolved: string, originalPath: string): void { - if (!this.roots.some((root) => isPathInsideRoot(resolved, root))) { - throw new FileSystemError( - `Path outside allowed roots: ${originalPath}`, - FileSystemErrorCodes.PATH_ESCAPE, - originalPath - ); - } - } - - private async validateExisting(input: string): Promise { - const resolved = this.assertAbsolute(input); - const real = await fs.realpath(resolved); - this.assertInsideAllowedRoots(real, input); - return real; - } - - private async nearestExistingPath(resolved: string): Promise<{ - realAncestor: string; - unresolvedSegments: string[]; - }> { - const unresolvedSegments: string[] = []; - let current = resolved; - - while (true) { - try { - return { - realAncestor: await fs.realpath(current), - unresolvedSegments: unresolvedSegments.reverse(), - }; - } catch (error) { - if (!isNotFound(error)) throw error; - const parent = path.dirname(current); - if (parent === current) throw error; - unresolvedSegments.push(path.basename(current)); - current = parent; - } - } - } - - private async validateTarget(input: string): Promise { - const resolved = this.assertAbsolute(input); - try { - return await this.validateExisting(resolved); - } catch (error) { - if (!isNotFound(error)) throw error; - } - - const { realAncestor, unresolvedSegments } = await this.nearestExistingPath(resolved); - this.assertInsideAllowedRoots(realAncestor, input); - const target = path.join(realAncestor, ...unresolvedSegments); - this.assertInsideAllowedRoots(target, input); - return target; - } - - async existsAbsolute(filePath: string): Promise { - try { - await this.validateExisting(filePath); - return true; - } catch { - return false; - } - } - - async mkdirAbsolute(dirPath: string, options?: { recursive?: boolean }): Promise { - const target = await this.validateTarget(dirPath); - await fs.mkdir(target, { recursive: options?.recursive ?? false }); - } - - async removeAbsolute( - filePath: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }> { - try { - const target = await this.validateExisting(filePath); - await fs.rm(target, { - recursive: options?.recursive ?? false, - force: true, - maxRetries: options?.recursive ? 3 : 0, - retryDelay: 100, - }); - return { success: true }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - } - - async realPathAbsolute(filePath: string): Promise { - return this.validateExisting(filePath); - } - - async globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise { - const cwd = await this.validateExisting(options.cwd); - return glob(pattern, { cwd, dot: options.dot ?? false, absolute: false }); - } - - async readFileAbsolute(filePath: string): Promise { - const safePath = await this.validateExisting(filePath); - return fs.readFile(safePath, 'utf8'); - } - - async copyFileAbsolute(src: string, dest: string): Promise { - const safeSrc = await this.validateExisting(src); - const safeDest = await this.validateTarget(dest); - await fs.copyFile(safeSrc, safeDest); - } - - async statAbsolute(filePath: string): Promise { - try { - const fullPath = await this.validateExisting(filePath); - const stat = await fs.stat(fullPath); - return { - path: fullPath, - type: stat.isDirectory() ? 'dir' : 'file', - size: stat.size, - mtime: stat.mtime, - ctime: stat.ctime, - mode: stat.mode, - }; - } catch { - return null; - } - } -} diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts deleted file mode 100644 index d70452b609..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { FileSystemErrorCodes, type FileSystemProvider } from '@main/core/fs/types'; -import { SshWorktreeHost } from './ssh-worktree-host'; - -function makeFs(): Pick< - FileSystemProvider, - 'exists' | 'mkdir' | 'remove' | 'realPath' | 'glob' | 'read' | 'copyFile' | 'stat' -> { - return { - exists: vi.fn().mockResolvedValue(true), - mkdir: vi.fn().mockResolvedValue(undefined), - remove: vi.fn().mockResolvedValue({ success: true }), - realPath: vi.fn().mockResolvedValue('/real/path'), - glob: vi.fn().mockResolvedValue(['.env']), - read: vi.fn().mockResolvedValue({ content: 'hello', truncated: false, totalSize: 5 }), - copyFile: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockResolvedValue(null), - }; -} - -describe('SshWorktreeHost', () => { - it('delegates absolute POSIX paths to the wrapped filesystem', async () => { - const fs = makeFs(); - const host = new SshWorktreeHost(fs); - - await host.mkdirAbsolute('/remote/worktrees/project', { recursive: true }); - await host.copyFileAbsolute('/remote/repo/.env', '/remote/worktrees/project/task/.env'); - await host.globAbsolute('.env', { cwd: '/remote/repo', dot: true }); - - expect(fs.mkdir).toHaveBeenCalledWith('/remote/worktrees/project', { recursive: true }); - expect(fs.copyFile).toHaveBeenCalledWith( - '/remote/repo/.env', - '/remote/worktrees/project/task/.env' - ); - expect(fs.glob).toHaveBeenCalledWith('.env', { cwd: '/remote/repo', dot: true }); - }); - - it('rejects relative paths before delegating', async () => { - const fs = makeFs(); - const host = new SshWorktreeHost(fs); - - await expect(host.existsAbsolute('relative/path')).rejects.toMatchObject({ - code: FileSystemErrorCodes.INVALID_PATH, - }); - expect(fs.exists).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts deleted file mode 100644 index 9751e4a8c1..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts +++ /dev/null @@ -1,68 +0,0 @@ -import path from 'node:path'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileEntry, - type FileSystemProvider, -} from '@main/core/fs/types'; -import type { WorktreeHost } from './worktree-host'; - -type SshWorktreeFs = Pick< - FileSystemProvider, - 'exists' | 'mkdir' | 'remove' | 'realPath' | 'glob' | 'read' | 'copyFile' | 'stat' ->; - -export class SshWorktreeHost implements WorktreeHost { - readonly pathApi = path.posix; - - constructor(private readonly fs: SshWorktreeFs) {} - - private validateAbsolute(input: string): string { - if (!path.posix.isAbsolute(input)) { - throw new FileSystemError( - `Expected absolute POSIX path: ${input}`, - FileSystemErrorCodes.INVALID_PATH, - input - ); - } - return input; - } - - async existsAbsolute(filePath: string): Promise { - return this.fs.exists(this.validateAbsolute(filePath)); - } - - async mkdirAbsolute(dirPath: string, options?: { recursive?: boolean }): Promise { - return this.fs.mkdir(this.validateAbsolute(dirPath), options); - } - - async removeAbsolute( - filePath: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }> { - return this.fs.remove(this.validateAbsolute(filePath), options); - } - - async realPathAbsolute(filePath: string): Promise { - return this.fs.realPath(this.validateAbsolute(filePath)); - } - - async globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise { - return this.fs.glob(pattern, { - ...options, - cwd: this.validateAbsolute(options.cwd), - }); - } - - async readFileAbsolute(filePath: string): Promise { - return (await this.fs.read(this.validateAbsolute(filePath))).content; - } - - async copyFileAbsolute(src: string, dest: string): Promise { - return this.fs.copyFile(this.validateAbsolute(src), this.validateAbsolute(dest)); - } - - async statAbsolute(filePath: string): Promise { - return this.fs.stat(this.validateAbsolute(filePath)); - } -} diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/worktree-host.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/worktree-host.ts deleted file mode 100644 index ab1f388d0a..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/worktree-host.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type path from 'node:path'; -import type { FileEntry } from '@main/core/fs/types'; - -export type WorktreeHostPathApi = Pick; - -export interface WorktreeHost { - readonly pathApi: WorktreeHostPathApi; - existsAbsolute(path: string): Promise; - mkdirAbsolute(path: string, options?: { recursive?: boolean }): Promise; - removeAbsolute( - path: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }>; - realPathAbsolute(path: string): Promise; - globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise; - readFileAbsolute(path: string): Promise; - copyFileAbsolute(src: string, dest: string): Promise; - statAbsolute(path: string): Promise; -} diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts index be62833620..fb4a891780 100644 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts @@ -1,14 +1,15 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import nodePath from 'node:path'; +import { contains, FilesRuntime, type IFileSystem } from '@emdash/core/files'; import type { GitRemote } from '@emdash/core/git'; -import { ok } from '@emdash/shared'; +import { err, ok, type Result } from '@emdash/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { IFilesRuntime, RuntimePath } from '@main/core/runtime/types'; import type { ProjectSettingsProvider } from '../settings/provider'; -import { LocalWorktreeHost } from './hosts/local-worktree-host'; -import type { WorktreeHost } from './hosts/worktree-host'; import { WorktreeService } from './worktree-service'; async function git( @@ -43,18 +44,78 @@ function makeSettings(preservePatterns: string[] = []): ProjectSettingsProvider const originRemote = (url = 'ssh://example.com/repo.git'): GitRemote => ({ name: 'origin', url }); +type FakeFilesRuntimeOptions = { + pathApi?: RuntimePath; + existsAbsolute?: (absPath: string) => Promise; + mkdirAbsolute?: (absPath: string, options?: { recursive?: boolean }) => Promise; + removeAbsolute?: ( + absPath: string, + options?: { recursive?: boolean } + ) => Promise>; + realPathAbsolute?: (absPath: string) => Promise; +}; + +function makeFakeFilesRuntime(options: FakeFilesRuntimeOptions = {}): IFilesRuntime { + const pathApi = options.pathApi ?? nativeMachinePath; + return { + path: pathApi, + openTree: vi.fn(), + watchChanges: vi.fn(), + fileSystem: vi.fn(() => + ok({ + exists: async (absPath: string) => ok(await (options.existsAbsolute?.(absPath) ?? false)), + mkdir: async (absPath: string, mkdirOptions?: { recursive?: boolean }) => { + await options.mkdirAbsolute?.(absPath, mkdirOptions); + return ok(); + }, + remove: async (absPath: string, removeOptions?: { recursive?: boolean }) => { + const result = (await options.removeAbsolute?.(absPath, removeOptions)) ?? ok(); + return result.success + ? ok() + : err({ + type: 'fs-error' as const, + path: absPath, + message: result.error.message, + }); + }, + realPath: async (absPath: string) => + ok(await (options.realPathAbsolute?.(absPath) ?? absPath)), + stat: async () => + err({ + type: 'fs-error' as const, + path: '', + message: 'stat is not implemented by test fake', + code: 'ENOENT', + }), + glob: () => + ok( + (async function* () { + // No preserved files in fake-runtime unit cases. + })() + ), + } as unknown as IFileSystem) + ), + dispose: vi.fn(), + } as unknown as IFilesRuntime; +} + +const nativeMachinePath: RuntimePath = { + join: (...parts: string[]) => nodePath.join(...parts), + dirname: (value: string) => nodePath.dirname(value), + basename: (value: string) => nodePath.basename(value), + isAbsolute: (value: string) => nodePath.isAbsolute(value), + relative: (from: string, to: string) => nodePath.relative(from, to), + contains, +}; + describe('WorktreeService', () => { let repoDir: string; let poolDir: string; - let host: WorktreeHost; beforeEach(async () => { repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-repo-')); poolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-pool-')); await initRepo(repoDir); - host = await LocalWorktreeHost.create({ - allowedRoots: [repoDir, poolDir], - }); }); afterEach(() => { @@ -75,27 +136,36 @@ describe('WorktreeService', () => { return new WorktreeService({ repoPath, ctx: new LocalExecutionContext({ root: repoPath }), - host, + files: Object.assign(new FilesRuntime(), { path: nativeMachinePath }), projectSettings: overrides.projectSettings ?? makeSettings(), resolveWorktreePoolPath: overrides.resolveWorktreePoolPath ?? (async () => worktreePoolPath), }); } - it('uses the host path API for worktree paths', async () => { - const remoteHost: WorktreeHost = { - existsAbsolute: vi.fn().mockResolvedValue(false), - mkdirAbsolute: vi.fn().mockResolvedValue(undefined), - removeAbsolute: vi.fn().mockResolvedValue({ success: true }), - realPathAbsolute: vi.fn().mockResolvedValue('/remote/worktrees/project'), - globAbsolute: vi.fn().mockResolvedValue([]), - readFileAbsolute: vi.fn().mockResolvedValue(''), - copyFileAbsolute: vi.fn().mockResolvedValue(undefined), - statAbsolute: vi.fn().mockResolvedValue(null), - pathApi: { - join: (...segments: string[]) => `host:${path.posix.join(...segments)}`, - dirname: (input: string) => `host-dir:${path.posix.dirname(input.replace(/^host:/, ''))}`, + it('uses the runtime path API for worktree paths', async () => { + const stripHost = (value: string) => value.replace(/^host:/, ''); + const remotePathApi: RuntimePath = { + join: (...segments: string[]) => + `host:${path.posix.join(...segments.map((segment) => stripHost(segment)))}`, + dirname: (input: string) => `host:${path.posix.dirname(stripHost(input))}`, + basename: (input: string) => path.posix.basename(stripHost(input)), + isAbsolute: (input: string) => input.startsWith('host:/') || path.posix.isAbsolute(input), + relative: (from: string, to: string) => path.posix.relative(stripHost(from), stripHost(to)), + contains: (parent: string, child: string) => { + const rel = path.posix.relative(stripHost(parent), stripHost(child)); + return ( + rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) + ); }, }; + const existsAbsolute = vi.fn().mockResolvedValue(false); + const mkdirAbsolute = vi.fn().mockResolvedValue(undefined); + const files = makeFakeFilesRuntime({ + pathApi: remotePathApi, + existsAbsolute, + mkdirAbsolute, + realPathAbsolute: async (absPath) => absPath, + }); const remoteCtx = { root: '/remote/repo', supportsLocalSpawn: false, @@ -106,16 +176,14 @@ describe('WorktreeService', () => { const svc = new WorktreeService({ repoPath: '/remote/repo', ctx: remoteCtx, - host: remoteHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => '/remote/worktrees/project', }); await expect(svc.getWorktree('emdash/task-abc')).resolves.toBeUndefined(); - expect(remoteHost.existsAbsolute).toHaveBeenCalledWith( - 'host:/remote/worktrees/project/emdash/task-abc' - ); + expect(existsAbsolute).toHaveBeenCalledWith('host:/remote/worktrees/project/emdash/task-abc'); const checkoutResult = await svc.checkoutBranchWorktree( { type: 'local', branch: 'main' }, @@ -123,10 +191,9 @@ describe('WorktreeService', () => { ); expect(checkoutResult.success).toBe(true); - expect(remoteHost.mkdirAbsolute).toHaveBeenCalledWith( - 'host-dir:/remote/worktrees/project/emdash', - { recursive: true } - ); + expect(mkdirAbsolute).toHaveBeenCalledWith('host:/remote/worktrees/project/emdash', { + recursive: true, + }); }); describe('checkoutBranchWorktree', () => { @@ -153,28 +220,23 @@ describe('WorktreeService', () => { execStreaming: async () => {}, dispose: () => {}, }; - const fakeHost: WorktreeHost = { - pathApi: path, - existsAbsolute: vi.fn(async (absPath: string) => absPath === targetPath), - mkdirAbsolute: vi.fn(async () => {}), - removeAbsolute: vi.fn(async () => ({ success: false, error: 'permission denied' })), - realPathAbsolute: vi.fn(async (absPath: string) => absPath), - globAbsolute: vi.fn(async () => []), - readFileAbsolute: vi.fn(async () => ''), - copyFileAbsolute: vi.fn(async () => {}), - statAbsolute: vi.fn(async () => null), - }; + const removeAbsolute = vi.fn(async () => err({ message: 'permission denied' })); + const files = makeFakeFilesRuntime({ + existsAbsolute: async (absPath) => absPath === targetPath, + removeAbsolute, + realPathAbsolute: async (absPath) => absPath, + }); const svc = new WorktreeService({ repoPath: repoDir, ctx, - host: fakeHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => poolDir, }); await expect(svc.getWorktree(branchName)).resolves.toBeUndefined(); - expect(fakeHost.removeAbsolute).toHaveBeenCalledWith(targetPath, { recursive: true }); + expect(removeAbsolute).toHaveBeenCalledWith(targetPath, { recursive: true }); }); it('creates a worktree from an existing local source branch', async () => { @@ -232,21 +294,15 @@ describe('WorktreeService', () => { execStreaming: async () => {}, dispose: () => {}, }; - const fakeHost: WorktreeHost = { - pathApi: path, - existsAbsolute: vi.fn(async (absPath: string) => absPath === targetPath), - mkdirAbsolute: vi.fn(async () => {}), - removeAbsolute: vi.fn(async () => ({ success: false, error: 'permission denied' })), - realPathAbsolute: vi.fn(async (absPath: string) => absPath), - globAbsolute: vi.fn(async () => []), - readFileAbsolute: vi.fn(async () => ''), - copyFileAbsolute: vi.fn(async () => {}), - statAbsolute: vi.fn(async () => null), - }; + const files = makeFakeFilesRuntime({ + existsAbsolute: async (absPath) => absPath === targetPath, + removeAbsolute: async () => err({ message: 'permission denied' }), + realPathAbsolute: async (absPath) => absPath, + }); const svc = new WorktreeService({ repoPath: repoDir, ctx, - host: fakeHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => poolDir, }); @@ -310,23 +366,17 @@ describe('WorktreeService', () => { execStreaming: async () => {}, dispose: () => {}, }; - const fakeHost: WorktreeHost = { - pathApi: path, - existsAbsolute: vi.fn(async (absPath: string) => { + const files = makeFakeFilesRuntime({ + existsAbsolute: async (absPath) => { return absPath === targetPath || absPath === path.join(targetPath, '.git'); - }), - mkdirAbsolute: vi.fn(async () => {}), - removeAbsolute: vi.fn(async () => ({ success: true })), - realPathAbsolute: vi.fn(async (absPath: string) => absPath), - globAbsolute: vi.fn(async () => []), - readFileAbsolute: vi.fn(async () => ''), - copyFileAbsolute: vi.fn(async () => {}), - statAbsolute: vi.fn(async () => null), - }; + }, + removeAbsolute: async () => ok(), + realPathAbsolute: async (absPath) => absPath, + }); const svc = new WorktreeService({ repoPath: repoDir, ctx, - host: fakeHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => poolDir, }); @@ -422,6 +472,36 @@ describe('WorktreeService', () => { if (!result.success) throw new Error('expected success'); expect(fs.readFileSync(path.join(result.data, '.env'), 'utf8')).toBe('SECRET=abc'); }); + + it('skips preserve patterns that can escape the source repo or target worktree', async () => { + fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=abc'); + const parentSecret = path.join(path.dirname(repoDir), 'preserve-secret.txt'); + const absoluteSecret = path.join(os.tmpdir(), `preserve-secret-${Date.now()}.txt`); + fs.writeFileSync(parentSecret, 'parent-secret'); + fs.writeFileSync(absoluteSecret, 'absolute-secret'); + await git(['branch', 'task/safe-preserve'], { cwd: repoDir }); + const svc = makeService({ + projectSettings: makeSettings(['.env', '../preserve-secret.txt', absoluteSecret]), + }); + + try { + const result = await svc.checkoutBranchWorktree( + { type: 'local', branch: 'main' }, + 'task/safe-preserve' + ); + + expect(result.success).toBe(true); + if (!result.success) throw new Error('expected success'); + expect(fs.readFileSync(path.join(result.data, '.env'), 'utf8')).toBe('SECRET=abc'); + expect(fs.existsSync(path.join(path.dirname(result.data), 'preserve-secret.txt'))).toBe( + false + ); + expect(fs.existsSync(path.join(result.data, path.basename(absoluteSecret)))).toBe(false); + } finally { + fs.rmSync(parentSecret, { force: true }); + fs.rmSync(absoluteSecret, { force: true }); + } + }); }); describe('removeWorktree', () => { @@ -435,21 +515,15 @@ describe('WorktreeService', () => { execStreaming: async () => {}, dispose: () => {}, }; - const fakeHost: WorktreeHost = { - pathApi: path, - existsAbsolute: vi.fn(async () => false), - mkdirAbsolute: vi.fn(async () => {}), - removeAbsolute: vi.fn(async () => ({ success: false, error: 'permission denied' })), - realPathAbsolute: vi.fn(async (absPath: string) => absPath), - globAbsolute: vi.fn(async () => []), - readFileAbsolute: vi.fn(async () => ''), - copyFileAbsolute: vi.fn(async () => {}), - statAbsolute: vi.fn(async () => null), - }; + const files = makeFakeFilesRuntime({ + existsAbsolute: async () => false, + removeAbsolute: async () => err({ message: 'permission denied' }), + realPathAbsolute: async (absPath) => absPath, + }); const svc = new WorktreeService({ repoPath: repoDir, ctx, - host: fakeHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => poolDir, }); diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.ts index 4c43385d7f..68df9f632c 100644 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.ts +++ b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.ts @@ -1,13 +1,22 @@ -import { promises as fsPromises } from 'node:fs'; +import type { IFileSystem } from '@emdash/core/files'; import type { GitBranchRef } from '@emdash/core/git'; import { err, ok, type Result } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; +import { + ensureAbsoluteDir, + openFileSystem, + realPathAbsolute, +} from '@main/core/runtime/files-helpers'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { log } from '@main/lib/logger'; import { DEFAULT_REMOTE_NAME } from '@shared/core/git/types'; import { getEffectiveTaskSettings } from '../settings/effective-task-settings'; +import { + isSafePreservePattern, + preservedDestinationPath, + preservedRepoRelativePath, +} from '../settings/preserve-pattern-safety'; import type { ProjectSettingsProvider } from '../settings/provider'; -import type { WorktreeHost } from './hosts/worktree-host'; export type ServeWorktreeError = | { type: 'worktree-setup-failed'; cause: unknown } @@ -18,13 +27,13 @@ export class WorktreeService { private readonly resolveWorktreePoolPath: () => Promise; private readonly repoPath: string; private readonly ctx: IExecutionContext; - private readonly host: WorktreeHost; + private readonly files: IFilesRuntime; private readonly projectSettings: ProjectSettingsProvider; constructor(args: { repoPath: string; ctx: IExecutionContext; - host: WorktreeHost; + files: IFilesRuntime; projectSettings: ProjectSettingsProvider; resolveWorktreePoolPath: () => Promise; }) { @@ -32,7 +41,7 @@ export class WorktreeService { this.repoPath = args.repoPath; this.projectSettings = args.projectSettings; this.ctx = args.ctx; - this.host = args.host; + this.files = args.files; this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } @@ -45,20 +54,8 @@ export class WorktreeService { private async isValidWorktree(worktreePath: string): Promise { // A linked worktree contains a .git FILE pointing to the main repo's worktrees - // directory. For local execution we bypass host path-restriction checks and use - // fs directly so external worktrees (outside allowedRoots) are still detected. - // For SSH we rely on the host (SshWorktreeHost has no root restrictions). - let hasGitFile = false; - if (this.ctx.supportsLocalSpawn) { - try { - await fsPromises.access(this.host.pathApi.join(worktreePath, '.git')); - hasGitFile = true; - } catch { - return false; - } - } else { - hasGitFile = await this.host.existsAbsolute(this.host.pathApi.join(worktreePath, '.git')); - } + // directory. + const hasGitFile = await this.existsAbsolute(this.files.path.join(worktreePath, '.git')); if (!hasGitFile) return false; try { @@ -79,21 +76,25 @@ export class WorktreeService { return this.resolveWorktreePoolPath(); } - private async ensureWorktreePoolDirExists(): Promise { - await this.host.mkdirAbsolute(await this.resolveWorktreePoolPath(), { recursive: true }); + private async ensureWorktreePoolDirExists(): Promise> { + const result = await ensureAbsoluteDir(this.files, await this.resolveWorktreePoolPath()); + return result.success ? ok() : err({ type: 'worktree-setup-failed', cause: result.error }); } private async removePathForReuse(targetPath: string): Promise { - const result = await this.host.removeAbsolute(targetPath, { recursive: true }); - if (!result.success) { + const poolPath = await this.resolveWorktreePoolPath(); + if (!this.files.path.contains(poolPath, targetPath)) { + throw new Error(`Refusing to remove worktree path outside pool: "${targetPath}"`); + } + + const removed = await this.removeAbsolute(targetPath, { recursive: true }); + if (!removed.success) { throw new Error( - result.error - ? `Failed to remove stale worktree directory "${targetPath}": ${result.error}` - : `Failed to remove stale worktree directory "${targetPath}"` + `Failed to remove stale worktree directory "${targetPath}": ${removed.error.message}` ); } - if (await this.host.existsAbsolute(targetPath)) { + if (await this.existsAbsolute(targetPath)) { throw new Error( `Failed to remove stale worktree directory "${targetPath}": path still exists` ); @@ -109,15 +110,29 @@ export class WorktreeService { } async existsAtAbsolutePath(absPath: string): Promise { - if (this.ctx.supportsLocalSpawn) { - try { - await fsPromises.access(absPath); - return true; - } catch { - return false; - } + return this.existsAbsolute(absPath); + } + + private async existsAbsolute(absPath: string): Promise { + if (!this.files.path.isAbsolute(absPath)) return false; + const opened = this.files.fileSystem(); + if (!opened.success) return false; + const exists = await opened.data.exists(absPath); + return exists.success ? exists.data : false; + } + + private async removeAbsolute( + absPath: string, + options?: { recursive?: boolean } + ): Promise> { + if (!this.files.path.isAbsolute(absPath)) { + return err({ message: `Expected absolute path: ${absPath}` }); } - return this.host.existsAbsolute(absPath); + const fs = openFileSystem(this.files); + if (!fs.success) return err({ message: fs.error.message }); + const removed = await fs.data.remove(absPath, options); + if (!removed.success) return err({ message: removed.error.message }); + return ok(); } async findBranchAnywhere(branchName: string): Promise { @@ -196,8 +211,8 @@ export class WorktreeService { async getWorktree(branchName: string): Promise { const worktreePoolPath = await this.resolveWorktreePoolPath(); - const worktreePath = this.host.pathApi.join(worktreePoolPath, branchName); - if (await this.host.existsAbsolute(worktreePath)) { + const worktreePath = this.files.path.join(worktreePoolPath, branchName); + if (await this.existsAbsolute(worktreePath)) { if (await this.isValidWorktree(worktreePath)) return worktreePath; try { await this.removePathForReuse(worktreePath); @@ -207,14 +222,16 @@ export class WorktreeService { } try { - const realPoolPath = await this.host.realPathAbsolute(worktreePoolPath); + const realPoolPath = await realPathAbsolute(this.files, worktreePoolPath); + if (!realPoolPath.success) return undefined; const { stdout } = await this.ctx.exec('git', ['worktree', 'list', '--porcelain']); const branchLine = `branch refs/heads/${branchName}`; for (const block of stdout.split('\n\n')) { if (block.split('\n').some((line) => line === branchLine)) { const match = /^worktree (.+)$/m.exec(block); const candidatePath = match?.[1]; - if (!candidatePath?.startsWith(realPoolPath)) continue; + if (!candidatePath || !this.files.path.contains(realPoolPath.data, candidatePath)) + continue; if (await this.isValidWorktree(candidatePath)) return candidatePath; await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } @@ -228,7 +245,8 @@ export class WorktreeService { branchName: string, options: { copyPreservedFiles?: boolean } = {} ): Promise> { - await this.ensureWorktreePoolDirExists(); + const poolDir = await this.ensureWorktreePoolDirExists(); + if (!poolDir.success) return poolDir; return this.enqueueGitOp(() => this.doCheckoutBranchWorktree(sourceBranch, branchName, options) ); @@ -246,8 +264,8 @@ export class WorktreeService { return ok(checkedOutPath); } - const targetPath = this.host.pathApi.join(await this.resolveWorktreePoolPath(), branchName); - if (await this.host.existsAbsolute(targetPath)) { + const targetPath = this.files.path.join(await this.resolveWorktreePoolPath(), branchName); + if (await this.existsAbsolute(targetPath)) { if (await this.isValidWorktree(targetPath)) { await this.ensureBranchBaseConfig(branchName, baseConfigValue); return ok(targetPath); @@ -276,7 +294,8 @@ export class WorktreeService { } await this.ensureBranchBaseConfig(branchName, baseConfigValue); - await this.host.mkdirAbsolute(this.host.pathApi.dirname(targetPath), { recursive: true }); + const parentDir = await ensureAbsoluteDir(this.files, this.files.path.dirname(targetPath)); + if (!parentDir.success) return err({ type: 'worktree-setup-failed', cause: parentDir.error }); await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); await this.ctx.exec('git', ['worktree', 'add', targetPath, branchName]); } catch (cause) { @@ -299,7 +318,8 @@ export class WorktreeService { branchName: string, options: { copyPreservedFiles?: boolean } = {} ): Promise> { - await this.ensureWorktreePoolDirExists(); + const poolDir = await this.ensureWorktreePoolDirExists(); + if (!poolDir.success) return poolDir; return this.enqueueGitOp(() => this.doCheckoutExistingBranch(branchName, options)); } @@ -327,10 +347,10 @@ export class WorktreeService { return ok(checkedOutPath); } - const targetPath = this.host.pathApi.join(await this.resolveWorktreePoolPath(), branchName); + const targetPath = this.files.path.join(await this.resolveWorktreePoolPath(), branchName); const remoteCandidates = await this.getRemoteCandidates(); - if (await this.host.existsAbsolute(targetPath)) { + if (await this.existsAbsolute(targetPath)) { if (await this.isValidWorktree(targetPath)) return ok(targetPath); try { await this.removePathForReuse(targetPath); @@ -341,7 +361,8 @@ export class WorktreeService { } try { - await this.host.mkdirAbsolute(this.host.pathApi.dirname(targetPath), { recursive: true }); + const parentDir = await ensureAbsoluteDir(this.files, this.files.path.dirname(targetPath)); + if (!parentDir.success) return err({ type: 'worktree-setup-failed', cause: parentDir.error }); for (const remoteName of remoteCandidates) { await this.ctx.exec('git', ['fetch', remoteName]).catch(() => {}); } @@ -403,24 +424,16 @@ export class WorktreeService { }); } - private taskConfigFs(targetPath: string): Pick { - return { - exists: (filePath) => this.host.existsAbsolute(this.host.pathApi.join(targetPath, filePath)), - read: async (filePath) => { - const content = await this.host.readFileAbsolute( - this.host.pathApi.join(targetPath, filePath) - ); - return { - content, - truncated: false, - totalSize: Buffer.byteLength(content), - }; - }, - }; + private taskConfigFs(): IFileSystem | null { + const opened = this.files.fileSystem(); + if (opened.success) return opened.data; + log.warn('WorktreeService: failed to open task config filesystem', opened.error); + return null; } - private async isTrackedSourcePath(relPath: string): Promise { + private async isTrackedSourcePath(absPath: string): Promise { try { + const relPath = this.files.path.relative(this.repoPath, absPath); await this.ctx.exec('git', ['ls-files', '--error-unmatch', '--', relPath]); return true; } catch { @@ -429,24 +442,48 @@ export class WorktreeService { } private async copyPreservedFiles(targetPath: string): Promise { + const taskFs = this.taskConfigFs(); + if (!taskFs) return; + const settings = await getEffectiveTaskSettings({ projectSettings: this.projectSettings, - taskFs: this.taskConfigFs(targetPath) as FileSystemProvider, + taskFs, + taskConfigPath: this.files.path.join(targetPath, '.emdash.json'), }); const patterns = settings.preservePatterns ?? []; + const repoFs = this.files.fileSystem(); + if (!repoFs.success) { + log.warn('WorktreeService: failed to open repo filesystem for preserved files', repoFs.error); + return; + } for (const pattern of patterns) { - const matches = await this.host.globAbsolute(pattern, { - cwd: this.repoPath, - dot: true, - }); - for (const relPath of matches) { - if (relPath === '.emdash.json' || (await this.isTrackedSourcePath(relPath))) continue; - const src = this.host.pathApi.join(this.repoPath, relPath); - const stat = await this.host.statAbsolute(src).catch(() => null); - if (!stat || stat.type !== 'file') continue; - const dest = this.host.pathApi.join(targetPath, relPath); - await this.host.mkdirAbsolute(this.host.pathApi.dirname(dest), { recursive: true }); - await this.host.copyFileAbsolute(src, dest); + if (!isSafePreservePattern(this.files.path, pattern)) { + log.warn('WorktreeService: skipping unsafe preserve pattern', { pattern }); + continue; + } + const matches = repoFs.data.glob([pattern], { cwd: this.repoPath, dot: true }); + if (!matches.success) { + log.warn('WorktreeService: failed to match preserve pattern', { + pattern, + error: matches.error, + }); + continue; + } + for await (const absPath of matches.data) { + const relPath = preservedRepoRelativePath(this.files.path, this.repoPath, absPath); + if (!relPath || (await this.isTrackedSourcePath(absPath))) continue; + const stat = await repoFs.data.stat(absPath); + if (!stat.success || stat.data.type !== 'file') continue; + const destPath = preservedDestinationPath(this.files.path, targetPath, relPath); + if (!destPath) continue; + const copied = await repoFs.data.copyFile(absPath, destPath); + if (!copied.success) { + log.warn('WorktreeService: failed to copy preserved file', { + sourcePath: absPath, + destPath, + error: copied.error, + }); + } } } } diff --git a/apps/emdash-desktop/src/main/core/tasks/task-builder.ts b/apps/emdash-desktop/src/main/core/tasks/task-builder.ts index df821a6695..2852533bfc 100644 --- a/apps/emdash-desktop/src/main/core/tasks/task-builder.ts +++ b/apps/emdash-desktop/src/main/core/tasks/task-builder.ts @@ -1,5 +1,6 @@ import type { GitBranchRef } from '@emdash/core/git'; import type { ConversationProvider } from '@main/core/conversations/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import type { TerminalProvider } from '@main/core/terminals/terminal-provider'; import type { Workspace } from '@main/core/workspaces/workspace'; import { events } from '@main/lib/events'; @@ -49,7 +50,8 @@ export async function buildTaskFromWorkspace( projectPath: string, settings: ProjectSettingsProvider, workspaceBranchName?: string, - workspaceSourceBranch?: GitBranchRef + workspaceSourceBranch?: GitBranchRef, + sshFilesRuntime?: IFilesRuntime ): Promise { const { taskEnvVars, tmuxEnabled, shellSetup } = await resolveTaskEnv( task, @@ -67,6 +69,7 @@ export async function buildTaskFromWorkspace( tmuxEnabled, shellSetup, taskEnvVars, + filesRuntime: sshFilesRuntime, }); const taskProvider: TaskProvider = { diff --git a/apps/emdash-desktop/src/main/core/workspaces/byoi/provision-byoi-task.ts b/apps/emdash-desktop/src/main/core/workspaces/byoi/provision-byoi-task.ts index 85bf28e437..dbab60e5e7 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/byoi/provision-byoi-task.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/byoi/provision-byoi-task.ts @@ -83,7 +83,7 @@ export async function provisionBYOITask( const workspaceType: WorkspaceType = { kind: 'ssh', proxy, connectionId }; const workspaceMachine: MachineRef = { kind: 'ssh', connectionId }; - const workspace = await workspaceRegistry.acquire( + const acquired = await workspaceRegistry.acquire( workspaceId, projectId, createWorkspaceFactory(workspaceId, workspaceType, { @@ -126,24 +126,27 @@ export async function provisionBYOITask( }); const { taskProvider } = await buildTaskFromWorkspace( task, - workspace, + acquired.workspace, workspaceType, projectId, projectPath, - settings + settings, + undefined, + undefined, + acquired.sshFilesRuntime ); log.debug(`${logPrefix}: provisionBYOITask DONE`, { taskId: task.id }); provisionSucceeded = true; return { path: workDir, - workspaceId: workspace.id, + workspaceId: acquired.workspace.id, sshConnectionId: connectionId, taskProvider, workspaceProviderData: { ...wpConfig, remoteWorkspaceId: output.id }, }; } finally { if (!provisionSucceeded) { - await workspaceRegistry.teardown(workspace.id, 'terminate').catch(() => {}); + await workspaceRegistry.teardown(acquired.workspace.id, 'terminate').catch(() => {}); } } } diff --git a/apps/emdash-desktop/src/main/core/workspaces/project-settings-controller.ts b/apps/emdash-desktop/src/main/core/workspaces/project-settings-controller.ts index 304a7cca6a..7f56e91376 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/project-settings-controller.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/project-settings-controller.ts @@ -1,18 +1,22 @@ +import { err, ok } from '@emdash/shared'; import { getEffectiveTaskSettings } from '@main/core/projects/settings/effective-task-settings'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import type { ProjectSettings } from '@shared/core/project-settings/project-settings'; +import type { ProjectSettingsLoadResult } from '@shared/core/project-settings/project-settings'; import { createRPCController } from '@shared/lib/ipc/rpc'; -async function getSettings(workspaceId: string): Promise { +async function getSettings(workspaceId: string): Promise { const workspace = workspaceRegistry.get(workspaceId); if (!workspace) { - throw new Error(`Workspace ${workspaceId} not found`); + return err({ type: 'not_found', entity: 'workspace', workspaceId }); } - return getEffectiveTaskSettings({ - projectSettings: workspace.settings, - taskFs: workspace.fs, - }); + return ok( + await getEffectiveTaskSettings({ + projectSettings: workspace.settings, + taskFs: workspace.fileSystem, + taskConfigPath: workspace.configPath, + }) + ); } export const projectSettingsController = createRPCController({ diff --git a/apps/emdash-desktop/src/main/core/workspaces/recovery-strategy.ts b/apps/emdash-desktop/src/main/core/workspaces/recovery-strategy.ts index 49fc972174..d244df4a82 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/recovery-strategy.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/recovery-strategy.ts @@ -22,7 +22,7 @@ export async function applyRecovery( const { branchName, candidatePath } = error; // Try the hint path first, then search all worktrees. - const path = candidatePath ?? (await findBranchAnywhere(branchName, ctx)); + const path = candidatePath ?? (await ctx.worktreeService.findBranchAnywhere(branchName)); if (path) { log.info('recovery-strategy: branch already checked out elsewhere — adopting path', { @@ -42,8 +42,7 @@ export async function applyRecovery( log.info('recovery-strategy: stale directory — removing and pruning', { path }); try { - await ctx.host.removeAbsolute(path, { recursive: true }); - await ctx.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); + await ctx.worktreeService.removeWorktree(path); return { kind: 'retry' }; } catch (removeError) { log.warn('recovery-strategy: failed to remove stale directory', { @@ -56,22 +55,3 @@ export async function applyRecovery( return { kind: 'failed', error }; } - -async function findBranchAnywhere( - branchName: string, - ctx: StepContext -): Promise { - try { - const { stdout } = await ctx.ctx.exec('git', ['worktree', 'list', '--porcelain']); - const branchLine = `branch refs/heads/${branchName}`; - for (const block of stdout.split('\n\n')) { - if (!block.split('\n').some((line) => line === branchLine)) continue; - const match = /^worktree (.+)$/m.exec(block); - const candidatePath = match?.[1]; - if (!candidatePath) continue; - const gitFile = ctx.host.pathApi.join(candidatePath, '.git'); - if (await ctx.host.existsAbsolute(gitFile)) return candidatePath; - } - } catch {} - return undefined; -} diff --git a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/copy-preserved-files.ts b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/copy-preserved-files.ts index 333f654825..2b1654839c 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/copy-preserved-files.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/copy-preserved-files.ts @@ -1,25 +1,25 @@ +import type { IFileSystem } from '@emdash/core/files'; import { ok, type Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { getEffectiveTaskSettings } from '@main/core/projects/settings/effective-task-settings'; +import { + isSafePreservePattern, + preservedDestinationPath, + preservedRepoRelativePath, +} from '@main/core/projects/settings/preserve-pattern-safety'; import { log } from '@main/lib/logger'; import type * as Step from '@shared/core/workspaces/workspace-setup-steps/copy-preserved-files'; import type { StepContext } from './step-context'; -function makeTaskFs( - targetPath: string, - ctx: StepContext -): Pick { - return { - exists: (filePath) => ctx.host.existsAbsolute(ctx.host.pathApi.join(targetPath, filePath)), - read: async (filePath) => { - const content = await ctx.host.readFileAbsolute(ctx.host.pathApi.join(targetPath, filePath)); - return { content, truncated: false, totalSize: Buffer.byteLength(content) }; - }, - }; +function makeTaskFs(ctx: StepContext): IFileSystem | null { + const opened = ctx.files.fileSystem(); + if (opened.success) return opened.data; + log.warn('setup-steps/copy-preserved-files: failed to open task filesystem', opened.error); + return null; } -async function isTrackedSourcePath(relPath: string, ctx: StepContext): Promise { +async function isTrackedSourcePath(absPath: string, ctx: StepContext): Promise { try { + const relPath = ctx.files.path.relative(ctx.repoPath, absPath); await ctx.ctx.exec('git', ['ls-files', '--error-unmatch', '--', relPath]); return true; } catch { @@ -38,25 +38,49 @@ export async function execute( } try { + const taskFs = makeTaskFs(ctx); + if (!taskFs) return ok({}); + const settings = await getEffectiveTaskSettings({ projectSettings: ctx.projectSettings, - taskFs: makeTaskFs(targetPath, ctx) as unknown as FileSystemProvider, + taskFs, + taskConfigPath: ctx.files.path.join(targetPath, '.emdash.json'), }); const patterns = settings.preservePatterns ?? []; + const repoFs = ctx.files.fileSystem(); + if (!repoFs.success) { + log.warn('setup-steps/copy-preserved-files: failed to open repo filesystem', repoFs.error); + return ok({}); + } for (const pattern of patterns) { - const matches = await ctx.host.globAbsolute(pattern, { - cwd: ctx.repoPath, - dot: true, - }); - for (const relPath of matches) { - if (relPath === '.emdash.json' || (await isTrackedSourcePath(relPath, ctx))) continue; - const src = ctx.host.pathApi.join(ctx.repoPath, relPath); - const stat = await ctx.host.statAbsolute(src).catch(() => null); - if (!stat || stat.type !== 'file') continue; - const dest = ctx.host.pathApi.join(targetPath, relPath); - await ctx.host.mkdirAbsolute(ctx.host.pathApi.dirname(dest), { recursive: true }); - await ctx.host.copyFileAbsolute(src, dest); + if (!isSafePreservePattern(ctx.files.path, pattern)) { + log.warn('setup-steps/copy-preserved-files: skipping unsafe preserve pattern', { pattern }); + continue; + } + const matches = repoFs.data.glob([pattern], { cwd: ctx.repoPath, dot: true }); + if (!matches.success) { + log.warn('setup-steps/copy-preserved-files: failed to match preserve pattern', { + pattern, + error: matches.error, + }); + continue; + } + for await (const absPath of matches.data) { + const relPath = preservedRepoRelativePath(ctx.files.path, ctx.repoPath, absPath); + if (!relPath || (await isTrackedSourcePath(absPath, ctx))) continue; + const stat = await repoFs.data.stat(absPath); + if (!stat.success || stat.data.type !== 'file') continue; + const destPath = preservedDestinationPath(ctx.files.path, targetPath, relPath); + if (!destPath) continue; + const copied = await repoFs.data.copyFile(absPath, destPath); + if (!copied.success) { + log.warn('setup-steps/copy-preserved-files: failed to copy preserved file', { + sourcePath: absPath, + destPath, + error: copied.error, + }); + } } } } catch (error: unknown) { diff --git a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/git-fetch.test.ts b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/git-fetch.test.ts index b5dcfa5184..f00afe6efc 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/git-fetch.test.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/git-fetch.test.ts @@ -7,9 +7,13 @@ function makeCtx(exec: StepContext['ctx']['exec']): StepContext { ctx: { exec } as StepContext['ctx'], repoPath: '/repo', worktreePoolPath: '/repo/.emdash/worktrees', - host: {} as StepContext['host'], + files: {} as StepContext['files'], projectSettings: {} as StepContext['projectSettings'], - worktreeService: {} as StepContext['worktreeService'], + worktreeService: { + findBranchAnywhere: vi.fn(), + removeWorktree: vi.fn(), + serveBranchWorktree: vi.fn(), + } as StepContext['worktreeService'], }; } diff --git a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/step-context.ts b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/step-context.ts index d8d7185e20..2e0e049ee2 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/step-context.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/step-context.ts @@ -1,7 +1,7 @@ import type { IExecutionContext } from '@main/core/execution-context/types'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/provider'; -import type { WorktreeHost } from '@main/core/projects/worktrees/hosts/worktree-host'; import type { WorktreeService } from '@main/core/projects/worktrees/worktree-service'; +import type { IFilesRuntime } from '@main/core/runtime/types'; /** * Context passed to every workspace setup step executor. @@ -15,12 +15,15 @@ export type StepContext = { repoPath: string; /** Absolute path to the worktree pool directory where worktrees are created. */ worktreePoolPath: string; - /** Filesystem host (local or SSH). */ - host: WorktreeHost; + /** Runtime-owned files capability for the project machine. */ + files: IFilesRuntime; /** Project settings provider (used by copy-preserved-files). */ projectSettings: ProjectSettingsProvider; /** Worktree service that owns checkout validation, stale cleanup, and checkout creation. */ - worktreeService: Pick; + worktreeService: Pick< + WorktreeService, + 'findBranchAnywhere' | 'removeWorktree' | 'serveBranchWorktree' + >; /** * Resolved worktree path from a preceding `add-worktree` step. * Populated by the executor after a successful add-worktree step so that diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.db.test.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.db.test.ts index 649c65498d..7fc2654d89 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.db.test.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.db.test.ts @@ -150,7 +150,7 @@ describe('WorkspaceBootstrapService', () => { describe('ensureWorkspaceSetup', () => { it('repairs persisted branch worktree paths before acquiring the workspace', async () => { const serveBranchWorktree = vi.fn().mockResolvedValue(ok('/worktrees/task-branch')); - const existsAbsolute = vi.fn().mockResolvedValue(true); + const existsAtAbsolutePath = vi.fn().mockResolvedValue(true); const project = { projectId: 'proj-1', type: 'local', @@ -163,10 +163,8 @@ describe('WorkspaceBootstrapService', () => { getConfiguredRemotes: vi.fn(), }, gitRepositoryFetchService: {}, - worktreeHost: { - existsAbsolute, - }, worktreeService: { + existsAtAbsolutePath, serveBranchWorktree, }, } as unknown as ProjectProvider; @@ -200,7 +198,7 @@ describe('WorkspaceBootstrapService', () => { type: 'local', branch: 'main', }); - expect(existsAbsolute).not.toHaveBeenCalledWith('/worktrees/broken-task-branch'); + expect(existsAtAbsolutePath).not.toHaveBeenCalledWith('/worktrees/broken-task-branch'); expect(mocks.acquireWorkspace).toHaveBeenCalled(); const [ws] = await fixture.db.select().from(workspaces).where(eq(workspaces.id, WS_ID)); diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.ts index 220a6e3fda..33801f7185 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.ts @@ -20,8 +20,6 @@ import { compileSetupSpec } from '@shared/core/workspaces/workspace-setup-spec'; import type { WorkspaceType } from '@shared/core/workspaces/workspaces'; import { deriveBranchName, resolveWorkspaceIntent } from '../tasks/resolve-workspace-intent'; import { provisionBYOITask } from './byoi/provision-byoi-task'; -import { LocalWorkspaceSetupExecutor } from './local-workspace-setup-executor'; -import { applyRecovery } from './recovery-strategy'; import { getProvisionedWorkspaceBranch } from './workspace-branch'; import { createWorkspaceFactory } from './workspace-factory'; import { computeWorkspaceKey } from './workspace-key'; @@ -147,7 +145,7 @@ export class WorkspaceBootstrapService { // Fast path: non-worktree path already persisted and still exists on disk. if (workspaceRow.path && !isByoi) { - const exists = await project.worktreeHost.existsAbsolute(workspaceRow.path); + const exists = await project.worktreeService.existsAtAbsolutePath(workspaceRow.path); if (exists) { return this._acquireAndBuild( workspaceRow.id, @@ -201,28 +199,7 @@ export class WorkspaceBootstrapService { } const worktreePoolPath = await project.worktreeService.getWorktreePoolPath(); - const stepCtx = { - ctx: project.ctx, - repoPath: project.repoPath, - worktreePoolPath, - host: project.worktreeHost, - projectSettings: project.settings, - worktreeService: project.worktreeService, - }; - - const executor = new LocalWorkspaceSetupExecutor(stepCtx); - let setupResult = await executor.execute(spec); - - if (!setupResult.success) { - const recovery = await applyRecovery(setupResult.error, stepCtx); - - if (recovery.kind === 'resolved') { - setupResult = ok({ path: recovery.path, warnings: [] }); - } else if (recovery.kind === 'retry') { - setupResult = await executor.execute(spec); - } - // 'failed' falls through to the error check below - } + const setupResult = await project.runWorkspaceSetup(spec, worktreePoolPath); if (!setupResult.success) { const { kind, type } = setupResult.error; @@ -333,9 +310,9 @@ export class WorkspaceBootstrapService { message: 'Initialising workspace…', }); - let workspace; + let acquired; try { - workspace = await workspaceRegistry.acquire( + acquired = await workspaceRegistry.acquire( workspaceId, project.projectId, createWorkspaceFactory(workspaceId, type, { @@ -373,13 +350,14 @@ export class WorkspaceBootstrapService { try { const buildResult = await buildTaskFromWorkspace( task, - workspace, + acquired.workspace, type, project.projectId, project.repoPath, project.settings, workspaceBranchName, - workspaceSourceBranch + workspaceSourceBranch, + acquired.sshFilesRuntime ); buildSucceeded = true; return ok({ diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts index 8fda946a75..f823f254fe 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts @@ -3,11 +3,10 @@ import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conve import type { ConversationProvider } from '@main/core/conversations/types'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; import { GitRepositoryService } from '@main/core/git/repository/service'; import { previewServerService } from '@main/core/preview-servers/preview-server-service-instance'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import type { MachineRef, RuntimeManager } from '@main/core/runtime/types'; import { workspaceFileIndexService } from '@main/core/search/workspace-file-index-service'; import { appSettingsService } from '@main/core/settings/settings-service'; @@ -72,13 +71,19 @@ export function createWorkspaceFactory( return async () => { const workDir = context.workDir; - // Transport-specific FS and exec - const workspaceFs = - type.kind === 'ssh' ? new SshFileSystem(type.proxy, workDir) : new LocalFileSystem(workDir); - const ctx = type.kind === 'ssh' ? new SshExecutionContext(type.proxy) : new LocalExecutionContext(); + const runtime = await acquireWorkspaceRuntime(context.workspaceRuntime, workDir); + const { gitWorktree, fileTree, filesRuntime } = runtime; + const openedFileSystem = filesRuntime.fileSystem(); + if (!openedFileSystem.success) { + await runtime.release(); + throw new Error(`Failed to open file system: ${openedFileSystem.error.message}`); + } + const fileSystem = openedFileSystem.data; + const configPath = filesRuntime.path.join(workDir, '.emdash.json'); + // Settings (shared) const projectSettings = await context.settings.get(); const defaultBranch = await context.settings.getDefaultBranch(); @@ -93,7 +98,8 @@ export function createWorkspaceFactory( const tmuxEnabled = projectSettings.tmux ?? false; const taskLevelSettings = await getEffectiveTaskSettings({ projectSettings: context.settings, - taskFs: workspaceFs, + taskFs: fileSystem, + taskConfigPath: configPath, }); const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; const scripts = taskLevelSettings.scripts; @@ -130,9 +136,6 @@ export function createWorkspaceFactory( terminals: workspaceTerminals, }); - const runtime = await acquireWorkspaceRuntime(context.workspaceRuntime, workDir); - const { gitWorktree, fileTree, filesRuntime } = runtime; - const gitRepository = context.gitRepository ?? new GitRepositoryService(gitWorktree.repository, context.settings); @@ -147,7 +150,8 @@ export function createWorkspaceFactory( const workspace: Workspace = { id: workspaceId, path: workDir, - fs: workspaceFs, + configPath, + fileSystem, fileTree, gitWorktree, settings: context.settings, @@ -169,6 +173,7 @@ export function createWorkspaceFactory( return { workspace, + sshFilesRuntime: type.kind === 'ssh' ? filesRuntime : undefined, onCreateSideEffect: (ws) => { void workspaceFileIndexService.onWorkspaceActivated(workspaceId, { @@ -191,18 +196,14 @@ export function createWorkspaceFactory( update, }); }); - const fileChanges = filesRuntime.watchChanges( - workDir, - (update) => { - events.emit(fileChangesChannel, { - projectId: context.projectId, - workspaceId, - update, - }); - workspaceFileIndexService.onWorkspaceFileChange(workspaceId, update); - }, - { paths: [''] } - ); + const fileChanges = filesRuntime.watchChanges(workDir, (update) => { + events.emit(fileChangesChannel, { + projectId: context.projectId, + workspaceId, + update, + }); + workspaceFileIndexService.onWorkspaceFileChange(workspaceId, update); + }); if (fileChanges.success) { unsubscribeFileChanges = fileChanges.data.unsubscribe; void fileChanges.data.ready().then((result) => { @@ -275,11 +276,12 @@ export function createWorkspaceFactory( gitRepositoryFetchService.stop(); } workspaceFileIndexService.onWorkspaceDeactivated(workspaceId); + const latestProjectSettings = await context.settings.get(); const latestTaskSettings = await getEffectiveTaskSettings({ projectSettings: context.settings, - taskFs: ws.fs, + taskFs: ws.fileSystem, + taskConfigPath: ws.configPath, }); - const latestProjectSettings = await context.settings.get(); const latestShellSetup = latestTaskSettings.shellSetup ?? latestProjectSettings.shellSetup; const teardownScript = latestTaskSettings.scripts?.teardown; @@ -325,8 +327,8 @@ async function acquireWorkspaceRuntime( if (!openedFileTree.success) { throw new Error(`Failed to open file tree: ${JSON.stringify(openedFileTree.error)}`); } - const fileTreeLease = openedFileTree.data; + let released = false; return { gitWorktree: worktreeLease.value, @@ -358,6 +360,7 @@ type TaskProviderOpts = { tmuxEnabled: boolean; shellSetup?: string; taskEnvVars: Record; + filesRuntime?: IFilesRuntime; }; async function resolveLocalConversationShellProfile(taskId: string): Promise { @@ -385,6 +388,9 @@ export async function buildTaskProviders( opts: TaskProviderOpts ): Promise<{ conversations: ConversationProvider; terminals: TerminalProvider }> { if (type.kind === 'ssh') { + if (!opts.filesRuntime) { + throw new Error('Missing SSH files runtime for SSH task provider'); + } const ctx = new SshExecutionContext(type.proxy); return { conversations: new SshConversationProvider({ @@ -395,6 +401,7 @@ export async function buildTaskProviders( shellSetup: opts.shellSetup, ctx, proxy: type.proxy, + filesRuntime: opts.filesRuntime, taskEnvVars: opts.taskEnvVars, }), terminals: new SshTerminalProvider({ @@ -444,7 +451,7 @@ export async function buildTaskProviders( */ export async function resolveTaskEnv( task: Pick, - workspace: Pick, + workspace: Pick, projectPath: string, settings: ProjectSettingsProvider ): Promise<{ @@ -456,7 +463,8 @@ export async function resolveTaskEnv( const defaultBranch = await settings.getDefaultBranch(); const taskLevelSettings = await getEffectiveTaskSettings({ projectSettings: settings, - taskFs: workspace.fs, + taskFs: workspace.fileSystem, + taskConfigPath: workspace.configPath, }); return { taskEnvVars: getTaskEnvVars({ diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts index 42f6e22ba7..44838f68e1 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts @@ -16,7 +16,8 @@ function makeWorkspace(id: string): { workspace: { id, path: `/tmp/${id}`, - fs: {} as Workspace['fs'], + configPath: `/tmp/${id}/.emdash.json`, + fileSystem: {} as Workspace['fileSystem'], fileTree: { dispose: fileTreeDispose } as unknown as Workspace['fileTree'], gitWorktree: { dispose: gitDispose } as unknown as Workspace['gitWorktree'], settings: {} as Workspace['settings'], @@ -41,8 +42,8 @@ describe('WorkspaceRegistry', () => { const first = await registry.acquire('branch:main', 'test-project', factory); const second = await registry.acquire('branch:main', 'test-project', factory); - expect(first).toBe(workspace); - expect(second).toBe(workspace); + expect(first.workspace).toBe(workspace); + expect(second.workspace).toBe(workspace); expect(factory).toHaveBeenCalledTimes(1); expect(registry.get('branch:main')).toBe(workspace); expect(registry.refCount('branch:main')).toBe(2); @@ -65,8 +66,8 @@ describe('WorkspaceRegistry', () => { expect(factory).toHaveBeenCalledTimes(1); resolveFactory?.({ workspace }); - await expect(first).resolves.toBe(workspace); - await expect(second).resolves.toBe(workspace); + await expect(first.then((acquired) => acquired.workspace)).resolves.toBe(workspace); + await expect(second.then((acquired) => acquired.workspace)).resolves.toBe(workspace); expect(registry.refCount('branch:main')).toBe(2); }); @@ -146,9 +147,9 @@ describe('WorkspaceRegistry', () => { }); const factory = vi.fn(async () => ({ workspace, onCreate })); - const acquired = registry.acquire('branch:main', 'test-project', factory).then((ws) => { + const acquired = registry.acquire('branch:main', 'test-project', factory).then((result) => { order.push('acquired'); - return ws; + return result.workspace; }); await acquired; diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts index 051706d474..acb90a9638 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts @@ -1,4 +1,5 @@ import { once } from '@emdash/shared'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import type { Workspace } from './workspace'; export type TeardownMode = 'detach' | 'terminate'; @@ -10,10 +11,17 @@ type WorkspaceHooks = { onDetach?: (workspace: Workspace) => Promise; }; -export type WorkspaceFactoryResult = { workspace: Workspace } & WorkspaceHooks; +export type WorkspaceAcquireResult = { + workspace: Workspace; + /** Transitional SSH-only capability for legacy SSH conversation adapters. */ + sshFilesRuntime?: IFilesRuntime; +}; + +export type WorkspaceFactoryResult = WorkspaceAcquireResult & WorkspaceHooks; type WorkspaceEntry = { workspace: Workspace; + sshFilesRuntime?: IFilesRuntime; refCount: number; projectId: string; onDestroy?: (workspace: Workspace) => Promise; @@ -24,25 +32,25 @@ type WorkspaceEntry = { export class WorkspaceRegistry { private entries = new Map(); - private acquiring = new Map>(); + private acquiring = new Map>(); async acquire( key: string, projectId: string, factory: () => Promise - ): Promise { + ): Promise { const existing = this.entries.get(key); if (existing) { existing.refCount += 1; - return existing.workspace; + return { workspace: existing.workspace, sshFilesRuntime: existing.sshFilesRuntime }; } const inFlight = this.acquiring.get(key); if (inFlight) { - const workspace = await inFlight; + const acquired = await inFlight; const current = this.entries.get(key); if (current) current.refCount += 1; - return workspace; + return acquired; } const pending = factory() @@ -50,6 +58,7 @@ export class WorkspaceRegistry { const workspace = result.workspace; this.entries.set(key, { workspace, + sshFilesRuntime: result.sshFilesRuntime, refCount: 1, projectId, onDestroy: result.onDestroy, @@ -64,7 +73,7 @@ export class WorkspaceRegistry { }); result.onCreateSideEffect?.(workspace); await result.onCreate?.(workspace); - return workspace; + return { workspace, sshFilesRuntime: result.sshFilesRuntime }; }) .finally(() => { this.acquiring.delete(key); diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace.ts index 8a8177eaed..a379951d47 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace.ts @@ -1,6 +1,5 @@ -import type { IFileTree } from '@emdash/core/files'; +import type { IFileSystem, IFileTree } from '@emdash/core/files'; import type { IGitWorktree } from '@emdash/core/git'; -import type { FileSystemProvider } from '@main/core/fs/types'; import type { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; import type { GitRepositoryService } from '@main/core/git/repository/service'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/provider'; @@ -9,7 +8,8 @@ import type { LifecycleScriptService } from './workspace-lifecycle-service'; export interface Workspace { readonly id: string; readonly path: string; - readonly fs: FileSystemProvider; + readonly configPath: string; + readonly fileSystem: IFileSystem; readonly fileTree: IFileTree; readonly gitWorktree: IGitWorktree; readonly settings: ProjectSettingsProvider; From c1ba9257f59928d8c2a0df020808f30037864526 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:13:45 -0700 Subject: [PATCH 24/37] feat(project-settings): read config through file runtime --- .../settings/effective-task-settings.test.ts | 64 ++-- .../settings/effective-task-settings.ts | 32 +- .../legacy-project-settings-migration.ts | 35 ++- .../settings/preserve-pattern-safety.ts | 34 +++ .../settings/project-settings-service.ts | 2 +- .../settings/project-settings.test.ts | 154 ++++++---- .../providers/db-project-settings-provider.ts | 28 +- .../local-project-settings-provider.ts | 33 +- .../ssh-project-settings-provider.ts | 15 +- .../sharing/codex-config-migration.ts | 108 +++---- .../sharing/conductor-config-migration.ts | 149 +++++---- .../sharing/config-migration-utils.ts | 108 +++++++ .../settings/sharing/config-migration.test.ts | 242 ++++++++------- .../settings/sharing/config-migration.ts | 48 ++- .../sharing/paseo-config-migration.ts | 106 +++---- .../project-settings-override-state.ts | 16 +- .../project-settings-target-resolver.ts | 52 ++-- .../share-project-settings-to-config.test.ts | 284 +++++++++++------- .../share-project-settings-to-config.ts | 47 ++- .../sharing/superset-config-migration.ts | 101 +++---- .../settings/worktree-directory.test.ts | 7 +- .../projects/settings/worktree-directory.ts | 25 +- .../core/project-settings/project-settings.ts | 7 + 23 files changed, 1020 insertions(+), 677 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/projects/settings/preserve-pattern-safety.ts create mode 100644 apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration-utils.ts diff --git a/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.test.ts index 98591d0016..213278f03b 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.test.ts @@ -1,5 +1,6 @@ +import type { IFileSystem } from '@emdash/core/files'; +import { ok } from '@emdash/shared'; import { describe, expect, it, vi } from 'vitest'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { getEffectiveTaskSettings } from './effective-task-settings'; import type { ProjectSettingsProvider } from './provider'; @@ -9,32 +10,40 @@ function makeProjectSettings(settings: Awaited { return { - exists: vi.fn().mockResolvedValue(config !== null), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify(config), - truncated: false, - totalSize: 0, - }), - } as unknown as FileSystemProvider; + exists: vi.fn(async () => ok(config !== null)), + readText: vi.fn(async () => + ok({ + content: JSON.stringify(config), + truncated: false, + totalSize: 0, + }) + ), + }; } describe('getEffectiveTaskSettings', () => { + const taskConfigPath = '/worktree/.emdash.json'; + it('merges shareable project settings by leaf with project settings winning', async () => { + const taskFs = makeTaskFs({ + scripts: { setup: 'pnpm install', run: 'npm run dev' }, + shellSetup: 'source .envrc', + tmux: true, + remote: 'upstream', + }); const settings = await getEffectiveTaskSettings({ projectSettings: makeProjectSettings({ preservePatterns: ['.env.local'], scripts: { run: 'pnpm dev' }, }), - taskFs: makeTaskFs({ - scripts: { setup: 'pnpm install', run: 'npm run dev' }, - shellSetup: 'source .envrc', - tmux: true, - remote: 'upstream', - }), + taskFs, + taskConfigPath, }); + expect(taskFs.exists).toHaveBeenCalledWith(taskConfigPath); + expect(taskFs.readText).toHaveBeenCalledWith(taskConfigPath); expect(settings).toMatchObject({ preservePatterns: ['.env.local'], shellSetup: 'source .envrc', @@ -52,9 +61,10 @@ describe('getEffectiveTaskSettings', () => { const settings = await getEffectiveTaskSettings({ projectSettings: makeProjectSettings({ shellSetup: 'nvm use' }), taskFs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ content: '{', truncated: false, totalSize: 1 }), - } as unknown as FileSystemProvider, + exists: vi.fn(async () => ok(true)), + readText: vi.fn(async () => ok({ content: '{', truncated: false, totalSize: 1 })), + }, + taskConfigPath, }); expect(settings.preservePatterns).toContain('.env'); @@ -62,12 +72,30 @@ describe('getEffectiveTaskSettings', () => { expect(settings.shellSetup).toBe('nvm use'); }); + it('falls back to project settings when the task config read is truncated', async () => { + const settings = await getEffectiveTaskSettings({ + projectSettings: makeProjectSettings({ + scripts: { run: 'pnpm dev' }, + }), + taskFs: { + exists: vi.fn(async () => ok(true)), + readText: vi.fn(async () => + ok({ content: '{"scripts":', truncated: true, totalSize: 204_801 }) + ), + }, + taskConfigPath, + }); + + expect(settings.scripts?.run).toBe('pnpm dev'); + }); + it('falls back to defaults when project settings are invalid', async () => { const settings = await getEffectiveTaskSettings({ projectSettings: makeProjectSettings({ preservePatterns: 'not-an-array', } as never), taskFs: makeTaskFs(null), + taskConfigPath, }); expect(settings.preservePatterns).toContain('.env'); diff --git a/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.ts b/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.ts index 8b68e57cce..9d8337f6eb 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.ts @@ -1,4 +1,4 @@ -import type { FileSystemProvider } from '@main/core/fs/types'; +import type { IFileSystem } from '@emdash/core/files'; import { log } from '@main/lib/logger'; import { defaultShareableProjectSettings, @@ -10,20 +10,38 @@ import type { ProjectSettingsProvider } from './provider'; export async function getEffectiveTaskSettings(args: { projectSettings: ProjectSettingsProvider; - taskFs: FileSystemProvider; + taskFs: Pick; + taskConfigPath: string; }): Promise { - const { projectSettings, taskFs } = args; + const { projectSettings, taskFs, taskConfigPath } = args; const parsedSettings = shareableProjectSettingsSchema.safeParse(await projectSettings.get()); const localShareableSettings = parsedSettings.success ? parsedSettings.data : {}; const defaults = defaultShareableProjectSettings(); - const exists = await taskFs.exists('.emdash.json'); - if (!exists) { + const exists = await taskFs.exists(taskConfigPath); + if (!exists.success) { + log.warn('Failed to check task .emdash.json, falling back to project settings', exists.error); + return mergeShareableProjectSettings(defaults, localShareableSettings); + } + if (!exists.data) { return mergeShareableProjectSettings(defaults, localShareableSettings); } try { - const { content } = await taskFs.read('.emdash.json'); - const projectFileSettings = shareableProjectSettingsSchema.parse(JSON.parse(content)); + const content = await taskFs.readText(taskConfigPath); + if (!content.success) { + log.warn('Failed to read task .emdash.json, falling back to project settings', content.error); + return mergeShareableProjectSettings(defaults, localShareableSettings); + } + if (content.data.truncated) { + log.warn('Task .emdash.json was truncated, falling back to project settings', { + path: taskConfigPath, + totalSize: content.data.totalSize, + }); + return mergeShareableProjectSettings(defaults, localShareableSettings); + } + const projectFileSettings = shareableProjectSettingsSchema.parse( + JSON.parse(content.data.content) + ); return mergeShareableProjectSettings(defaults, projectFileSettings, localShareableSettings); } catch (err) { log.warn('Failed to parse task .emdash.json, falling back to project settings', err); diff --git a/apps/emdash-desktop/src/main/core/projects/settings/legacy-project-settings-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/legacy-project-settings-migration.ts index 7a55263774..9657a043cf 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/legacy-project-settings-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/legacy-project-settings-migration.ts @@ -1,5 +1,5 @@ +import type { IFileSystem } from '@emdash/core/files'; import type { Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { remoteNameFromQualifiedRef } from '@shared/core/git/utils'; import { @@ -22,7 +22,8 @@ import type { ProjectSettingsStorage, StoredProjectSettings } from './project-se export type LegacyProjectSettingsMigrationArgs = { projectId: string; row: StoredProjectSettings | undefined; - configReader: Pick | undefined; + configReader: Pick | undefined; + configPath: string; defaultBranchFallback: string; storage: ProjectSettingsStorage; git?: ProjectSettingsGitInspector; @@ -49,7 +50,8 @@ function normalizeLegacyDefaultBranch( } async function readLegacyProjectConfig( - configReader: Pick | undefined + configReader: Pick | undefined, + configPath: string ): Promise< | (BaseProjectSettings & { remote?: string; @@ -58,9 +60,25 @@ async function readLegacyProjectConfig( > { if (!configReader) return undefined; try { - if (!(await configReader.exists('.emdash.json'))) return undefined; - const { content } = await configReader.read('.emdash.json'); - const parsed = legacyProjectConfigSchema.safeParse(parseJsonObject(content)); + const exists = await configReader.exists(configPath); + if (!exists.success) { + log.warn('Failed to check legacy .emdash.json for migration', exists.error); + return undefined; + } + if (!exists.data) return undefined; + const content = await configReader.readText(configPath); + if (!content.success) { + log.warn('Failed to read legacy .emdash.json for migration', content.error); + return undefined; + } + if (content.data.truncated) { + log.warn('Legacy .emdash.json was truncated during migration', { + path: configPath, + totalSize: content.data.totalSize, + }); + return undefined; + } + const parsed = legacyProjectConfigSchema.safeParse(parseJsonObject(content.data.content)); if (!parsed.success) { log.warn('Failed to parse legacy .emdash.json for migration', parsed.error); return undefined; @@ -76,6 +94,7 @@ export async function migrateLegacyProjectSettingsIfNeeded({ projectId, row, configReader, + configPath, defaultBranchFallback, storage, git, @@ -100,7 +119,7 @@ export async function migrateLegacyProjectSettingsIfNeeded({ 'shareable project settings' ); const { remote, ...currentSettings } = current; - const legacy = await readLegacyProjectConfig(configReader); + const legacy = await readLegacyProjectConfig(configReader, configPath); const next: BaseProjectSettings = baseProjectSettingsSchema.parse({ ...currentSettings, baseRemote: currentSettings.baseRemote ?? remote, @@ -129,7 +148,7 @@ export async function migrateLegacyProjectSettingsIfNeeded({ } if (legacy && !shareableAlreadyMigrated) { - if ((await git?.isFileCleanlyTracked('.emdash.json')) === false) { + if ((await git?.isFileCleanlyTracked(configPath)) === false) { const legacyShareable = shareableProjectSettingsSchema.parse(legacy); nextShareable = mergeShareableProjectSettings(currentShareable, legacyShareable); } diff --git a/apps/emdash-desktop/src/main/core/projects/settings/preserve-pattern-safety.ts b/apps/emdash-desktop/src/main/core/projects/settings/preserve-pattern-safety.ts new file mode 100644 index 0000000000..581bd70ed3 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/projects/settings/preserve-pattern-safety.ts @@ -0,0 +1,34 @@ +import type { RuntimePath } from '@main/core/runtime/types'; + +export function isSafePreservePattern(machinePath: RuntimePath, pattern: string): boolean { + const trimmed = pattern.trim(); + if (!trimmed) return false; + if (looksAbsolute(machinePath, trimmed)) return false; + return !trimmed.replace(/\\/g, '/').split('/').includes('..'); +} + +export function preservedRepoRelativePath( + machinePath: RuntimePath, + repoPath: string, + absPath: string +): string | null { + if (!machinePath.contains(repoPath, absPath)) return null; + const relPath = machinePath.relative(repoPath, absPath).replace(/\\/g, '/'); + if (!relPath || relPath === '.emdash.json') return null; + if (relPath === '..' || relPath.startsWith('../') || looksAbsolute(machinePath, relPath)) + return null; + return relPath; +} + +export function preservedDestinationPath( + machinePath: RuntimePath, + targetPath: string, + relPath: string +): string | null { + const destPath = machinePath.join(targetPath, relPath); + return machinePath.contains(targetPath, destPath) ? destPath : null; +} + +function looksAbsolute(machinePath: RuntimePath, value: string): boolean { + return machinePath.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\'); +} diff --git a/apps/emdash-desktop/src/main/core/projects/settings/project-settings-service.ts b/apps/emdash-desktop/src/main/core/projects/settings/project-settings-service.ts index fd4f1a81f9..68b433db61 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/project-settings-service.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/project-settings-service.ts @@ -145,7 +145,7 @@ export class ProjectSettingsService implements Hookable, I const overrideState = await computeProjectSettingsOverrideState(resolvedTargets); const configMigrations = hasConfiguredShareableProjectSettings(settings) ? [] - : await inspectProjectConfigMigrations(project.fs); + : await inspectProjectConfigMigrations(project); return { settings, defaults, diff --git a/apps/emdash-desktop/src/main/core/projects/settings/project-settings.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/project-settings.test.ts index 5fbf509968..6d6bbace5f 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/project-settings.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/project-settings.test.ts @@ -2,9 +2,10 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { DEFAULT_PRESERVE_PATTERNS } from '@shared/core/project-settings/project-settings'; import type { ProjectSettingsStorage } from './project-settings-storage'; import { LocalProjectSettingsProvider } from './providers/local-project-settings-provider'; @@ -20,6 +21,62 @@ function makeTrackingGit(isFileCleanlyTracked: boolean) { }; } +const projectId = () => `project-${randomUUID()}`; + +function makeLocalConfigReader(projectPath: string): Pick { + const resolvePath = (filePath: string) => + path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath); + return { + exists: vi.fn(async (filePath: string) => ok(fs.existsSync(resolvePath(filePath)))), + readText: vi.fn(async (filePath: string) => { + try { + const content = fs.readFileSync(resolvePath(filePath), 'utf8'); + return ok({ content, truncated: false, totalSize: Buffer.byteLength(content) }); + } catch { + return err({ + type: 'fs-error' as const, + path: filePath, + message: `File not found: ${filePath}`, + code: 'ENOENT', + }); + } + }), + }; +} + +function makeLocalProvider( + projectPath: string, + options?: ConstructorParameters[4] +): LocalProjectSettingsProvider { + return new LocalProjectSettingsProvider( + projectId(), + projectPath, + 'main', + makeLocalConfigReader(projectPath), + options + ); +} + +function makeSshConfigReader( + config: unknown | null = null +): Pick { + return { + exists: vi.fn(async () => ok(config !== null)), + readText: vi.fn(async (filePath: string) => { + if (config === null) { + return err({ + type: 'fs-error' as const, + path: filePath, + message: `File not found: ${filePath}`, + code: 'NOT_FOUND', + }); + } + const content = JSON.stringify(config); + return ok({ content, truncated: false, totalSize: Buffer.byteLength(content) }); + }), + }; +} + vi.mock('@main/core/settings/settings-service', () => ({ appSettingsService: { get: vi.fn().mockImplementation((key: string) => { @@ -72,8 +129,6 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }; }; - const projectId = () => `project-${randomUUID()}`; - beforeEach(() => { storageMockState.storage = createStorage(); }); @@ -89,7 +144,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.toMatchObject({ preservePatterns: [...DEFAULT_PRESERVE_PATTERNS], @@ -104,7 +159,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { JSON.stringify({ shellSetup: 'nvm use' }) ); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.toMatchObject({ preservePatterns: [...DEFAULT_PRESERVE_PATTERNS], @@ -119,7 +174,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { JSON.stringify({ preservePatterns: ['.env.shared'] }) ); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.not.toHaveProperty('preservePatterns'); }); @@ -140,9 +195,8 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }) ); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main', { - git: makeTrackingGit(false), - }); + const git = makeTrackingGit(false); + const provider = makeLocalProvider(projectPath, { git }); await expect(provider.get()).resolves.toMatchObject({ preservePatterns: ['.env.local'], @@ -153,6 +207,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { teardown: 'pnpm cleanup', }, }); + expect(git.isFileCleanlyTracked).toHaveBeenCalledWith(path.join(projectPath, '.emdash.json')); }); it('migrates local-only shareable settings for rows already base-migrated', async () => { @@ -181,9 +236,8 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main', { - git: makeTrackingGit(false), - }); + const git = makeTrackingGit(false); + const provider = makeLocalProvider(projectPath, { git }); await expect(provider.get()).resolves.toMatchObject({ shellSetup: 'nvm use', @@ -192,6 +246,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { run: 'pnpm dev', }, }); + expect(git.isFileCleanlyTracked).toHaveBeenCalledWith(path.join(projectPath, '.emdash.json')); const result = await provider.update({ preservePatterns: [] }); expect(result.success).toBe(true); @@ -213,22 +268,22 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }) ); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main', { - git: makeTrackingGit(true), - }); + const git = makeTrackingGit(true); + const provider = makeLocalProvider(projectPath, { git }); await expect(provider.get()).resolves.toMatchObject({ preservePatterns: [...DEFAULT_PRESERVE_PATTERNS], }); await expect(provider.get()).resolves.not.toHaveProperty('shellSetup'); await expect(provider.get()).resolves.not.toHaveProperty('scripts'); + expect(git.isFileCleanlyTracked).toHaveBeenCalledWith(path.join(projectPath, '.emdash.json')); }); it('does not seed computed worktreeDirectory into project settings', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.not.toHaveProperty('worktreeDirectory'); await expect(provider.getDefaultWorktreeDirectory()).resolves.toBe('/tmp/emdash/worktrees'); @@ -251,7 +306,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.toMatchObject({ baseRemote: 'upstream' }); expect(JSON.parse(row.baseProjectSettingsJson)).toEqual({ baseRemote: 'upstream' }); @@ -260,7 +315,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('keeps computed worktreeDirectory default separate from configured overrides', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const expectedOverridePath = path.resolve(projectPath, 'worktrees'); const result = await provider.update({ preservePatterns: [], @@ -277,7 +332,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('stores the selected GitHub account as base project settings', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], @@ -291,7 +346,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('stores null GitHub account selection as an explicit project override', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], @@ -324,7 +379,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.patch({ githubAccountId: 'github.com:42' }); @@ -363,7 +418,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.ensure()).rejects.toThrow('db write failed'); await expect(provider.ensure()).resolves.toBeUndefined(); @@ -396,7 +451,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.patch({ clearShareableFields: ['preservePatterns', 'scripts.run'], @@ -414,7 +469,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const expectedPath = path.resolve(projectPath, 'worktrees'); const result = await provider.update({ preservePatterns: [], worktreeDirectory: expectedPath }); expect(result.success).toBe(true); @@ -430,7 +485,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); expect(result).toEqual({ @@ -443,7 +498,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const foreignPath = process.platform === 'win32' ? '/tmp/worktrees' : 'C:\\worktrees'; const result = await provider.update({ preservePatterns: [], worktreeDirectory: foreignPath }); @@ -458,7 +513,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { tempDirs.push(projectPath); fs.writeFileSync(path.join(projectPath, 'not-a-directory'), 'file'); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], worktreeDirectory: path.join(projectPath, 'not-a-directory', 'worktrees'), @@ -473,7 +528,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], worktreeDirectory: ' ' }); expect(result.success).toBe(true); @@ -481,12 +536,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('normalizes and canonicalizes ssh absolute worktreeDirectory on update', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/ssh-worktrees')), }; const provider = new SshProjectSettingsProvider( @@ -512,12 +565,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('rejects ssh relative worktreeDirectory values', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/ssh-worktrees')), }; const provider = new SshProjectSettingsProvider( @@ -538,9 +589,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('uses project-scoped ssh default worktree directory when not configured', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const provider = new SshProjectSettingsProvider( projectId(), @@ -554,12 +603,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('rejects tilde worktreeDirectory for ssh projects', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/ssh-worktrees')), }; const provider = new SshProjectSettingsProvider( @@ -581,12 +628,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('falls back to project-scoped ssh default when configured directory is invalid', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ worktreeDirectory: '~/worktrees' }), - }), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader({ worktreeDirectory: '~/worktrees' }); const provider = new SshProjectSettingsProvider( projectId(), @@ -600,12 +642,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('expands and caches ssh home for tilde worktreeDirectory values', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/ssh-worktrees')), }; const ctx = { root: undefined, diff --git a/apps/emdash-desktop/src/main/core/projects/settings/providers/db-project-settings-provider.ts b/apps/emdash-desktop/src/main/core/projects/settings/providers/db-project-settings-provider.ts index d41cf276f6..a2be540c3c 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/providers/db-project-settings-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/providers/db-project-settings-provider.ts @@ -1,5 +1,5 @@ +import type { IFileSystem } from '@emdash/core/files'; import { err, ok, type Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; import { log } from '@main/lib/logger'; import { remoteNameFromQualifiedRef } from '@shared/core/git/utils'; @@ -37,7 +37,8 @@ export abstract class DbProjectSettingsProvider implements ProjectSettingsProvid private readonly projectId: string, protected readonly projectPath: string, protected readonly defaultBranchFallback: string = 'main', - private readonly configReader: Pick | undefined, + private readonly configReader: Pick | undefined, + private readonly joinProjectPath: (rootPath: string, relPath: string) => string, private readonly options: DbProjectSettingsProviderOptions = {} ) {} @@ -63,10 +64,22 @@ export abstract class DbProjectSettingsProvider implements ProjectSettingsProvid private async hasSharedPreservePatterns(): Promise { if (!this.configReader) return false; + const configPath = this.projectFilePath(CONFIG_FILE); try { - if (!(await this.configReader.exists(CONFIG_FILE))) return false; - const { content } = await this.configReader.read(CONFIG_FILE); - const parsed = shareableProjectSettingsSchema.safeParse(parseJsonObject(content)); + const exists = await this.configReader.exists(configPath); + if (!exists.success || !exists.data) return false; + const content = await this.configReader.readText(configPath); + if (!content.success) return false; + if (content.data.truncated) { + log.warn('Shared project settings were truncated during initialization', { + path: configPath, + totalSize: content.data.totalSize, + }); + return false; + } + const parsed = shareableProjectSettingsSchema.safeParse( + parseJsonObject(content.data.content) + ); if (!parsed.success) { log.warn('Failed to inspect shared project settings during initialization', parsed.error); return false; @@ -78,6 +91,10 @@ export abstract class DbProjectSettingsProvider implements ProjectSettingsProvid } } + private projectFilePath(relPath: string): string { + return this.joinProjectPath(this.projectPath, relPath); + } + private async ensureRow(): Promise { if (await this.storage.get(this.projectId)) return; @@ -140,6 +157,7 @@ export abstract class DbProjectSettingsProvider implements ProjectSettingsProvid projectId: this.projectId, row, configReader: this.configReader, + configPath: this.projectFilePath(CONFIG_FILE), defaultBranchFallback: this.defaultBranchFallback, storage: this.storage, git, diff --git a/apps/emdash-desktop/src/main/core/projects/settings/providers/local-project-settings-provider.ts b/apps/emdash-desktop/src/main/core/projects/settings/providers/local-project-settings-provider.ts index 0cc1129f7c..2574a56f21 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/providers/local-project-settings-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/providers/local-project-settings-provider.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; import { appSettingsService } from '@main/core/settings/settings-service'; import type { UpdateProjectSettingsError } from '@shared/projects'; import { @@ -24,21 +25,10 @@ export class LocalProjectSettingsProvider extends DbProjectSettingsProvider { projectId: string, projectPath: string, defaultBranchFallback: string = 'main', + configReader: Pick, options: DbProjectSettingsProviderOptions = {} ) { - super( - projectId, - projectPath, - defaultBranchFallback, - { - exists: async (filePath) => fs.existsSync(path.join(projectPath, filePath)), - read: async (filePath) => { - const content = await fs.promises.readFile(path.join(projectPath, filePath), 'utf8'); - return { content, truncated: false, totalSize: Buffer.byteLength(content) }; - }, - }, - options - ); + super(projectId, projectPath, defaultBranchFallback, configReader, path.join, options); } protected defaultWorktreeDirectory(): Promise { @@ -53,9 +43,20 @@ export class LocalProjectSettingsProvider extends DbProjectSettingsProvider { pathPlatform: localPathPlatform, fs: { mkdir: async (p, options) => { - await fs.promises.mkdir(p, options); + try { + await fs.promises.mkdir(p, options); + return ok(); + } catch (error: unknown) { + return err({ message: error instanceof Error ? error.message : String(error) }); + } + }, + realPath: async (p) => { + try { + return ok(await fs.promises.realpath(p)); + } catch (error: unknown) { + return err({ message: error instanceof Error ? error.message : String(error) }); + } }, - realPath: async (p) => fs.promises.realpath(p), }, homeDirectory: os.homedir(), }); diff --git a/apps/emdash-desktop/src/main/core/projects/settings/providers/ssh-project-settings-provider.ts b/apps/emdash-desktop/src/main/core/projects/settings/providers/ssh-project-settings-provider.ts index 4ceabbe097..76a551daf0 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/providers/ssh-project-settings-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/providers/ssh-project-settings-provider.ts @@ -1,8 +1,7 @@ import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; import { err, ok, type Result } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { getDefaultSshWorktreeDirectory } from '@main/core/settings/worktree-defaults'; import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; import type { UpdateProjectSettingsError } from '@shared/projects'; @@ -21,14 +20,20 @@ export class SshProjectSettingsProvider extends DbProjectSettingsProvider { constructor( projectId: string, - private readonly fs: SshFileSystem, + fs: Pick, defaultBranchFallback: string = 'main', - private readonly rootFs?: Pick, + private readonly rootFs?: { + mkdir( + path: string, + options?: { recursive?: boolean } + ): Promise>; + realPath(path: string): Promise>; + }, projectPath: string = '/', private readonly ctx?: IExecutionContext, options: DbProjectSettingsProviderOptions = {} ) { - super(projectId, projectPath, defaultBranchFallback, fs, options); + super(projectId, projectPath, defaultBranchFallback, fs, path.posix.join, options); } private async getHomeDirectory(): Promise> { diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/codex-config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/codex-config-migration.ts index 194fab0d1e..72c0edfb53 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/codex-config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/codex-config-migration.ts @@ -1,7 +1,7 @@ -import { err, ok, type Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import * as toml from 'smol-toml'; import z from 'zod'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { type MigrateProjectConfigRequest, @@ -9,11 +9,18 @@ import { type ShareableProjectSettings, type ShareableProjectSettingsWriteField, } from '@shared/core/project-settings/project-settings'; -import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import type { ProjectConfigMigrator } from './config-migration'; -import { CONFIG_FILE } from './workspace-config-file'; +import { + addScript, + applyProjectConfigMigration, + errorMessage, + openProjectFileSystem, + projectPath, + trimmedText, + writeConfigFailed, +} from './config-migration-utils'; const CODEX_ENVIRONMENT_FILE = '.codex/environments/environment.toml'; @@ -46,35 +53,6 @@ type CodexMigrationData = { unsupportedFields: string[]; }; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - -function trimmedText(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function setScript( - settings: ShareableProjectSettings, - field: ShareableProjectSettingsWriteField, - value: string -): void { - settings.scripts ??= {}; - if (field === 'scripts.setup') settings.scripts.setup = value; - if (field === 'scripts.teardown') settings.scripts.teardown = value; -} - -function addScript( - data: CodexMigrationData, - field: ShareableProjectSettingsWriteField, - value: string | undefined -): void { - if (!value) return; - setScript(data.settings, field, value); - data.fields.push(field); -} - function actionLabel(action: z.infer, index: number): string { const name = action.name?.trim(); return name ? name : String(index); @@ -105,7 +83,8 @@ function toCodexMigration(data: CodexMigrationData): ProjectConfigMigration | nu } async function readCodexMigrationData( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ): Promise { const data: CodexMigrationData = { settings: {}, @@ -114,10 +93,27 @@ async function readCodexMigrationData( unsupportedFields: [], }; - if (!(await fs.exists(CODEX_ENVIRONMENT_FILE))) return data; + const environmentPath = projectPath(project, CODEX_ENVIRONMENT_FILE); + const exists = await fileSystem.exists(environmentPath); + if (!exists.success) { + log.warn('Failed to inspect Codex environment file for migration', exists.error); + return data; + } + if (!exists.data) return data; - const { content } = await fs.read(CODEX_ENVIRONMENT_FILE); - const codexEnvironment = codexEnvironmentSchema.parse(toml.parse(content)); + const content = await fileSystem.readText(environmentPath); + if (!content.success) { + log.warn('Failed to read Codex environment file for migration', content.error); + return data; + } + if (content.data.truncated) { + log.warn('Codex environment file was truncated during migration', { + path: environmentPath, + totalSize: content.data.totalSize, + }); + return data; + } + const codexEnvironment = codexEnvironmentSchema.parse(toml.parse(content.data.content)); data.files.push(CODEX_ENVIRONMENT_FILE); addScript(data, 'scripts.setup', trimmedText(codexEnvironment.setup?.script)); @@ -132,47 +128,25 @@ async function migrateCodexConfig( request: MigrateProjectConfigRequest ): Promise> { try { - const data = await readCodexMigrationData(project.fs); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const data = await readCodexMigrationData(project, fileSystem.data); const migration = toCodexMigration(data); if (!migration) { return writeConfigFailed('No supported Codex settings were found.'); } - if (request.destination === 'local') { - const currentSettings = await project.settings.get(); - const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); - const updateResult = await project.settings.update({ - ...currentSettings, - ...shareableSettings, - }); - if (!updateResult.success) return updateResult; - return ok(migration); - } - - const writeResult = await project.fs.write( - CONFIG_FILE, - `${JSON.stringify(data.settings, null, 2)}\n` - ); - if (!writeResult.success) { - log.warn('Failed to write migrated project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); - } - - const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); - if (!clearResult.success) { - log.warn('Failed to clear imported local project settings', clearResult.error); - return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); - } - - return ok(migration); + return await applyProjectConfigMigration(project, request, data, migration); } catch (error) { log.warn('Failed to migrate Codex config to project config', error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } export const codexConfigMigrator: ProjectConfigMigrator = { provider: 'codex', - inspect: async (fs) => toCodexMigration(await readCodexMigrationData(fs)), + inspect: async (project, fileSystem) => + toCodexMigration(await readCodexMigrationData(project, fileSystem)), migrate: migrateCodexConfig, }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/conductor-config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/conductor-config-migration.ts index 312f20144c..95f34436f1 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/conductor-config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/conductor-config-migration.ts @@ -1,6 +1,6 @@ -import { err, ok, type Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import z from 'zod'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { type MigrateProjectConfigRequest, @@ -8,12 +8,19 @@ import { type ShareableProjectSettings, type ShareableProjectSettingsWriteField, } from '@shared/core/project-settings/project-settings'; -import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import { parseJsonObject } from '../project-settings-json'; import type { ProjectConfigMigrator } from './config-migration'; -import { CONFIG_FILE } from './workspace-config-file'; +import { + addScript, + applyProjectConfigMigration, + errorMessage, + openProjectFileSystem, + projectPath, + trimmedText, + writeConfigFailed, +} from './config-migration-utils'; const CONDUCTOR_CONFIG_FILE = 'conductor.json'; const CONDUCTOR_WORKTREE_INCLUDE_FILE = '.worktreeinclude'; @@ -39,15 +46,6 @@ type ConductorMigrationData = { unsupportedFields: string[]; }; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - -function trimmedText(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - function parseWorktreeInclude(content: string): string[] { return content .split(/\r?\n/) @@ -67,7 +65,8 @@ function toConductorMigration(data: ConductorMigrationData): ProjectConfigMigrat } async function readConductorMigrationData( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ): Promise { const data: ConductorMigrationData = { settings: {}, @@ -76,41 +75,61 @@ async function readConductorMigrationData( unsupportedFields: [], }; - const hasConductorConfig = await fs.exists(CONDUCTOR_CONFIG_FILE); - if (hasConductorConfig) { - const { content } = await fs.read(CONDUCTOR_CONFIG_FILE); - const conductorConfig = conductorConfigSchema.parse(parseJsonObject(content)); - data.files.push(CONDUCTOR_CONFIG_FILE); - - const setup = trimmedText(conductorConfig.scripts?.setup); - const run = trimmedText(conductorConfig.scripts?.run); - const archive = trimmedText(conductorConfig.scripts?.archive); - - if (setup) { - data.settings.scripts ??= {}; - data.settings.scripts.setup = setup; - data.fields.push('scripts.setup'); - } - if (run) { - data.settings.scripts ??= {}; - data.settings.scripts.run = run; - data.fields.push('scripts.run'); - } - if (archive) { - data.settings.scripts ??= {}; - data.settings.scripts.teardown = archive; - data.fields.push('scripts.teardown'); - } - - if (conductorConfig.runScriptMode !== undefined) data.unsupportedFields.push('runScriptMode'); - if (conductorConfig.enterpriseDataPrivacy !== undefined) { - data.unsupportedFields.push('enterpriseDataPrivacy'); + const conductorConfigPath = projectPath(project, CONDUCTOR_CONFIG_FILE); + const hasConductorConfig = await fileSystem.exists(conductorConfigPath); + if (!hasConductorConfig.success) { + log.warn('Failed to inspect Conductor config for migration', hasConductorConfig.error); + } + if (hasConductorConfig.success && hasConductorConfig.data) { + const content = await fileSystem.readText(conductorConfigPath); + if (!content.success) { + log.warn('Failed to read Conductor config for migration', content.error); + } else if (content.data.truncated) { + log.warn('Conductor config was truncated during migration', { + path: conductorConfigPath, + totalSize: content.data.totalSize, + }); + } else { + const conductorConfig = conductorConfigSchema.parse(parseJsonObject(content.data.content)); + data.files.push(CONDUCTOR_CONFIG_FILE); + + const setup = trimmedText(conductorConfig.scripts?.setup); + const run = trimmedText(conductorConfig.scripts?.run); + const archive = trimmedText(conductorConfig.scripts?.archive); + + addScript(data, 'scripts.setup', setup); + addScript(data, 'scripts.run', run); + addScript(data, 'scripts.teardown', archive); + + if (conductorConfig.runScriptMode !== undefined) data.unsupportedFields.push('runScriptMode'); + if (conductorConfig.enterpriseDataPrivacy !== undefined) { + data.unsupportedFields.push('enterpriseDataPrivacy'); + } } } - if (await fs.exists(CONDUCTOR_WORKTREE_INCLUDE_FILE)) { - const { content } = await fs.read(CONDUCTOR_WORKTREE_INCLUDE_FILE); - const patterns = parseWorktreeInclude(content); + const worktreeIncludePath = projectPath(project, CONDUCTOR_WORKTREE_INCLUDE_FILE); + const hasWorktreeInclude = await fileSystem.exists(worktreeIncludePath); + if (!hasWorktreeInclude.success) { + log.warn( + 'Failed to inspect Conductor worktree include for migration', + hasWorktreeInclude.error + ); + } + if (hasWorktreeInclude.success && hasWorktreeInclude.data) { + const content = await fileSystem.readText(worktreeIncludePath); + if (!content.success) { + log.warn('Failed to read Conductor worktree include for migration', content.error); + return data; + } + if (content.data.truncated) { + log.warn('Conductor worktree include was truncated during migration', { + path: worktreeIncludePath, + totalSize: content.data.totalSize, + }); + return data; + } + const patterns = parseWorktreeInclude(content.data.content); if (patterns.length > 0) { data.files.push(CONDUCTOR_WORKTREE_INCLUDE_FILE); data.settings.preservePatterns = patterns; @@ -126,47 +145,25 @@ async function migrateConductorConfig( request: MigrateProjectConfigRequest ): Promise> { try { - const data = await readConductorMigrationData(project.fs); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const data = await readConductorMigrationData(project, fileSystem.data); const migration = toConductorMigration(data); if (!migration) { return writeConfigFailed('No supported Conductor settings were found.'); } - if (request.destination === 'local') { - const currentSettings = await project.settings.get(); - const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); - const updateResult = await project.settings.update({ - ...currentSettings, - ...shareableSettings, - }); - if (!updateResult.success) return updateResult; - return ok(migration); - } - - const writeResult = await project.fs.write( - CONFIG_FILE, - `${JSON.stringify(data.settings, null, 2)}\n` - ); - if (!writeResult.success) { - log.warn('Failed to write migrated project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); - } - - const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); - if (!clearResult.success) { - log.warn('Failed to clear imported local project settings', clearResult.error); - return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); - } - - return ok(migration); + return await applyProjectConfigMigration(project, request, data, migration); } catch (error) { log.warn('Failed to migrate Conductor config to project config', error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } export const conductorConfigMigrator: ProjectConfigMigrator = { provider: 'conductor', - inspect: async (fs) => toConductorMigration(await readConductorMigrationData(fs)), + inspect: async (project, fileSystem) => + toConductorMigration(await readConductorMigrationData(project, fileSystem)), migrate: migrateConductorConfig, }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration-utils.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration-utils.ts new file mode 100644 index 0000000000..7ece83f4d0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration-utils.ts @@ -0,0 +1,108 @@ +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; +import { log } from '@main/lib/logger'; +import type { + MigrateProjectConfigRequest, + ProjectConfigMigration, + ShareableProjectSettings, + ShareableProjectSettingsWriteField, +} from '@shared/core/project-settings/project-settings'; +import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; +import type { UpdateProjectSettingsError } from '@shared/projects'; +import type { ProjectProvider } from '../../project-provider'; +import { CONFIG_FILE } from './workspace-config-file'; + +type ScriptField = Extract; + +export type MigrationSettingsData = { + settings: ShareableProjectSettings; + fields: ShareableProjectSettingsWriteField[]; +}; + +export function writeConfigFailed( + message: string +): Result { + return err({ type: 'write-config-failed', message }); +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function projectPath(project: ProjectProvider, relPath: string): string { + return project.resolveProjectPath(relPath); +} + +export function openProjectFileSystem( + project: ProjectProvider +): Result { + return ok(project.fileSystem); +} + +export function trimmedText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizedCommandLines(commands: string[]): string | undefined { + const normalized = commands.map((command) => command.trim()).filter(Boolean); + return normalized.length > 0 ? normalized.join('\n') : undefined; +} + +export function setScript( + settings: ShareableProjectSettings, + field: ScriptField, + value: string +): void { + settings.scripts ??= {}; + if (field === 'scripts.setup') settings.scripts.setup = value; + if (field === 'scripts.run') settings.scripts.run = value; + if (field === 'scripts.teardown') settings.scripts.teardown = value; +} + +export function addScript( + data: MigrationSettingsData, + field: ScriptField, + value: string | undefined +): void { + if (!value) return; + setScript(data.settings, field, value); + data.fields.push(field); +} + +export async function applyProjectConfigMigration( + project: ProjectProvider, + request: MigrateProjectConfigRequest, + data: MigrationSettingsData, + migration: ProjectConfigMigration +): Promise> { + if (request.destination === 'local') { + const currentSettings = await project.settings.get(); + const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); + const updateResult = await project.settings.update({ + ...currentSettings, + ...shareableSettings, + }); + if (!updateResult.success) return updateResult; + return ok(migration); + } + + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const written = await fileSystem.data.writeText( + projectPath(project, CONFIG_FILE), + `${JSON.stringify(data.settings, null, 2)}\n` + ); + if (!written.success) { + return writeConfigFailed(`Could not write ${CONFIG_FILE}: ${written.error.message}`); + } + + const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); + if (!clearResult.success) { + log.warn('Failed to clear imported local project settings', clearResult.error); + return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); + } + + return ok(migration); +} diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.test.ts index df4af9bd31..fff66a3e1a 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.test.ts @@ -1,3 +1,4 @@ +import { err, ok } from '@emdash/shared'; import { describe, expect, it, vi } from 'vitest'; import { inspectProjectConfigMigrations, @@ -10,32 +11,62 @@ vi.mock('@main/lib/logger', () => ({ }, })); +const repoPath = '/repo'; + function createFs(initialFiles: Record) { - const files = new Map(Object.entries(initialFiles)); + const files = new Map( + Object.entries(initialFiles).map(([filePath, content]) => [ + filePath.startsWith('/') ? filePath : `${repoPath}/${filePath}`, + content, + ]) + ); return { - exists: vi.fn((filePath: string) => Promise.resolve(files.has(filePath))), - read: vi.fn((filePath: string) => { + exists: vi.fn((filePath: string) => Promise.resolve(ok(files.has(filePath)))), + readText: vi.fn((filePath: string) => { const content = files.get(filePath); - if (content === undefined) throw new Error(`Missing file: ${filePath}`); - return Promise.resolve({ - content, - truncated: false, - totalSize: Buffer.byteLength(content), - }); + if (content === undefined) { + return Promise.resolve( + err({ + type: 'fs-error' as const, + path: filePath, + message: `Missing file: ${filePath}`, + code: 'ENOENT', + }) + ); + } + return Promise.resolve( + ok({ + content, + truncated: false, + totalSize: Buffer.byteLength(content), + }) + ); }), - write: vi.fn((filePath: string, content: string) => { + writeText: vi.fn((filePath: string, content: string) => { files.set(filePath, content); - return Promise.resolve({ - success: true, - bytesWritten: Buffer.byteLength(content), - }); + return Promise.resolve(ok({ bytesWritten: Buffer.byteLength(content) })); }), content(filePath: string) { - return files.get(filePath); + return files.get(filePath) ?? files.get(`${repoPath}/${filePath}`); }, }; } +function createProject( + fileSystem: ReturnType, + settings: Record = {} +) { + const join = (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'); + return { + repoPath, + fileSystem, + projectConfigPath: join(repoPath, '.emdash.json'), + resolveProjectPath: (relativePath: string) => join(repoPath, relativePath), + configPathForDirectory: (directoryPath: string) => join(directoryPath, '.emdash.json'), + settings, + } as never; +} + describe('config migration', () => { it('detects importable Conductor settings', async () => { const fs = createFs({ @@ -56,7 +87,7 @@ describe('config migration', () => { `, }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'conductor', label: 'Conductor', @@ -80,15 +111,10 @@ describe('config migration', () => { }); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'conductor', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'conductor', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -118,7 +144,7 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'conductor', label: 'Conductor', @@ -129,15 +155,10 @@ describe('config migration', () => { ]); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'conductor', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'conductor', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -162,23 +183,20 @@ describe('config migration', () => { const update = vi.fn().mockResolvedValue({ success: true }); const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - get: vi.fn().mockResolvedValue({ - shellSetup: 'source .envrc', - scripts: { - setup: 'pnpm install', - }, - }), - update, - }, - } as never, + createProject(fs, { + get: vi.fn().mockResolvedValue({ + shellSetup: 'source .envrc', + scripts: { + setup: 'pnpm install', + }, + }), + update, + }), { provider: 'conductor', destination: 'local' } ); expect(result.success).toBe(true); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith({ shellSetup: 'source .envrc', scripts: { @@ -198,7 +216,7 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'superset', label: 'Superset', @@ -219,15 +237,10 @@ describe('config migration', () => { }); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'superset', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'superset', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -251,22 +264,19 @@ describe('config migration', () => { const update = vi.fn().mockResolvedValue({ success: true }); const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - get: vi.fn().mockResolvedValue({ - scripts: { - setup: 'bun install', - }, - }), - update, - }, - } as never, + createProject(fs, { + get: vi.fn().mockResolvedValue({ + scripts: { + setup: 'bun install', + }, + }), + update, + }), { provider: 'superset', destination: 'local' } ); expect(result.success).toBe(true); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith({ scripts: { setup: 'bun install', @@ -288,9 +298,9 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([]); + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([]); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'superset', destination: 'shared', }) @@ -318,7 +328,7 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'paseo', label: 'Paseo', @@ -350,15 +360,10 @@ describe('config migration', () => { }); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'paseo', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'paseo', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -386,22 +391,19 @@ describe('config migration', () => { const update = vi.fn().mockResolvedValue({ success: true }); const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - get: vi.fn().mockResolvedValue({ - scripts: { - teardown: 'docker compose down', - }, - }), - update, - }, - } as never, + createProject(fs, { + get: vi.fn().mockResolvedValue({ + scripts: { + teardown: 'docker compose down', + }, + }), + update, + }), { provider: 'paseo', destination: 'local' } ); expect(result.success).toBe(true); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith({ scripts: { setup: 'npm ci', @@ -420,9 +422,9 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([]); + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([]); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'paseo', destination: 'shared', }) @@ -457,7 +459,7 @@ describe('config migration', () => { `, }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'codex', label: 'Codex', @@ -484,15 +486,10 @@ describe('config migration', () => { }); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'codex', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'codex', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -516,22 +513,19 @@ describe('config migration', () => { const update = vi.fn().mockResolvedValue({ success: true }); const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - get: vi.fn().mockResolvedValue({ - scripts: { - teardown: 'docker compose down', - }, - }), - update, - }, - } as never, + createProject(fs, { + get: vi.fn().mockResolvedValue({ + scripts: { + teardown: 'docker compose down', + }, + }), + update, + }), { provider: 'codex', destination: 'local' } ); expect(result.success).toBe(true); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith({ scripts: { setup: 'npm ci', @@ -550,9 +544,9 @@ describe('config migration', () => { `, }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([]); + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([]); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'codex', destination: 'shared', }) @@ -574,7 +568,7 @@ describe('config migration', () => { }); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'conductor', destination: 'shared', }) @@ -597,7 +591,7 @@ describe('config migration', () => { '.emdash.json': JSON.stringify({ scripts: { run: 'pnpm dev' } }), }); - const result = await migrateProjectConfigFromProvider({ fs } as never, { + const result = await migrateProjectConfigFromProvider(createProject(fs), { provider: 'conductor', destination: 'shared', }); @@ -610,14 +604,14 @@ describe('config migration', () => { message: '.emdash.json already exists.', }, }); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); }); it('returns an error for unknown providers', async () => { const fs = createFs({}); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'unknown' as never, destination: 'shared', }) diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.ts index 1d798c8c37..da1c3d36bf 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.ts @@ -1,5 +1,5 @@ -import { err, type Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import { log } from '@main/lib/logger'; import type { MigrateProjectConfigRequest, @@ -9,6 +9,12 @@ import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import { codexConfigMigrator } from './codex-config-migration'; import { conductorConfigMigrator } from './conductor-config-migration'; +import { + errorMessage, + openProjectFileSystem, + projectPath, + writeConfigFailed, +} from './config-migration-utils'; import { paseoConfigMigrator } from './paseo-config-migration'; import { supersetConfigMigrator } from './superset-config-migration'; import { CONFIG_FILE } from './workspace-config-file'; @@ -16,7 +22,8 @@ import { CONFIG_FILE } from './workspace-config-file'; export type ProjectConfigMigrator = { provider: ProjectConfigMigration['provider']; inspect: ( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ) => Promise; migrate: ( project: ProjectProvider, @@ -31,24 +38,30 @@ const PROJECT_CONFIG_MIGRATORS = [ codexConfigMigrator, ] as const; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); +function projectConfigPath(project: ProjectProvider): string { + return projectPath(project, CONFIG_FILE); } export async function inspectProjectConfigMigrations( - fs: Pick + project: ProjectProvider ): Promise { - try { - if (await fs.exists(CONFIG_FILE)) return []; - } catch (error) { - log.warn(`Failed to inspect ${CONFIG_FILE} before config migration`, error); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) { + log.warn('Failed to open project file system before config migration', fileSystem.error); + return []; + } + + const existingConfig = await fileSystem.data.exists(projectConfigPath(project)); + if (!existingConfig.success) { + log.warn(`Failed to inspect ${CONFIG_FILE} before config migration`, existingConfig.error); return []; } + if (existingConfig.data) return []; const migrations = await Promise.all( PROJECT_CONFIG_MIGRATORS.map(async (migrator) => { try { - return await migrator.inspect(fs); + return await migrator.inspect(project, fileSystem.data); } catch (error) { log.warn(`Failed to inspect ${migrator.provider} config for migration`, error); return null; @@ -64,7 +77,16 @@ export async function migrateProjectConfigFromProvider( request: MigrateProjectConfigRequest ): Promise> { try { - if (await project.fs.exists(CONFIG_FILE)) { + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const existingConfig = await fileSystem.data.exists(projectConfigPath(project)); + if (!existingConfig.success) { + return writeConfigFailed( + `Could not check existing ${CONFIG_FILE}: ${existingConfig.error.message}` + ); + } + if (existingConfig.data) { return writeConfigFailed(`${CONFIG_FILE} already exists.`); } @@ -76,6 +98,6 @@ export async function migrateProjectConfigFromProvider( return await migrator.migrate(project, request); } catch (error) { log.warn(`Failed to migrate ${request.provider} config to project config`, error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/paseo-config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/paseo-config-migration.ts index 31e3bb0de7..2a0365edb0 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/paseo-config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/paseo-config-migration.ts @@ -1,6 +1,6 @@ -import { err, ok, type Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import z from 'zod'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { type MigrateProjectConfigRequest, @@ -8,12 +8,19 @@ import { type ShareableProjectSettings, type ShareableProjectSettingsWriteField, } from '@shared/core/project-settings/project-settings'; -import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import { parseJsonObject } from '../project-settings-json'; import type { ProjectConfigMigrator } from './config-migration'; -import { CONFIG_FILE } from './workspace-config-file'; +import { + addScript, + applyProjectConfigMigration, + errorMessage, + normalizedCommandLines, + openProjectFileSystem, + projectPath, + writeConfigFailed, +} from './config-migration-utils'; const PASEO_CONFIG_FILE = 'paseo.json'; @@ -48,36 +55,11 @@ type PaseoMigrationData = { unsupportedFields: string[]; }; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - function normalizeCommand(value: string | string[] | undefined): string | undefined { if (value === undefined) return undefined; const commands = Array.isArray(value) ? value : [value]; - const normalized = commands.map((command) => command.trim()).filter(Boolean); - return normalized.length > 0 ? normalized.join('\n') : undefined; -} - -function setScript( - settings: ShareableProjectSettings, - field: ShareableProjectSettingsWriteField, - value: string -): void { - settings.scripts ??= {}; - if (field === 'scripts.setup') settings.scripts.setup = value; - if (field === 'scripts.teardown') settings.scripts.teardown = value; -} - -function addScript( - data: PaseoMigrationData, - field: ShareableProjectSettingsWriteField, - value: string | undefined -): void { - if (!value) return; - setScript(data.settings, field, value); - data.fields.push(field); + return normalizedCommandLines(commands); } function toPaseoMigration(data: PaseoMigrationData): ProjectConfigMigration | null { @@ -105,7 +87,8 @@ function addUnsupportedScripts( } async function readPaseoMigrationData( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ): Promise { const data: PaseoMigrationData = { settings: {}, @@ -114,10 +97,27 @@ async function readPaseoMigrationData( unsupportedFields: [], }; - if (!(await fs.exists(PASEO_CONFIG_FILE))) return data; + const paseoConfigPath = projectPath(project, PASEO_CONFIG_FILE); + const exists = await fileSystem.exists(paseoConfigPath); + if (!exists.success) { + log.warn('Failed to inspect Paseo config for migration', exists.error); + return data; + } + if (!exists.data) return data; - const { content } = await fs.read(PASEO_CONFIG_FILE); - const paseoConfig = paseoConfigSchema.parse(parseJsonObject(content)); + const content = await fileSystem.readText(paseoConfigPath); + if (!content.success) { + log.warn('Failed to read Paseo config for migration', content.error); + return data; + } + if (content.data.truncated) { + log.warn('Paseo config was truncated during migration', { + path: paseoConfigPath, + totalSize: content.data.totalSize, + }); + return data; + } + const paseoConfig = paseoConfigSchema.parse(parseJsonObject(content.data.content)); data.files.push(PASEO_CONFIG_FILE); addScript(data, 'scripts.setup', normalizeCommand(paseoConfig.worktree?.setup)); @@ -136,47 +136,25 @@ async function migratePaseoConfig( request: MigrateProjectConfigRequest ): Promise> { try { - const data = await readPaseoMigrationData(project.fs); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const data = await readPaseoMigrationData(project, fileSystem.data); const migration = toPaseoMigration(data); if (!migration) { return writeConfigFailed('No supported Paseo settings were found.'); } - if (request.destination === 'local') { - const currentSettings = await project.settings.get(); - const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); - const updateResult = await project.settings.update({ - ...currentSettings, - ...shareableSettings, - }); - if (!updateResult.success) return updateResult; - return ok(migration); - } - - const writeResult = await project.fs.write( - CONFIG_FILE, - `${JSON.stringify(data.settings, null, 2)}\n` - ); - if (!writeResult.success) { - log.warn('Failed to write migrated project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); - } - - const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); - if (!clearResult.success) { - log.warn('Failed to clear imported local project settings', clearResult.error); - return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); - } - - return ok(migration); + return await applyProjectConfigMigration(project, request, data, migration); } catch (error) { log.warn('Failed to migrate Paseo config to project config', error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } export const paseoConfigMigrator: ProjectConfigMigrator = { provider: 'paseo', - inspect: async (fs) => toPaseoMigration(await readPaseoMigrationData(fs)), + inspect: async (project, fileSystem) => + toPaseoMigration(await readPaseoMigrationData(project, fileSystem)), migrate: migratePaseoConfig, }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-override-state.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-override-state.ts index ce7c8a68bc..4a123fa5a2 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-override-state.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-override-state.ts @@ -7,7 +7,6 @@ import { } from '@shared/core/project-settings/project-settings'; import { SHAREABLE_FIELD_ACCESSORS } from '@shared/core/project-settings/project-settings-fields'; import type { ProjectSettingsResolvedTarget } from './project-settings-target-resolver'; -import { CONFIG_FILE } from './workspace-config-file'; export async function computeProjectSettingsOverrideState( targets: ProjectSettingsResolvedTarget[] @@ -16,10 +15,19 @@ export async function computeProjectSettingsOverrideState( for (const resolved of targets) { try { - if (!(await resolved.fs.exists(CONFIG_FILE))) continue; + const exists = await resolved.fileSystem.exists(resolved.configPath); + if (!exists.success || !exists.data) continue; - const { content } = await resolved.fs.read(CONFIG_FILE); - const parsed = shareableProjectSettingsSchema.safeParse(JSON.parse(content)); + const content = await resolved.fileSystem.readText(resolved.configPath); + if (!content.success) continue; + if (content.data.truncated) { + log.warn('Project settings override source was truncated', { + path: resolved.configPath, + totalSize: content.data.totalSize, + }); + continue; + } + const parsed = shareableProjectSettingsSchema.safeParse(JSON.parse(content.data.content)); if (!parsed.success) continue; for (const field of SHAREABLE_PROJECT_SETTINGS_WRITE_FIELDS) { diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-target-resolver.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-target-resolver.ts index aa3cc72ec6..5a8ad5a24e 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-target-resolver.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-target-resolver.ts @@ -1,7 +1,5 @@ +import type { IFileSystem } from '@emdash/core/files'; import { eq } from 'drizzle-orm'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { getProvisionedWorkspaceBranch } from '@main/core/workspaces/workspace-branch'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { db } from '@main/db/client'; @@ -20,7 +18,8 @@ import type { ProjectProvider } from '../../project-provider'; import { resolveWorkspace } from '../../utils'; export type ProjectSettingsResolvedTarget = ProjectSettingsWriteTargetOption & { - fs: FileSystemProvider; + fileSystem: IFileSystem; + configPath: string; }; function stripTarget(target: ProjectSettingsWriteTargetOption): ProjectSettingsWriteTarget { @@ -32,7 +31,7 @@ function stripTarget(target: ProjectSettingsWriteTargetOption): ProjectSettingsW export function stripResolvedTarget( target: ProjectSettingsResolvedTarget ): ProjectSettingsWriteTargetOption { - const { fs: _fs, ...option } = target; + const { configPath: _configPath, fileSystem: _fileSystem, ...option } = target; return option; } @@ -56,13 +55,15 @@ async function resolveTaskTarget( task: TaskTargetRow ): Promise { let targetPath: string | null = null; - let fs: FileSystemProvider | null = null; + let fileSystem: IFileSystem | null = null; + let configPath: string | null = null; if (task.workspaceId) { const activeWorkspace = workspaceRegistry.get(task.workspaceId); if (activeWorkspace) { targetPath = activeWorkspace.path; - fs = activeWorkspace.fs; + fileSystem = activeWorkspace.fileSystem; + configPath = activeWorkspace.configPath; } } @@ -76,23 +77,25 @@ async function resolveTaskTarget( } if (!targetPath) return null; if (targetPath === project.repoPath) return null; + const resolvedFileSystem = fileSystem ?? resolveProjectFileSystem(project); + if (!resolvedFileSystem) return null; return { type: 'task', taskId: task.id, label: task.name, path: targetPath, - fs: - fs ?? - (project.defaultWorkspaceType.kind === 'ssh' - ? new SshFileSystem(project.defaultWorkspaceType.proxy, targetPath) - : new LocalFileSystem(targetPath)), + fileSystem: resolvedFileSystem, + configPath: configPath ?? project.configPathForDirectory(targetPath), }; } export async function resolveAllProjectSettingsTargets( project: ProjectProvider ): Promise { + const projectFileSystem = resolveProjectFileSystem(project); + if (!projectFileSystem) return []; + const [projectRow] = await db .select({ name: projectsTable.name }) .from(projectsTable) @@ -103,7 +106,8 @@ export async function resolveAllProjectSettingsTargets( type: 'project', label: projectRow?.name ?? 'Project repository', path: project.repoPath, - fs: project.fs, + fileSystem: projectFileSystem, + configPath: project.projectConfigPath, }; if (!projectRow) return [projectTarget]; @@ -145,16 +149,20 @@ export async function resolveProjectSettingsTarget( if (request.target.type === 'workspace') { const workspace = resolveWorkspace(project.projectId, request.target.workspaceId); - return workspace - ? { - type: 'workspace', - workspaceId: request.target.workspaceId, - label: 'Workspace', - path: workspace.path, - fs: workspace.fs, - } - : null; + if (!workspace) return null; + return { + type: 'workspace', + workspaceId: request.target.workspaceId, + label: 'Workspace', + path: workspace.path, + fileSystem: workspace.fileSystem, + configPath: workspace.configPath, + }; } return null; } + +function resolveProjectFileSystem(project: ProjectProvider): IFileSystem | null { + return project.fileSystem; +} diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.test.ts index dae89fbaf9..df5504c374 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.test.ts @@ -1,3 +1,5 @@ +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ShareableProjectSettings } from '@shared/core/project-settings/project-settings'; import { computeProjectSettingsOverrideState } from './project-settings-override-state'; @@ -36,6 +38,83 @@ vi.mock('@main/lib/logger', () => ({ }, })); +const repoPath = '/repo'; +const configPath = `${repoPath}/.emdash.json`; + +function createMemoryFileSystem(initialFiles: Record = {}) { + const files = new Map( + Object.entries(initialFiles).map(([filePath, content]) => [ + filePath.startsWith('/') ? filePath : `${repoPath}/${filePath}`, + content, + ]) + ); + const fileSystem = { + exists: vi.fn(async (filePath: string) => ok(files.has(filePath))), + readText: vi.fn(async (filePath: string) => { + const content = files.get(filePath); + if (content === undefined) { + return err({ + type: 'fs-error' as const, + path: filePath, + message: `Missing file: ${filePath}`, + code: 'ENOENT', + }); + } + return ok({ content, truncated: false, totalSize: Buffer.byteLength(content) }); + }), + writeText: vi.fn(async (filePath: string, content: string) => { + files.set(filePath, content); + return ok({ bytesWritten: Buffer.byteLength(content) }); + }), + readBytes: vi.fn(), + writeBytes: vi.fn(), + stat: vi.fn(), + mkdir: vi.fn(), + remove: vi.fn(), + realPath: vi.fn(), + copyFile: vi.fn(), + glob: vi.fn(), + enumerate: vi.fn(), + content(filePath: string) { + return files.get(filePath) ?? files.get(`${repoPath}/${filePath}`); + }, + }; + return fileSystem as unknown as IFileSystem & typeof fileSystem; +} + +function joinPath(...parts: string[]): string { + return parts.join('/').replace(/\/+/g, '/'); +} + +function configPathForDirectory(directoryPath: string): string { + return joinPath(directoryPath, '.emdash.json'); +} + +function projectFixture(fileSystem: IFileSystem, overrides: Record = {}) { + return { + projectId: 'project-1', + repoPath, + fileSystem, + projectConfigPath: configPath, + resolveProjectPath: (relativePath: string) => joinPath(repoPath, relativePath), + configPathForDirectory, + defaultWorkspaceType: { kind: 'local' }, + ...overrides, + }; +} + +function workspaceFixture(workspacePath: string, fileSystem: IFileSystem) { + return { + path: workspacePath, + fileSystem, + configPath: configPathForDirectory(workspacePath), + }; +} + +function projectTarget(fileSystem: IFileSystem) { + return { type: 'project' as const, label: 'Repo Name', path: repoPath, fileSystem, configPath }; +} + describe('shareProjectSettingsToConfig', () => { beforeEach(() => { vi.clearAllMocks(); @@ -44,13 +123,10 @@ describe('shareProjectSettingsToConfig', () => { }); it('writes selected shareable project settings to .emdash.json', async () => { - const write = vi.fn().mockResolvedValue({ success: true, bytesWritten: 100 }); + const fileSystem = createMemoryFileSystem(); + const write = fileSystem.writeText; const patch = vi.fn().mockResolvedValue({ success: true }); const project = { - fs: { - exists: vi.fn().mockResolvedValue(false), - write, - }, settings: { get: vi.fn().mockResolvedValue({ defaultBranch: 'origin/main', @@ -73,12 +149,12 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns', 'shellSetup', 'scripts.setup', 'scripts.run'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem)] ); expect(result.success).toBe(true); expect(write).toHaveBeenCalledWith( - '.emdash.json', + configPath, `${JSON.stringify( { preservePatterns: ['.env', '.env.local'], @@ -98,20 +174,11 @@ describe('shareProjectSettingsToConfig', () => { }); it('preserves existing config fields when sharing a later script field to the same target', async () => { - let configContent = ''; + const fileSystem = createMemoryFileSystem(); let shareableSettings: ShareableProjectSettings = { preservePatterns: ['.env', '.env.local'], }; - const fs = { - exists: vi.fn().mockImplementation(() => Promise.resolve(configContent !== '')), - read: vi.fn().mockImplementation(() => Promise.resolve({ content: configContent })), - write: vi.fn().mockImplementation((_path: string, content: string) => { - configContent = content; - return Promise.resolve({ success: true, bytesWritten: content.length }); - }), - }; const project = { - fs, settings: { get: vi.fn().mockImplementation(() => Promise.resolve(shareableSettings)), patch: vi.fn().mockImplementation(({ clearShareableFields }) => { @@ -125,7 +192,7 @@ describe('shareProjectSettingsToConfig', () => { }), }, }; - const targets = [{ type: 'project' as const, label: 'Repo Name', path: '/repo', fs }]; + const targets = [projectTarget(fileSystem)]; await shareProjectSettingsToConfig( project as never, @@ -152,7 +219,7 @@ describe('shareProjectSettingsToConfig', () => { ); expect(result.success).toBe(true); - expect(JSON.parse(configContent)).toEqual({ + expect(JSON.parse(fileSystem.content('.emdash.json') ?? '{}')).toEqual({ preservePatterns: ['.env', '.env.local'], scripts: { run: 'pnpm dev', @@ -161,16 +228,12 @@ describe('shareProjectSettingsToConfig', () => { }); it('only clears fields that were actually written to .emdash.json', async () => { - const write = vi.fn().mockResolvedValue({ success: true, bytesWritten: 100 }); + const fileSystem = createMemoryFileSystem({ + '.emdash.json': JSON.stringify({ preservePatterns: ['.env'] }), + }); + const write = fileSystem.writeText; const patch = vi.fn().mockResolvedValue({ success: true }); const project = { - fs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ preservePatterns: ['.env'] }), - }), - write, - }, settings: { get: vi.fn().mockResolvedValue({ preservePatterns: ['.env.local'], @@ -185,12 +248,12 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns', 'scripts.run'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem)] ); expect(result.success).toBe(true); expect(write).toHaveBeenCalledWith( - '.emdash.json', + configPath, `${JSON.stringify({ preservePatterns: ['.env.local'] }, null, 2)}\n` ); expect(patch).toHaveBeenCalledWith({ @@ -200,15 +263,17 @@ describe('shareProjectSettingsToConfig', () => { it('returns an error when the filesystem reports an unsuccessful write', async () => { const patch = vi.fn(); + const fileSystem = { + ...createMemoryFileSystem(), + writeText: vi.fn(async (filePath: string) => + err({ + type: 'fs-error' as const, + path: filePath, + message: 'permission denied', + }) + ), + }; const project = { - fs: { - exists: vi.fn().mockResolvedValue(false), - write: vi.fn().mockResolvedValue({ - success: false, - bytesWritten: 0, - error: 'permission denied', - }), - }, settings: { get: vi.fn().mockResolvedValue({ preservePatterns: ['.env'], @@ -223,30 +288,29 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem as never)] ); expect(result).toEqual({ success: false, - error: { type: 'write-config-failed', message: 'permission denied' }, + error: { + type: 'write-config-failed', + message: 'Could not write .emdash.json: permission denied', + }, }); expect(patch).not.toHaveBeenCalled(); }); it('returns an error when clearing shared fields fails after writing config', async () => { - const write = vi.fn().mockResolvedValue({ success: true, bytesWritten: 100 }); + const fileSystem = createMemoryFileSystem({ + '.emdash.json': `${JSON.stringify({ shellSetup: 'old setup' }, null, 2)}\n`, + }); + const write = fileSystem.writeText; const patch = vi.fn().mockResolvedValue({ success: false, error: { type: 'error' }, }); const project = { - fs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: `${JSON.stringify({ shellSetup: 'old setup' }, null, 2)}\n`, - }), - write, - }, settings: { get: vi.fn().mockResolvedValue({ preservePatterns: ['.env'], @@ -261,7 +325,7 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem)] ); expect(result).toEqual({ @@ -275,11 +339,8 @@ describe('shareProjectSettingsToConfig', () => { }); it('returns the read/parse failure when existing .emdash.json cannot be parsed', async () => { + const fileSystem = createMemoryFileSystem({ '.emdash.json': '{ invalid json' }); const project = { - fs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ content: '{ invalid json' }), - }, settings: { get: vi.fn().mockResolvedValue({ preservePatterns: ['.env'], @@ -293,7 +354,7 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem)] ); if (result.success) { @@ -308,6 +369,41 @@ describe('shareProjectSettingsToConfig', () => { expect(result.error.message).toContain('Could not read existing .emdash.json'); }); + it('does not overwrite an existing .emdash.json when the read is truncated', async () => { + const fileSystem = { + ...createMemoryFileSystem({ '.emdash.json': '{"shellSetup":' }), + readText: vi.fn(async () => + ok({ content: '{"shellSetup":', truncated: true, totalSize: 204_801 }) + ), + }; + const project = { + settings: { + get: vi.fn().mockResolvedValue({ + preservePatterns: ['.env'], + }), + patch: vi.fn(), + }, + }; + + const result = await shareProjectSettingsToConfig( + project as never, + { + target: { type: 'project' }, + fields: ['preservePatterns'], + }, + [projectTarget(fileSystem as never)] + ); + + expect(result).toEqual({ + success: false, + error: { + type: 'write-config-failed', + message: 'Could not read existing .emdash.json: file was truncated.', + }, + }); + expect(fileSystem.writeText).not.toHaveBeenCalled(); + }); + it('returns target resolution failures instead of rejecting the RPC', async () => { await expect( shareProjectSettingsToConfig( @@ -333,15 +429,12 @@ describe('shareProjectSettingsToConfig', () => { it('includes task worktrees from git branch discovery, not only active workspaces', async () => { const findBranchAnywhere = vi.fn().mockResolvedValue('/external/worktrees/task-one'); - const project = { - projectId: 'project-1', - repoPath: '/repo', - fs: {}, - defaultWorkspaceType: { kind: 'local' }, + const projectFs = createMemoryFileSystem(); + const project = projectFixture(projectFs, { worktreeService: { findBranchAnywhere, }, - }; + }); mocks.select .mockReturnValueOnce({ from: () => ({ @@ -381,32 +474,26 @@ describe('shareProjectSettingsToConfig', () => { }); it('excludes task targets that use the project root working directory', async () => { - const projectRootFs = { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ shellSetup: 'root setup' }), - }), - }; - const worktreeFs = { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ shellSetup: 'worktree setup' }), + const projectRootFs = createMemoryFileSystem({ + '.emdash.json': JSON.stringify({ shellSetup: 'root setup' }), + }); + const worktreeFs = createMemoryFileSystem({ + '/repo/.emdash/worktrees/task-two/.emdash.json': JSON.stringify({ + shellSetup: 'worktree setup', }), - }; + }); const findBranchAnywhere = vi.fn(); - const project = { - projectId: 'project-1', - repoPath: '/repo', - fs: projectRootFs, - defaultWorkspaceType: { kind: 'local' }, + const project = projectFixture(projectRootFs, { worktreeService: { findBranchAnywhere, }, - }; + }); mocks.workspaceGet.mockImplementation((workspaceId: string) => { - if (workspaceId === 'root-workspace') return { path: '/repo', fs: projectRootFs }; + if (workspaceId === 'root-workspace') { + return workspaceFixture('/repo', projectRootFs); + } if (workspaceId === 'worktree-workspace') { - return { path: '/repo/.emdash/worktrees/task-two', fs: worktreeFs }; + return workspaceFixture('/repo/.emdash/worktrees/task-two', worktreeFs); } return undefined; }); @@ -465,15 +552,12 @@ describe('shareProjectSettingsToConfig', () => { it('skips task target resolution when the project row no longer exists', async () => { const findBranchAnywhere = vi.fn(); - const project = { - projectId: 'project-1', - repoPath: '/repo', - fs: {}, - defaultWorkspaceType: { kind: 'local' }, + const projectFs = createMemoryFileSystem(); + const project = projectFixture(projectFs, { worktreeService: { findBranchAnywhere, }, - }; + }); mocks.select.mockReturnValueOnce({ from: () => ({ where: () => ({ @@ -492,28 +576,22 @@ describe('shareProjectSettingsToConfig', () => { }); it('detects workspace setting overrides from .emdash.json files', async () => { - const project = { - projectId: 'project-1', - repoPath: '/repo', - fs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ - preservePatterns: ['.env', '.env.local'], - shellSetup: 'nvm use', - scripts: { - setup: 'pnpm install', - run: 'pnpm dev', - teardown: 'docker compose down', - }, - }), - }), - }, - defaultWorkspaceType: { kind: 'local' }, + const projectFs = createMemoryFileSystem({ + '.emdash.json': JSON.stringify({ + preservePatterns: ['.env', '.env.local'], + shellSetup: 'nvm use', + scripts: { + setup: 'pnpm install', + run: 'pnpm dev', + teardown: 'docker compose down', + }, + }), + }); + const project = projectFixture(projectFs, { worktreeService: { findBranchAnywhere: vi.fn(), }, - }; + }); mocks.select .mockReturnValueOnce({ from: () => ({ diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.ts index 5728d19c3b..93880cb284 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.ts @@ -1,8 +1,9 @@ -import { err, ok, type Result } from '@emdash/shared'; +import { ok, type Result } from '@emdash/shared'; import { log } from '@main/lib/logger'; import type { WriteProjectConfigRequest } from '@shared/core/project-settings/project-settings'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; +import { errorMessage, writeConfigFailed } from './config-migration-utils'; import { resolveProjectSettingsTarget, type ProjectSettingsResolvedTarget, @@ -13,14 +14,6 @@ import { patchShareableProjectSettingsFields, } from './workspace-config-file'; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - export async function shareProjectSettingsToConfig( project: ProjectProvider, request: WriteProjectConfigRequest, @@ -35,9 +28,28 @@ export async function shareProjectSettingsToConfig( const localSettings = await project.settings.get(); let config: Record; try { - if (await target.fs.exists(CONFIG_FILE)) { - const { content } = await target.fs.read(CONFIG_FILE); - config = parseWorkspaceConfigObject(content); + const exists = await target.fileSystem.exists(target.configPath); + if (!exists.success) { + const message = `Could not check existing ${CONFIG_FILE}: ${exists.error.message}`; + log.warn('Failed to check project config before writing', exists.error); + return writeConfigFailed(message); + } + if (exists.data) { + const content = await target.fileSystem.readText(target.configPath); + if (!content.success) { + const message = `Could not read existing ${CONFIG_FILE}: ${content.error.message}`; + log.warn('Failed to read project config before writing', content.error); + return writeConfigFailed(message); + } + if (content.data.truncated) { + const message = `Could not read existing ${CONFIG_FILE}: file was truncated.`; + log.warn('Project config was truncated before writing', { + path: target.configPath, + totalSize: content.data.totalSize, + }); + return writeConfigFailed(message); + } + config = parseWorkspaceConfigObject(content.data.content); } else { config = {}; } @@ -53,10 +65,13 @@ export async function shareProjectSettingsToConfig( request.fields ); - const writeResult = await target.fs.write(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`); - if (!writeResult.success) { - log.warn('Failed to write project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); + const written = await target.fileSystem.writeText( + target.configPath, + `${JSON.stringify(config, null, 2)}\n` + ); + if (!written.success) { + log.warn('Failed to write project config to repo', written.error); + return writeConfigFailed(`Could not write ${CONFIG_FILE}: ${written.error.message}`); } const clearResult = await project.settings.patch({ clearShareableFields: writtenFields }); diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/superset-config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/superset-config-migration.ts index 6a0d6795bd..6a0dacd8c5 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/superset-config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/superset-config-migration.ts @@ -1,6 +1,6 @@ -import { err, ok, type Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import z from 'zod'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { type MigrateProjectConfigRequest, @@ -8,12 +8,19 @@ import { type ShareableProjectSettings, type ShareableProjectSettingsWriteField, } from '@shared/core/project-settings/project-settings'; -import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import { parseJsonObject } from '../project-settings-json'; import type { ProjectConfigMigrator } from './config-migration'; -import { CONFIG_FILE } from './workspace-config-file'; +import { + applyProjectConfigMigration, + errorMessage, + normalizedCommandLines, + openProjectFileSystem, + projectPath, + setScript, + writeConfigFailed, +} from './config-migration-utils'; const SUPERSET_CONFIG_FILE = '.superset/config.json'; @@ -52,15 +59,6 @@ const SUPERSET_SCRIPT_FIELDS = [ target: ShareableProjectSettingsWriteField; }>; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - -function normalizeCommands(commands: string[]): string | undefined { - const normalized = commands.map((command) => command.trim()).filter(Boolean); - return normalized.length > 0 ? normalized.join('\n') : undefined; -} - function addUnsupportedOverrideFields( data: SupersetMigrationData, source: 'setup' | 'run' | 'teardown', @@ -71,17 +69,6 @@ function addUnsupportedOverrideFields( if (script.after !== undefined) data.unsupportedFields.push(`${source}.after`); } -function setScript( - settings: ShareableProjectSettings, - field: ShareableProjectSettingsWriteField, - value: string -): void { - settings.scripts ??= {}; - if (field === 'scripts.setup') settings.scripts.setup = value; - if (field === 'scripts.run') settings.scripts.run = value; - if (field === 'scripts.teardown') settings.scripts.teardown = value; -} - function toSupersetMigration(data: SupersetMigrationData): ProjectConfigMigration | null { if (data.fields.length === 0) return null; return { @@ -94,7 +81,8 @@ function toSupersetMigration(data: SupersetMigrationData): ProjectConfigMigratio } async function readSupersetMigrationData( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ): Promise { const data: SupersetMigrationData = { settings: {}, @@ -103,10 +91,27 @@ async function readSupersetMigrationData( unsupportedFields: [], }; - if (!(await fs.exists(SUPERSET_CONFIG_FILE))) return data; + const supersetConfigPath = projectPath(project, SUPERSET_CONFIG_FILE); + const exists = await fileSystem.exists(supersetConfigPath); + if (!exists.success) { + log.warn('Failed to inspect Superset config for migration', exists.error); + return data; + } + if (!exists.data) return data; - const { content } = await fs.read(SUPERSET_CONFIG_FILE); - const config = supersetConfigSchema.parse(parseJsonObject(content)); + const content = await fileSystem.readText(supersetConfigPath); + if (!content.success) { + log.warn('Failed to read Superset config for migration', content.error); + return data; + } + if (content.data.truncated) { + log.warn('Superset config was truncated during migration', { + path: supersetConfigPath, + totalSize: content.data.totalSize, + }); + return data; + } + const config = supersetConfigSchema.parse(parseJsonObject(content.data.content)); data.files.push(SUPERSET_CONFIG_FILE); for (const { source, target } of SUPERSET_SCRIPT_FIELDS) { @@ -114,7 +119,7 @@ async function readSupersetMigrationData( if (script === undefined) continue; if (Array.isArray(script)) { - const value = normalizeCommands(script); + const value = normalizedCommandLines(script); if (!value) continue; setScript(data.settings, target, value); data.fields.push(target); @@ -132,47 +137,25 @@ async function migrateSupersetConfig( request: MigrateProjectConfigRequest ): Promise> { try { - const data = await readSupersetMigrationData(project.fs); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const data = await readSupersetMigrationData(project, fileSystem.data); const migration = toSupersetMigration(data); if (!migration) { return writeConfigFailed('No supported Superset settings were found.'); } - if (request.destination === 'local') { - const currentSettings = await project.settings.get(); - const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); - const updateResult = await project.settings.update({ - ...currentSettings, - ...shareableSettings, - }); - if (!updateResult.success) return updateResult; - return ok(migration); - } - - const writeResult = await project.fs.write( - CONFIG_FILE, - `${JSON.stringify(data.settings, null, 2)}\n` - ); - if (!writeResult.success) { - log.warn('Failed to write migrated project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); - } - - const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); - if (!clearResult.success) { - log.warn('Failed to clear imported local project settings', clearResult.error); - return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); - } - - return ok(migration); + return await applyProjectConfigMigration(project, request, data, migration); } catch (error) { log.warn('Failed to migrate Superset config to project config', error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } export const supersetConfigMigrator: ProjectConfigMigrator = { provider: 'superset', - inspect: async (fs) => toSupersetMigration(await readSupersetMigrationData(fs)), + inspect: async (project, fileSystem) => + toSupersetMigration(await readSupersetMigrationData(project, fileSystem)), migrate: migrateSupersetConfig, }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.test.ts index 5f09dd4f46..f29a86f444 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.test.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { err, ok } from '@emdash/shared'; import { describe, expect, it, vi } from 'vitest'; import { canonicalizeWorktreeDirectory, normalizeWorktreeDirectory } from './worktree-directory'; @@ -150,8 +151,8 @@ describe('worktree-directory', () => { describe('canonicalizeWorktreeDirectory', () => { it('creates and canonicalizes directory through fs provider', async () => { const fs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/path'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/path')), }; const resolved = await canonicalizeWorktreeDirectory('/input/path', fs); @@ -165,7 +166,7 @@ describe('worktree-directory', () => { it('rejects inaccessible directories', async () => { const fs = { - mkdir: vi.fn().mockRejectedValue(new Error('permission denied')), + mkdir: vi.fn().mockResolvedValue(err({ message: 'permission denied' })), realPath: vi.fn(), }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.ts b/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.ts index db27b3fd27..16c18d39e9 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.ts @@ -1,12 +1,19 @@ import type path from 'node:path'; import { err, ok, type Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; import type { UpdateProjectSettingsError } from '@shared/projects'; export type PathPlatform = 'posix' | 'win32'; type PathApi = Pick; +export type WorktreeDirectoryFileSystem = { + mkdir( + path: string, + options?: { recursive?: boolean } + ): Promise>; + realPath(path: string): Promise>; +}; + function isWindowsDriveAbsolute(input: string): boolean { return /^[A-Za-z]:[\\/]/.test(input); } @@ -60,14 +67,14 @@ export async function normalizeWorktreeDirectory( export async function canonicalizeWorktreeDirectory( directory: string, - fs: Pick + fs: WorktreeDirectoryFileSystem ): Promise> { - try { - await fs.mkdir(directory, { recursive: true }); - return ok(await fs.realPath(directory)); - } catch { - return err({ type: 'invalid-worktree-directory' }); - } + const madeDir = await fs.mkdir(directory, { recursive: true }); + if (!madeDir.success) return err({ type: 'invalid-worktree-directory' }); + + const realPath = await fs.realPath(directory); + if (!realPath.success) return err({ type: 'invalid-worktree-directory' }); + return ok(realPath.data); } export async function resolveAndValidateWorktreeDirectory( @@ -75,7 +82,7 @@ export async function resolveAndValidateWorktreeDirectory( options: { pathApi: PathApi; pathPlatform: PathPlatform; - fs: Pick; + fs: WorktreeDirectoryFileSystem; homeDirectory?: string; resolveHomeDirectory?: () => Promise; } diff --git a/apps/emdash-desktop/src/shared/core/project-settings/project-settings.ts b/apps/emdash-desktop/src/shared/core/project-settings/project-settings.ts index c9232dbfa7..54c065d05c 100644 --- a/apps/emdash-desktop/src/shared/core/project-settings/project-settings.ts +++ b/apps/emdash-desktop/src/shared/core/project-settings/project-settings.ts @@ -1,3 +1,4 @@ +import type { Result } from '@emdash/shared'; import z from 'zod'; export const PROJECT_CONFIG_FILE = '.emdash.json'; @@ -78,6 +79,12 @@ export function defaultShareableProjectSettings(): ShareableProjectSettings { export type ProjectSettings = z.infer; +export type ProjectSettingsLoadError = + | { type: 'not_found'; entity: 'workspace'; workspaceId: string } + | { type: 'fs_error'; message: string }; + +export type ProjectSettingsLoadResult = Result; + export type ProjectSettingsPatch = { clearShareableFields?: ShareableProjectSettingsWriteField[]; githubAccountId?: string | null; From a8487539ecb8977126cf139762ba11b492f93f24 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:13:57 -0700 Subject: [PATCH 25/37] feat(agent-hooks): trust absolute workspace paths --- .../agent-hooks/claude-trust-service.test.ts | 148 +++++++++++++----- .../core/agent-hooks/claude-trust-service.ts | 127 ++++++++------- .../agent-hooks/cursor-trust-service.test.ts | 118 ++++++++++---- .../core/agent-hooks/cursor-trust-service.ts | 140 ++++++++++------- .../core/agent-hooks/workspace-trust-paths.ts | 42 +++++ .../workspace-trust-service.test.ts | 16 +- .../agent-hooks/workspace-trust-service.ts | 28 +--- .../core/agent-hooks/workspace-trust-types.ts | 23 +++ .../conversation-provider-respawn.test.ts | 2 + .../conversations/impl/local-conversation.ts | 2 +- .../conversations/impl/ssh-conversation.ts | 17 +- 11 files changed, 442 insertions(+), 221 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts create mode 100644 apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts index 2cfc4427be..8001382f33 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts @@ -1,11 +1,9 @@ import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileSystemProvider, -} from '@main/core/fs/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { ClaudeTrustService } from './claude-trust-service'; const mockReadFile = vi.hoisted(() => vi.fn()); @@ -38,14 +36,6 @@ vi.mock('@main/lib/logger', () => ({ }, })); -function notFound(pathName: string): FileSystemError { - return new FileSystemError( - `File not found: ${pathName}`, - FileSystemErrorCodes.NOT_FOUND, - pathName - ); -} - function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): ClaudeTrustService { return new ClaudeTrustService({ getTaskSettings: () => @@ -54,16 +44,49 @@ function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): ClaudeTr } function makeRemoteFs( - overrides: Partial> = {} -): Pick { + overrides: Partial> = {} +): Pick { return { - realPath: vi.fn(async (p: string) => p), - read: vi.fn().mockRejectedValue(notFound('/home/remote-user/.claude.json')), - write: vi.fn().mockResolvedValue({ success: true, bytesWritten: 0 }), + realPath: vi.fn(async () => ok('/remote/worktree')), + readText: vi.fn(async (p: string) => + err({ + type: 'fs-error' as const, + path: p, + message: `File not found: ${p}`, + code: 'NOT_FOUND', + }) + ), + writeText: vi.fn(async (_path: string, content: string) => + ok({ bytesWritten: content.length }) + ), ...overrides, }; } +function makeFilesRuntime(args: { + fs: Pick; +}): IFilesRuntime { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: (parent: string, child: string) => { + const rel = path.posix.relative(parent, child); + return ( + rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) + ); + }, + }, + openTree: vi.fn(), + watchChanges: vi.fn(), + fileSystem: vi.fn(() => ok(args.fs as IFileSystem)), + dispose: vi.fn(), + } as unknown as IFilesRuntime; +} + describe('ClaudeTrustService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -79,7 +102,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'codex', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -92,7 +115,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -105,7 +128,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', force: true, }); @@ -116,11 +139,11 @@ describe('ClaudeTrustService', () => { it('writes local config atomically when missing', async () => { const service = makeService(); - const relPath = './relative/path'; + const workspacePath = '/absolute/path'; await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: relPath, + workspacePath, homedir: '/home/local-user', }); @@ -136,19 +159,36 @@ describe('ClaudeTrustService', () => { expect(renameTo).toBe('/home/local-user/.claude.json'); const written = JSON.parse(String(content)); - expect(written.projects[path.resolve(relPath)]).toEqual({ + expect(written.projects[workspacePath]).toEqual({ hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true, }); }); + it('refuses to auto-trust relative local workspace paths', async () => { + const service = makeService(); + + await service.maybeAutoTrustLocal({ + providerId: 'claude', + workspacePath: './relative/path', + homedir: '/home/local-user', + }); + + expect(mockReadFile).not.toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'ClaudeTrustService: refusing to auto-trust non-absolute workspace path', + { path: './relative/path' } + ); + }); + it('adds Copilot trusted folders', async () => { const service = makeService(); mockReadFile.mockResolvedValue(JSON.stringify({ trustedFolders: ['/already/trusted'] })); await service.maybeAutoTrustLocal({ providerId: 'copilot', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -170,7 +210,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'copilot', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -194,7 +234,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: trustedPath, + workspacePath: trustedPath, homedir: '/home/local-user', }); @@ -208,7 +248,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -226,7 +266,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -250,12 +290,12 @@ describe('ClaudeTrustService', () => { await Promise.all([ service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/worktree/a', + workspacePath: '/worktree/a', homedir: '/home/local-user', }), service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/worktree/b', + workspacePath: '/worktree/b', homedir: '/home/local-user', }), ]); @@ -275,8 +315,9 @@ describe('ClaudeTrustService', () => { it('writes ssh config and renames tmp file remotely', async () => { const service = makeService(); const remoteFs = makeRemoteFs({ - realPath: vi.fn().mockResolvedValue('/remote/worktree'), + realPath: vi.fn(async () => ok('/remote/worktree')), }); + const files = makeFilesRuntime({ fs: remoteFs }); const ctx: IExecutionContext = { root: undefined, @@ -301,17 +342,16 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustSsh({ providerId: 'claude', - cwd: '/remote/worktree', + workspacePath: '/remote/worktree', ctx, - remoteFs, + files, }); - expect(remoteFs.read).toHaveBeenCalledWith( - '/home/remote-user/.claude.json', - expect.any(Number) - ); - expect(remoteFs.write).toHaveBeenCalledTimes(1); - const [tmpPath, content] = vi.mocked(remoteFs.write).mock.calls[0]; + expect(remoteFs.readText).toHaveBeenCalledWith('/home/remote-user/.claude.json', { + maxBytes: expect.any(Number), + }); + expect(remoteFs.writeText).toHaveBeenCalledTimes(1); + const [tmpPath, content] = vi.mocked(remoteFs.writeText).mock.calls[0]; expect(tmpPath).toContain('/home/remote-user/.claude.json.'); const written = JSON.parse(String(content)); expect(written.projects['/remote/worktree']).toEqual({ @@ -320,4 +360,32 @@ describe('ClaudeTrustService', () => { }); expect(ctx.exec).toHaveBeenCalledWith('mv', [tmpPath, '/home/remote-user/.claude.json']); }); + + it('refuses to auto-trust relative ssh workspace paths', async () => { + const service = makeService(); + const remoteFs = makeRemoteFs(); + const files = makeFilesRuntime({ fs: remoteFs }); + const ctx: IExecutionContext = { + root: undefined, + supportsLocalSpawn: false, + exec: vi.fn(), + execStreaming: vi.fn(), + dispose: vi.fn(), + }; + + await service.maybeAutoTrustSsh({ + providerId: 'claude', + workspacePath: 'relative/worktree', + ctx, + files, + }); + + expect(remoteFs.realPath).not.toHaveBeenCalled(); + expect(remoteFs.writeText).not.toHaveBeenCalled(); + expect(ctx.exec).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'ClaudeTrustService: refusing to auto-trust non-absolute workspace path', + { path: 'relative/worktree' } + ); + }); }); diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts index 91022fa690..7731a32ef7 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts @@ -1,16 +1,15 @@ import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { isFileNotFoundError, isFileNotFoundException, type IFileSystem } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileSystemProvider, -} from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; import { log } from '@main/lib/logger'; import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; +import { normalizeLocalWorkspacePath, normalizeSshWorkspacePath } from './workspace-trust-paths'; +import type { WorkspaceTrustLocalArgs, WorkspaceTrustSshArgs } from './workspace-trust-types'; const CLAUDE_PROVIDER_ID: AgentProviderId = 'claude'; const COPILOT_PROVIDER_ID: AgentProviderId = 'copilot'; @@ -29,19 +28,14 @@ export class ClaudeTrustService { async maybeAutoTrustLocal({ providerId, - cwd, + workspacePath, homedir, force = false, - }: { - providerId: AgentProviderId; - cwd?: string; - homedir: string; - force?: boolean; - }): Promise { - if (!cwd) return; + }: WorkspaceTrustLocalArgs): Promise { const trustConfig = await this.getTrustConfig(providerId, force); if (!trustConfig) return; - const normalizedPath = path.resolve(cwd); + const normalizedPath = normalizeLocalWorkspacePath(workspacePath, 'ClaudeTrustService'); + if (!normalizedPath) return; const configPath = path.join(homedir, trustConfig.configName); await this.withLock(configPath, () => this.ensureTrusted(normalizedPath, { @@ -54,29 +48,35 @@ export class ClaudeTrustService { async maybeAutoTrustSsh({ providerId, - cwd, + workspacePath, ctx, - remoteFs, + files, force = false, - }: { - providerId: AgentProviderId; - cwd?: string; - ctx: IExecutionContext; - remoteFs: Pick; - force?: boolean; - }): Promise { - if (!cwd) return; + }: WorkspaceTrustSshArgs): Promise { const trustConfig = await this.getTrustConfig(providerId, force); if (!trustConfig) return; - const normalizedPath = await remoteFs.realPath(cwd).catch(() => path.posix.resolve('/', cwd)); + const normalizedPath = await normalizeSshWorkspacePath( + files, + workspacePath, + 'ClaudeTrustService' + ); + if (!normalizedPath) return; const homeDir = await resolveRemoteHome(ctx); + const homeFs = files.fileSystem(); + if (!homeFs.success) { + log.warn('ClaudeTrustService: failed to open filesystem for auto-trust', { + path: normalizedPath, + error: homeFs.error.message, + }); + return; + } const configPath = path.posix.join(homeDir, trustConfig.configName); await this.withLock(configPath, () => this.ensureTrusted(normalizedPath, { - readConfig: () => readRemoteConfig(remoteFs, configPath), - writeConfig: (content) => writeRemoteConfigAtomic(remoteFs, ctx, configPath, content), + readConfig: () => readRemoteConfig(homeFs.data, configPath), + writeConfig: (content) => writeRemoteConfigAtomic(homeFs.data, ctx, configPath, content), trustConfig, }) ); @@ -117,18 +117,31 @@ export class ClaudeTrustService { private async ensureTrusted( normalizedPath: string, io: { - readConfig: () => Promise; - writeConfig: (content: string) => Promise; + readConfig: () => Promise>; + writeConfig: (content: string) => Promise>; trustConfig: TrustConfig; } ): Promise { try { const rawConfig = await io.readConfig(); - const config = parseConfig(rawConfig, io.trustConfig.parseWarningName); + if (!rawConfig.success) { + log.warn('ClaudeTrustService: failed to read auto-trust config', { + path: normalizedPath, + error: rawConfig.error.message, + }); + return; + } + const config = parseConfig(rawConfig.data, io.trustConfig.parseWarningName); if (!config) return; const nextConfig = io.trustConfig.withTrustedPath(config, normalizedPath); if (!nextConfig) return; - await io.writeConfig(JSON.stringify(nextConfig, null, 2) + '\n'); + const written = await io.writeConfig(JSON.stringify(nextConfig, null, 2) + '\n'); + if (!written.success) { + log.warn('ClaudeTrustService: failed to write auto-trust config', { + path: normalizedPath, + error: written.error.message, + }); + } } catch (error: unknown) { log.warn('ClaudeTrustService: failed to auto-trust worktree', { path: normalizedPath, @@ -151,6 +164,9 @@ type TrustConfig = { ) => Record | null; }; +type TrustIoError = { message: string }; +type TrustIoResult = Result; + function parseConfig(raw: string | null, warningName: string): Record | null { if (!raw || raw.trim() === '') return {}; @@ -205,67 +221,66 @@ function withCopilotTrustedFolder( }; } -async function readLocalConfig(configPath: string): Promise { +async function readLocalConfig(configPath: string): Promise> { try { - return await fs.readFile(configPath, 'utf8'); + return ok(await fs.readFile(configPath, 'utf8')); } catch (error: unknown) { - if (isNodeNotFound(error)) return null; - throw error; + if (isFileNotFoundException(error)) return ok(null); + return err({ message: errorMessage(error) }); } } -async function writeLocalConfigAtomic(configPath: string, content: string): Promise { +async function writeLocalConfigAtomic( + configPath: string, + content: string +): Promise> { const tmpPath = `${configPath}.${randomUUID()}.tmp`; try { await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(tmpPath, content, 'utf8'); await fs.rename(tmpPath, configPath); + return ok(); } catch (error: unknown) { try { await fs.rm(tmpPath, { force: true }); } catch {} - throw error; + return err({ message: errorMessage(error) }); } } async function readRemoteConfig( - remoteFs: Pick, + remoteFs: Pick, configPath: string -): Promise { - try { - const result = await remoteFs.read(configPath, CLAUDE_CONFIG_MAX_BYTES); - return result.content; - } catch (error: unknown) { - if (isFsNotFound(error)) return null; - throw error; - } +): Promise> { + const result = await remoteFs.readText(configPath, { maxBytes: CLAUDE_CONFIG_MAX_BYTES }); + if (result.success) return ok(result.data.content); + if (isFileNotFoundError(result.error)) return ok(null); + return err(result.error); } async function writeRemoteConfigAtomic( - remoteFs: Pick, + remoteFs: Pick, ctx: IExecutionContext, configPath: string, content: string -): Promise { +): Promise> { const tmpPath = `${configPath}.${randomUUID()}.tmp`; try { await ctx.exec('mkdir', ['-p', path.posix.dirname(configPath)]); - await remoteFs.write(tmpPath, content); + const written = await remoteFs.writeText(tmpPath, content); + if (!written.success) return err(written.error); await ctx.exec('mv', [tmpPath, configPath]); + return ok(); } catch (error: unknown) { try { await ctx.exec('rm', ['-f', tmpPath]); } catch {} - throw error; + return err({ message: errorMessage(error) }); } } -function isNodeNotFound(error: unknown): boolean { - return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; -} - -function isFsNotFound(error: unknown): boolean { - return error instanceof FileSystemError && error.code === FileSystemErrorCodes.NOT_FOUND; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } function isPlainObject(value: unknown): value is Record { diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts index 08f4f3f03d..2e37c92e8c 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts @@ -1,10 +1,9 @@ +import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileSystemProvider, -} from '@main/core/fs/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { CursorTrustService } from './cursor-trust-service'; const mockAccess = vi.hoisted(() => vi.fn()); @@ -37,14 +36,6 @@ function nodeNotFound() { return Object.assign(new Error('not found'), { code: 'ENOENT' }); } -function fsNotFound(pathName: string): FileSystemError { - return new FileSystemError( - `File not found: ${pathName}`, - FileSystemErrorCodes.NOT_FOUND, - pathName - ); -} - function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): CursorTrustService { return new CursorTrustService({ getTaskSettings: () => @@ -53,16 +44,42 @@ function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): CursorTr } function makeRemoteFs( - overrides: Partial> = {} -): Pick { + overrides: Partial> = {} +): Pick { return { - realPath: vi.fn(async (p: string) => p), - read: vi.fn().mockRejectedValue(fsNotFound('/home/remote-user/.cursor/projects/worktree')), - write: vi.fn().mockResolvedValue({ success: true, bytesWritten: 0 }), + realPath: vi.fn(async () => ok('/remote/worktree')), + exists: vi.fn(async () => ok(false)), + writeText: vi.fn(async (_path: string, content: string) => + ok({ bytesWritten: content.length }) + ), ...overrides, }; } +function makeFilesRuntime(args: { + fs: Pick; +}): IFilesRuntime { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: (parent: string, child: string) => { + const rel = path.posix.relative(parent, child); + return ( + rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) + ); + }, + }, + openTree: vi.fn(), + watchChanges: vi.fn(), + fileSystem: vi.fn(() => ok(args.fs as IFileSystem)), + dispose: vi.fn(), + } as unknown as IFilesRuntime; +} + function makeCtx(): IExecutionContext { return { root: undefined, @@ -89,7 +106,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -102,7 +119,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -115,7 +132,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', force: true, }); @@ -131,7 +148,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -150,13 +167,30 @@ describe('CursorTrustService', () => { }); }); + it('refuses to auto-trust relative local workspace paths', async () => { + const service = makeService(); + + await service.maybeAutoTrustLocal({ + providerId: 'cursor', + workspacePath: './relative/path', + homedir: '/home/local-user', + }); + + expect(mockAccess).not.toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'CursorTrustService: refusing to auto-trust non-absolute workspace path', + { path: './relative/path' } + ); + }); + it('is idempotent when the local marker already exists', async () => { const service = makeService(); mockAccess.mockResolvedValue(undefined); await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -169,7 +203,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/Users/janburzinski/emdash/worktrees/emdash-official/tough-falcons-notice', + workspacePath: '/Users/janburzinski/emdash/worktrees/emdash-official/tough-falcons-notice', homedir: '/Users/janburzinski', }); @@ -183,26 +217,50 @@ describe('CursorTrustService', () => { it('writes the ssh Cursor workspace trust marker remotely', async () => { const service = makeService(); const remoteFs = makeRemoteFs({ - realPath: vi.fn().mockResolvedValue('/remote/worktree'), + realPath: vi.fn(async () => ok('/remote/worktree')), }); + const files = makeFilesRuntime({ fs: remoteFs }); const ctx = makeCtx(); await service.maybeAutoTrustSsh({ providerId: 'cursor', - cwd: '/remote/worktree', + workspacePath: '/remote/worktree', ctx, - remoteFs, + files, }); const markerPath = '/home/remote-user/.cursor/projects/remote-worktree/.workspace-trusted'; - expect(remoteFs.read).toHaveBeenCalledWith(markerPath, expect.any(Number)); - expect(remoteFs.write).toHaveBeenCalledWith(markerPath, expect.any(String)); + expect(remoteFs.exists).toHaveBeenCalledWith(markerPath); + expect(remoteFs.writeText).toHaveBeenCalledWith(markerPath, expect.any(String)); - const marker = JSON.parse(String(vi.mocked(remoteFs.write).mock.calls[0][1])); + const marker = JSON.parse(String(vi.mocked(remoteFs.writeText).mock.calls[0][1])); expect(marker).toEqual({ trustedAt: expect.any(String), workspacePath: '/remote/worktree', trustMethod: 'emdash-auto-trust', }); }); + + it('refuses to auto-trust relative ssh workspace paths', async () => { + const service = makeService(); + const remoteFs = makeRemoteFs(); + const files = makeFilesRuntime({ fs: remoteFs }); + const ctx = makeCtx(); + + await service.maybeAutoTrustSsh({ + providerId: 'cursor', + workspacePath: 'relative/worktree', + ctx, + files, + }); + + expect(remoteFs.realPath).not.toHaveBeenCalled(); + expect(remoteFs.exists).not.toHaveBeenCalled(); + expect(remoteFs.writeText).not.toHaveBeenCalled(); + expect(ctx.exec).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'CursorTrustService: refusing to auto-trust non-absolute workspace path', + { path: 'relative/worktree' } + ); + }); }); diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts index adbcd86405..89ae19b989 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts @@ -1,21 +1,18 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { IExecutionContext } from '@main/core/execution-context/types'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileSystemProvider, -} from '@main/core/fs/types'; +import { isFileNotFoundException, type IFileSystem } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; import { appSettingsService } from '@main/core/settings/settings-service'; import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; import { log } from '@main/lib/logger'; import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; +import { normalizeLocalWorkspacePath, normalizeSshWorkspacePath } from './workspace-trust-paths'; +import type { WorkspaceTrustLocalArgs, WorkspaceTrustSshArgs } from './workspace-trust-types'; const CURSOR_PROVIDER_ID: AgentProviderId = 'cursor'; const CURSOR_DATA_DIR_NAME = '.cursor'; const CURSOR_PROJECTS_DIR_NAME = 'projects'; const CURSOR_TRUST_MARKER_NAME = '.workspace-trusted'; -const CURSOR_TRUST_MARKER_MAX_BYTES = 1024; export class CursorTrustService { constructor( @@ -26,26 +23,24 @@ export class CursorTrustService { async maybeAutoTrustLocal({ providerId, - cwd, + workspacePath, homedir, force = false, - }: { - providerId: AgentProviderId; - cwd?: string; - homedir: string; - force?: boolean; - }): Promise { - if (!cwd) return; + }: WorkspaceTrustLocalArgs): Promise { if (!(await this.shouldAutoTrust(providerId, force))) return; - const workspacePath = path.resolve(cwd); + const normalizedWorkspacePath = normalizeLocalWorkspacePath( + workspacePath, + 'CursorTrustService' + ); + if (!normalizedWorkspacePath) return; const dataDir = path.join(homedir, CURSOR_DATA_DIR_NAME); const markerPath = path.join( - cursorProjectDir(workspacePath, dataDir, path), + cursorProjectDir(normalizedWorkspacePath, dataDir, path), CURSOR_TRUST_MARKER_NAME ); - await this.ensureTrusted(markerPath, workspacePath, { + await this.ensureTrusted(markerPath, normalizedWorkspacePath, { exists: () => localExists(markerPath), write: (content) => writeLocalMarker(markerPath, content), }); @@ -53,31 +48,37 @@ export class CursorTrustService { async maybeAutoTrustSsh({ providerId, - cwd, + workspacePath, ctx, - remoteFs, + files, force = false, - }: { - providerId: AgentProviderId; - cwd?: string; - ctx: IExecutionContext; - remoteFs: Pick; - force?: boolean; - }): Promise { - if (!cwd) return; + }: WorkspaceTrustSshArgs): Promise { if (!(await this.shouldAutoTrust(providerId, force))) return; - const workspacePath = await remoteFs.realPath(cwd).catch(() => path.posix.resolve('/', cwd)); + const normalizedWorkspacePath = await normalizeSshWorkspacePath( + files, + workspacePath, + 'CursorTrustService' + ); + if (!normalizedWorkspacePath) return; const homeDir = await resolveRemoteHome(ctx); + const homeFs = files.fileSystem(); + if (!homeFs.success) { + log.warn('CursorTrustService: failed to open filesystem for auto-trust', { + path: normalizedWorkspacePath, + error: homeFs.error.message, + }); + return; + } const dataDir = path.posix.join(homeDir, CURSOR_DATA_DIR_NAME); const markerPath = path.posix.join( - cursorProjectDir(workspacePath, dataDir, path.posix), + cursorProjectDir(normalizedWorkspacePath, dataDir, path.posix), CURSOR_TRUST_MARKER_NAME ); - await this.ensureTrusted(markerPath, workspacePath, { - exists: () => remoteExists(remoteFs, markerPath), - write: (content) => remoteFs.write(markerPath, content).then(() => undefined), + await this.ensureTrusted(markerPath, normalizedWorkspacePath, { + exists: () => remoteExists(homeFs.data, markerPath), + write: (content) => writeRemoteText(homeFs.data, markerPath, content), }); } @@ -92,14 +93,32 @@ export class CursorTrustService { markerPath: string, workspacePath: string, io: { - exists: () => Promise; - write: (content: string) => Promise; + exists: () => Promise>; + write: (content: string) => Promise>; } ): Promise { try { - if (await io.exists()) return; - - await io.write(JSON.stringify(createTrustMarker(workspacePath), null, 2) + '\n'); + const exists = await io.exists(); + if (!exists.success) { + log.warn('CursorTrustService: failed to check auto-trust marker', { + path: workspacePath, + markerPath, + error: exists.error.message, + }); + return; + } + if (exists.data) return; + + const written = await io.write( + JSON.stringify(createTrustMarker(workspacePath), null, 2) + '\n' + ); + if (!written.success) { + log.warn('CursorTrustService: failed to write auto-trust marker', { + path: workspacePath, + markerPath, + error: written.error.message, + }); + } } catch (error: unknown) { log.warn('CursorTrustService: failed to auto-trust worktree', { path: workspacePath, @@ -110,6 +129,9 @@ export class CursorTrustService { } } +type TrustIoError = { message: string }; +type TrustIoResult = Result; + export const cursorTrustService = new CursorTrustService({ getTaskSettings: () => appSettingsService.get('tasks'), }); @@ -138,38 +160,42 @@ function slugifyPath(value: string): string { .replace(/^-+|-+$/g, ''); } -async function localExists(markerPath: string): Promise { +async function localExists(markerPath: string): Promise> { try { await fs.access(markerPath); - return true; + return ok(true); } catch (error: unknown) { - if (isNodeNotFound(error)) return false; - throw error; + if (isFileNotFoundException(error)) return ok(false); + return err({ message: errorMessage(error) }); } } async function remoteExists( - remoteFs: Pick, + remoteFs: Pick, markerPath: string -): Promise { +): Promise> { + return remoteFs.exists(markerPath); +} + +async function writeLocalMarker(markerPath: string, content: string): Promise> { try { - await remoteFs.read(markerPath, CURSOR_TRUST_MARKER_MAX_BYTES); - return true; + await fs.mkdir(path.dirname(markerPath), { recursive: true }); + await fs.writeFile(markerPath, content, 'utf8'); + return ok(); } catch (error: unknown) { - if (isFsNotFound(error)) return false; - throw error; + return err({ message: errorMessage(error) }); } } -async function writeLocalMarker(markerPath: string, content: string): Promise { - await fs.mkdir(path.dirname(markerPath), { recursive: true }); - await fs.writeFile(markerPath, content, 'utf8'); -} - -function isNodeNotFound(error: unknown): boolean { - return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; +async function writeRemoteText( + remoteFs: Pick, + absPath: string, + content: string +): Promise> { + const result = await remoteFs.writeText(absPath, content); + return result.success ? ok() : result; } -function isFsNotFound(error: unknown): boolean { - return error instanceof FileSystemError && error.code === FileSystemErrorCodes.NOT_FOUND; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts new file mode 100644 index 0000000000..a2aff73296 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; +import type { IFilesRuntime } from '@main/core/runtime/types'; +import { log } from '@main/lib/logger'; + +export function normalizeLocalWorkspacePath( + workspacePath: string, + serviceName: string +): string | null { + if (!path.isAbsolute(workspacePath)) { + log.warn(`${serviceName}: refusing to auto-trust non-absolute workspace path`, { + path: workspacePath, + }); + return null; + } + + return path.normalize(workspacePath); +} + +export async function normalizeSshWorkspacePath( + files: IFilesRuntime, + workspacePath: string, + serviceName: string +): Promise { + if (!files.path.isAbsolute(workspacePath)) { + log.warn(`${serviceName}: refusing to auto-trust non-absolute workspace path`, { + path: workspacePath, + }); + return null; + } + + const opened = files.fileSystem(); + if (!opened.success) { + log.warn(`${serviceName}: failed to open filesystem for workspace trust`, { + path: workspacePath, + error: opened.error.message, + }); + return null; + } + + const realPath = await opened.data.realPath(workspacePath); + return realPath.success ? realPath.data : path.posix.normalize(workspacePath); +} diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts index d325c48138..ff6282acb7 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; vi.mock('@main/core/settings/settings-service', () => ({ appSettingsService: { get: vi.fn() }, @@ -25,12 +25,8 @@ function makeCtx(): IExecutionContext { }; } -function makeRemoteFs(): Pick { - return { - realPath: vi.fn(), - read: vi.fn(), - write: vi.fn(), - }; +function makeFilesRuntime(): IFilesRuntime { + return { fileSystem: vi.fn() } as unknown as IFilesRuntime; } describe('WorkspaceTrustService', () => { @@ -40,7 +36,7 @@ describe('WorkspaceTrustService', () => { const service = new WorkspaceTrustService([first, second]); const args = { providerId: 'cursor' as const, - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', force: true, }; @@ -57,9 +53,9 @@ describe('WorkspaceTrustService', () => { const service = new WorkspaceTrustService([first, second]); const args = { providerId: 'cursor' as const, - cwd: '/remote/worktree', + workspacePath: '/remote/worktree', ctx: makeCtx(), - remoteFs: makeRemoteFs(), + files: makeFilesRuntime(), force: true, }; diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts index 36a0cc1262..f0b0adfa92 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts @@ -1,28 +1,10 @@ -import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; -import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; import { claudeTrustService } from './claude-trust-service'; import { cursorTrustService } from './cursor-trust-service'; - -type WorkspaceTrustLocalArgs = { - providerId: AgentProviderId; - cwd?: string; - homedir: string; - force?: boolean; -}; - -type WorkspaceTrustSshArgs = { - providerId: AgentProviderId; - cwd?: string; - ctx: IExecutionContext; - remoteFs: Pick; - force?: boolean; -}; - -type WorkspaceTrustProvider = { - maybeAutoTrustLocal(args: WorkspaceTrustLocalArgs): Promise; - maybeAutoTrustSsh(args: WorkspaceTrustSshArgs): Promise; -}; +import type { + WorkspaceTrustLocalArgs, + WorkspaceTrustProvider, + WorkspaceTrustSshArgs, +} from './workspace-trust-types'; export class WorkspaceTrustService { constructor(private readonly providers: readonly WorkspaceTrustProvider[]) {} diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts new file mode 100644 index 0000000000..ec3cfe3716 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts @@ -0,0 +1,23 @@ +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; +import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; + +export type WorkspaceTrustLocalArgs = { + providerId: AgentProviderId; + workspacePath: string; + homedir: string; + force?: boolean; +}; + +export type WorkspaceTrustSshArgs = { + providerId: AgentProviderId; + workspacePath: string; + ctx: IExecutionContext; + files: IFilesRuntime; + force?: boolean; +}; + +export type WorkspaceTrustProvider = { + maybeAutoTrustLocal(args: WorkspaceTrustLocalArgs): Promise; + maybeAutoTrustSsh(args: WorkspaceTrustSshArgs): Promise; +}; diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts index 96ae778cee..4f5483133a 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CONVERSATION_FRESH_RECOVERY_GRACE_MS } from '@main/core/conversations/conversation-session-supervisor'; import type { Pty, PtyExitInfo } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { agentSessionExitedChannel } from '@shared/core/agents/agentEvents'; import type { Conversation } from '@shared/core/conversations/conversations'; import { ptyExitChannel } from '@shared/core/pty/ptyEvents'; @@ -204,6 +205,7 @@ function sshProvider( tmux, ctx, proxy: proxy as never, + filesRuntime: {} as IFilesRuntime, }); } diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts index 374201c9b4..c738ae3e33 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts @@ -119,7 +119,7 @@ export class LocalConversationProvider implements ConversationProvider { try { await workspaceTrustService.maybeAutoTrustLocal({ providerId: conversation.providerId, - cwd: this.taskPath, + workspacePath: this.taskPath, homedir: homedir(), force: conversation.autoApprove === true, }); diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts index 37d985cedc..baa80f3b1e 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts @@ -5,13 +5,13 @@ import { resolveAgentSessionCommandArgs } from '@main/core/conversations/resolve import type { ConversationProvider } from '@main/core/conversations/types'; import { hostDependencyStore } from '@main/core/dependencies/host-dependency-store'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { Pty } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSshCommand } from '@main/core/pty/spawn-utils'; import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import { getTerminalColorEnv } from '@main/core/pty/terminal-color-scheme'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import { events } from '@main/lib/events'; @@ -45,6 +45,7 @@ export class SshConversationProvider implements ConversationProvider { private readonly shellSetup?: string; private readonly ctx: IExecutionContext; private readonly proxy: SshClientProxy; + private readonly filesRuntime: IFilesRuntime; constructor({ projectId, @@ -55,6 +56,7 @@ export class SshConversationProvider implements ConversationProvider { shellSetup, ctx, proxy, + filesRuntime, }: { projectId: string; taskPath: string; @@ -64,6 +66,7 @@ export class SshConversationProvider implements ConversationProvider { shellSetup?: string; ctx: IExecutionContext; proxy: SshClientProxy; + filesRuntime: IFilesRuntime; }) { this.projectId = projectId; this.taskPath = taskPath; @@ -73,6 +76,7 @@ export class SshConversationProvider implements ConversationProvider { this.shellSetup = shellSetup; this.ctx = ctx; this.proxy = proxy; + this.filesRuntime = filesRuntime; } async startSession( @@ -114,9 +118,9 @@ export class SshConversationProvider implements ConversationProvider { try { await workspaceTrustService.maybeAutoTrustSsh({ providerId: conversation.providerId, - cwd: this.taskPath, + workspacePath: this.taskPath, ctx: this.ctx, - remoteFs: new SshFileSystem(this.proxy, '/'), + files: this.filesRuntime, force: conversation.autoApprove === true, }); @@ -188,7 +192,12 @@ export class SshConversationProvider implements ConversationProvider { sessionId, error: result.error.message, }); - throw new Error(result.error.message); + this.supervisor.failSpawn(sessionId, spawnToken); + events.emit(agentSessionExitedChannel, { + conversationId: conversation.id, + taskId: conversation.taskId, + }); + return; } const pty = result.data; From 09e1d37757b71487ff97e436a03060154f8e2052 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:14:10 -0700 Subject: [PATCH 26/37] feat(search): index absolute workspace file paths --- .../core/search/collect-with-budget.test.ts | 5 +- .../main/core/search/collect-with-budget.ts | 11 +- .../workspace-file-index-service.db.test.ts | 78 +++++----- .../workspace-file-index-service.test.ts | 134 +++++++++++++----- .../search/workspace-file-index-service.ts | 30 +++- .../workspace-file-index-store.db.test.ts | 75 ++++++---- .../core/search/workspace-file-index-store.ts | 16 ++- apps/emdash-desktop/src/main/db/initialize.ts | 3 +- .../tooling/fixtures/baseline.db | Bin 364544 -> 364544 bytes apps/emdash-desktop/tooling/fixtures/empty.db | Bin 364544 -> 364544 bytes 10 files changed, 238 insertions(+), 114 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts b/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts index fc184d0b32..9da9bdf99b 100644 --- a/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts +++ b/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts @@ -1,4 +1,3 @@ -import type { RelPath } from '@emdash/core/files'; import { describe, expect, it } from 'vitest'; import { collectWithBudget } from './collect-with-budget'; @@ -48,8 +47,8 @@ describe('collectWithBudget', () => { }); }); -async function* paths(values: string[]): AsyncIterable { +async function* paths(values: string[]): AsyncIterable { for (const value of values) { - yield value as RelPath; + yield value; } } diff --git a/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts b/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts index 6ebb128f75..d720ad4386 100644 --- a/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts +++ b/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts @@ -1,4 +1,3 @@ -import type { RelPath } from '@emdash/core/files'; import type { FileIndexTruncateReason } from './workspace-file-index-store'; export type CollectWithBudgetOptions = { @@ -8,22 +7,22 @@ export type CollectWithBudgetOptions = { }; export type BudgetedFileCollection = { - paths: RelPath[]; + paths: string[]; truncated: boolean; truncateReason?: FileIndexTruncateReason; }; export async function collectWithBudget( - paths: AsyncIterable, + paths: AsyncIterable, options: CollectWithBudgetOptions ): Promise { const now = options.now ?? Date.now; const startTime = now(); - const collected: RelPath[] = []; + const collected: string[] = []; let truncated = false; let truncateReason: FileIndexTruncateReason | undefined; - for await (const relPath of paths) { + for await (const filePath of paths) { if (now() - startTime > options.timeoutMs) { truncated = true; truncateReason = 'timeBudget'; @@ -34,7 +33,7 @@ export async function collectWithBudget( truncateReason = 'maxEntries'; break; } - collected.push(relPath); + collected.push(filePath); } return { paths: collected, truncated, truncateReason }; diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts index af17812e7e..f233ca9f43 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts @@ -1,13 +1,14 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { IFilesRuntime, RelPath } from '@emdash/core/files'; +import type { IFileSystem, IFilesRuntime } from '@emdash/core/files'; import type BetterSqlite3 from 'better-sqlite3'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { WorkspaceFileIndexServiceOptions } from './workspace-file-index-service'; type LoadedService = Awaited>; type FileIndexMetaRow = { + root_path: string; status: string; file_count: number; truncate_reason: string | null; @@ -34,17 +35,18 @@ describe('WorkspaceFileIndexService', () => { await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', - filesRuntime: filesRuntime(() => ['README.md', 'src/index.ts']), + filesRuntime: filesRuntime(() => ['/repo/README.md', '/repo/src/index.ts']), }); - expect(indexedPaths(sqlite)).toEqual(['README.md', 'src/index.ts']); + expect(indexedPaths(sqlite)).toEqual(['/repo/README.md', '/repo/src/index.ts']); expect(indexMeta(sqlite)).toEqual({ + root_path: '/repo', status: 'complete', file_count: 2, truncate_reason: null, }); expect(service.search('ws-1', 'index')).toEqual([ - { path: 'src/index.ts', filename: 'index.ts' }, + { path: '/repo/src/index.ts', filename: 'index.ts' }, ]); }); @@ -54,20 +56,20 @@ describe('WorkspaceFileIndexService', () => { await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', - filesRuntime: filesRuntime(() => ['src/changed.ts', 'src/old.ts']), + filesRuntime: filesRuntime(() => ['/repo/src/changed.ts', '/repo/src/old.ts']), }); service.onWorkspaceFileChange('ws-1', { kind: 'changes', changes: [ - { kind: 'create', path: 'src/new.ts', entryType: 'file' }, - { kind: 'update', path: 'src/changed.ts', entryType: 'file' }, - { kind: 'update', path: 'src/missing.ts', entryType: 'file' }, - { kind: 'delete', path: 'src/old.ts', entryType: 'file' }, + { kind: 'create', path: '/repo/src/new.ts', entryType: 'file' }, + { kind: 'update', path: '/repo/src/changed.ts', entryType: 'file' }, + { kind: 'update', path: '/repo/src/missing.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/src/old.ts', entryType: 'file' }, ], }); - expect(indexedPaths(sqlite)).toEqual(['src/changed.ts', 'src/new.ts']); + expect(indexedPaths(sqlite)).toEqual(['/repo/src/changed.ts', '/repo/src/new.ts']); expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 2 }); }); @@ -77,15 +79,19 @@ describe('WorkspaceFileIndexService', () => { await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', - filesRuntime: filesRuntime(() => ['other.ts', 'src/a.ts', 'src/nested/b.ts']), + filesRuntime: filesRuntime(() => [ + '/repo/other.ts', + '/repo/src/a.ts', + '/repo/src/nested/b.ts', + ]), }); service.onWorkspaceFileChange('ws-1', { kind: 'changes', - changes: [{ kind: 'delete', path: 'src', entryType: 'unknown' }], + changes: [{ kind: 'delete', path: '/repo/src', entryType: 'unknown' }], }); - expect(indexedPaths(sqlite)).toEqual(['other.ts']); + expect(indexedPaths(sqlite)).toEqual(['/repo/other.ts']); expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 1 }); }); @@ -93,19 +99,19 @@ describe('WorkspaceFileIndexService', () => { vi.useFakeTimers(); loadedService = await loadService({ reindexDebounceMs: 1 }); const { service, sqlite } = loadedService; - let paths = ['stale.ts']; + let paths = ['/repo/stale.ts']; await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', filesRuntime: filesRuntime(() => paths), }); - expect(indexedPaths(sqlite)).toEqual(['stale.ts']); + expect(indexedPaths(sqlite)).toEqual(['/repo/stale.ts']); - paths = ['fresh.ts']; + paths = ['/repo/fresh.ts']; service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); await vi.advanceTimersByTimeAsync(1); - expect(indexedPaths(sqlite)).toEqual(['fresh.ts']); + expect(indexedPaths(sqlite)).toEqual(['/repo/fresh.ts']); expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 1 }); }); @@ -115,11 +121,12 @@ describe('WorkspaceFileIndexService', () => { await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', - filesRuntime: filesRuntime(() => ['a.ts', 'b.ts', 'c.ts']), + filesRuntime: filesRuntime(() => ['/repo/a.ts', '/repo/b.ts', '/repo/c.ts']), }); - expect(indexedPaths(sqlite)).toEqual(['a.ts', 'b.ts']); + expect(indexedPaths(sqlite)).toEqual(['/repo/a.ts', '/repo/b.ts']); expect(indexMeta(sqlite)).toEqual({ + root_path: '/repo', status: 'truncated', file_count: 2, truncate_reason: 'maxEntries', @@ -127,10 +134,10 @@ describe('WorkspaceFileIndexService', () => { service.onWorkspaceFileChange('ws-1', { kind: 'changes', - changes: [{ kind: 'create', path: 'd.ts', entryType: 'file' }], + changes: [{ kind: 'create', path: '/repo/d.ts', entryType: 'file' }], }); - expect(indexedPaths(sqlite)).toEqual(['a.ts', 'b.ts']); + expect(indexedPaths(sqlite)).toEqual(['/repo/a.ts', '/repo/b.ts']); expect(indexMeta(sqlite)).toMatchObject({ status: 'truncated', file_count: 2 }); }); @@ -140,15 +147,15 @@ describe('WorkspaceFileIndexService', () => { await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', - filesRuntime: filesRuntime(() => ['a.ts', 'b.ts']), + filesRuntime: filesRuntime(() => ['/repo/a.ts', '/repo/b.ts']), }); service.onWorkspaceFileChange('ws-1', { kind: 'changes', - changes: [{ kind: 'create', path: 'c.ts', entryType: 'file' }], + changes: [{ kind: 'create', path: '/repo/c.ts', entryType: 'file' }], }); - expect(indexedPaths(sqlite)).toEqual(['a.ts', 'b.ts']); + expect(indexedPaths(sqlite)).toEqual(['/repo/a.ts', '/repo/b.ts']); expect(indexMeta(sqlite)).toMatchObject({ status: 'stale', file_count: 2 }); }); }); @@ -182,6 +189,7 @@ function createFileIndexTables(sqlite: BetterSqlite3.Database): void { CREATE TABLE workspace_file_index_meta ( workspace_id TEXT PRIMARY KEY, indexed_at INTEGER NOT NULL, + root_path TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('complete', 'stale', 'truncated')), file_count INTEGER NOT NULL, @@ -192,6 +200,17 @@ function createFileIndexTables(sqlite: BetterSqlite3.Database): void { } function filesRuntime(readPaths: () => readonly string[]): IFilesRuntime { + const fileSystem = { + enumerate: () => ({ + success: true as const, + data: (async function* () { + for (const path of readPaths()) { + yield path; + } + })(), + }), + } as unknown as IFileSystem; + return { openTree: async () => { throw new Error('openTree is not used by WorkspaceFileIndexService tests'); @@ -199,14 +218,7 @@ function filesRuntime(readPaths: () => readonly string[]): IFilesRuntime { watchChanges: () => { throw new Error('watchChanges is not used by WorkspaceFileIndexService tests'); }, - enumerate: () => ({ - success: true, - data: (async function* () { - for (const path of readPaths()) { - yield path as RelPath; - } - })(), - }), + fileSystem: () => ({ success: true, data: fileSystem }), dispose: async () => {}, }; } @@ -222,7 +234,7 @@ function indexedPaths(sqlite: BetterSqlite3.Database): string[] { function indexMeta(sqlite: BetterSqlite3.Database): FileIndexMetaRow | undefined { return sqlite .prepare( - `SELECT status, file_count, truncate_reason + `SELECT root_path, status, file_count, truncate_reason FROM workspace_file_index_meta WHERE workspace_id = 'ws-1'` ) diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts index 0d540e75e7..e50b171154 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts @@ -1,4 +1,4 @@ -import type { IFilesRuntime, RelPath } from '@emdash/core/files'; +import type { IFileSystem, IFilesRuntime } from '@emdash/core/files'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { FileHit, @@ -17,21 +17,26 @@ describe('WorkspaceFileIndexService', () => { it('delegates initialize and search to the store', async () => { const store = new FakeStore(); - store.searchResults = [{ path: 'src/index.ts', filename: 'index.ts' }]; + store.searchResults = [{ path: '/repo/src/index.ts', filename: 'index.ts' }]; const service = await createService(store); service.initialize(); expect(store.evictedDays).toBe(14); expect(service.search('ws-1', 'index')).toEqual([ - { path: 'src/index.ts', filename: 'index.ts' }, + { path: '/repo/src/index.ts', filename: 'index.ts' }, ]); expect(store.operations).toContain('search:index'); }); it('refreshes complete metadata on activation without enumerating', async () => { const store = new FakeStore(); - store.meta.set('ws-1', { status: 'complete', fileCount: 1, truncateReason: null }); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); const service = await createService(store); await service.onWorkspaceActivated('ws-1', { @@ -44,17 +49,44 @@ describe('WorkspaceFileIndexService', () => { expect(store.operations).toEqual(['refresh:ws-1']); }); + it('reindexes when a complete index belongs to an old workspace root', async () => { + const store = new FakeStore(); + store.meta.set('ws-1', { + rootPath: '/old-repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + store.paths.set('ws-1', new Set(['/old-repo/stale.ts'])); + const service = await createService(store); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + filesRuntime: filesRuntime(() => ['/repo/fresh.ts']), + }); + + expect([...store.pathSet('ws-1')]).toEqual(['/repo/fresh.ts']); + expect(store.meta.get('ws-1')).toEqual({ + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + expect(store.operations).toContain('deleteIndex:ws-1'); + }); + it('indexes from enumeration when metadata is missing', async () => { const store = new FakeStore(); const service = await createService(store); await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', - filesRuntime: filesRuntime(() => ['README.md', 'src/index.ts']), + filesRuntime: filesRuntime(() => ['/repo/README.md', '/repo/src/index.ts']), }); - expect([...store.pathSet('ws-1')].sort()).toEqual(['README.md', 'src/index.ts']); + expect([...store.pathSet('ws-1')].sort()).toEqual(['/repo/README.md', '/repo/src/index.ts']); expect(store.meta.get('ws-1')).toEqual({ + rootPath: '/repo', status: 'complete', fileCount: 2, truncateReason: null, @@ -64,46 +96,58 @@ describe('WorkspaceFileIndexService', () => { it('debounces and coalesces resync requests', async () => { vi.useFakeTimers(); const store = new FakeStore(); - store.meta.set('ws-1', { status: 'complete', fileCount: 1, truncateReason: null }); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); const service = await createService(store, { reindexDebounceMs: 5 }); await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', - filesRuntime: filesRuntime(() => ['fresh.ts']), + filesRuntime: filesRuntime(() => ['/repo/fresh.ts']), }); service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); await vi.advanceTimersByTimeAsync(5); - expect(store.operations.filter((op) => op.startsWith('sync:'))).toEqual(['sync:fresh.ts']); + expect(store.operations.filter((op) => op.startsWith('sync:'))).toEqual([ + 'sync:/repo/fresh.ts', + ]); expect(store.meta.get('ws-1')).toMatchObject({ status: 'complete', fileCount: 1 }); }); it('applies deletes before creates, ignores updates, and recounts once for subtree deletes', async () => { const store = new FakeStore(); - store.meta.set('ws-1', { status: 'complete', fileCount: 3, truncateReason: null }); - store.paths.set('ws-1', new Set(['changed.ts', 'dir/a.ts', 'old.ts'])); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 3, + truncateReason: null, + }); + store.paths.set('ws-1', new Set(['/repo/changed.ts', '/repo/dir/a.ts', '/repo/old.ts'])); const service = await createService(store); service.onWorkspaceFileChange('ws-1', { kind: 'changes', changes: [ - { kind: 'create', path: 'new.ts', entryType: 'file' }, - { kind: 'update', path: 'missing.ts', entryType: 'file' }, - { kind: 'delete', path: 'old.ts', entryType: 'file' }, - { kind: 'delete', path: 'dir', entryType: 'unknown' }, + { kind: 'create', path: '/repo/new.ts', entryType: 'file' }, + { kind: 'update', path: '/repo/missing.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/old.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/dir', entryType: 'unknown' }, ], }); - expect([...store.pathSet('ws-1')].sort()).toEqual(['changed.ts', 'new.ts']); + expect([...store.pathSet('ws-1')].sort()).toEqual(['/repo/changed.ts', '/repo/new.ts']); expect(store.operations).toEqual([ 'transaction', 'count:ws-1', - 'deletePath:old.ts', - 'deleteSubtree:dir', + 'deletePath:/repo/old.ts', + 'deleteSubtree:/repo/dir', 'count:ws-1', - 'insert:new.ts', + 'insert:/repo/new.ts', 'count:ws-1', 'record:complete:2', ]); @@ -112,38 +156,48 @@ describe('WorkspaceFileIndexService', () => { it('marks the index stale when creates would exceed the cap', async () => { vi.useFakeTimers(); const store = new FakeStore(); - store.meta.set('ws-1', { status: 'complete', fileCount: 2, truncateReason: null }); - store.paths.set('ws-1', new Set(['a.ts', 'b.ts'])); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 2, + truncateReason: null, + }); + store.paths.set('ws-1', new Set(['/repo/a.ts', '/repo/b.ts'])); const service = await createService(store, { maxFiles: 2, reindexDebounceMs: 1_000 }); await service.onWorkspaceActivated('ws-1', { rootPath: '/repo', - filesRuntime: filesRuntime(() => ['a.ts', 'b.ts', 'c.ts']), + filesRuntime: filesRuntime(() => ['/repo/a.ts', '/repo/b.ts', '/repo/c.ts']), }); service.onWorkspaceFileChange('ws-1', { kind: 'changes', changes: [ - { kind: 'delete', path: 'missing.ts', entryType: 'file' }, - { kind: 'create', path: 'c.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/missing.ts', entryType: 'file' }, + { kind: 'create', path: '/repo/c.ts', entryType: 'file' }, ], }); - expect([...store.pathSet('ws-1')].sort()).toEqual(['a.ts', 'b.ts']); + expect([...store.pathSet('ws-1')].sort()).toEqual(['/repo/a.ts', '/repo/b.ts']); expect(store.meta.get('ws-1')).toMatchObject({ status: 'stale', fileCount: 2 }); }); it('ignores incremental changes while the current index is truncated', async () => { const store = new FakeStore(); - store.meta.set('ws-1', { status: 'truncated', fileCount: 2, truncateReason: 'maxEntries' }); - store.paths.set('ws-1', new Set(['a.ts', 'b.ts'])); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'truncated', + fileCount: 2, + truncateReason: 'maxEntries', + }); + store.paths.set('ws-1', new Set(['/repo/a.ts', '/repo/b.ts'])); const service = await createService(store); service.onWorkspaceFileChange('ws-1', { kind: 'changes', - changes: [{ kind: 'create', path: 'c.ts', entryType: 'file' }], + changes: [{ kind: 'create', path: '/repo/c.ts', entryType: 'file' }], }); - expect([...store.pathSet('ws-1')].sort()).toEqual(['a.ts', 'b.ts']); + expect([...store.pathSet('ws-1')].sort()).toEqual(['/repo/a.ts', '/repo/b.ts']); expect(store.operations).toEqual([]); }); }); @@ -157,6 +211,17 @@ async function createService( } function filesRuntime(readPaths: () => readonly string[]): IFilesRuntime { + const fileSystem = { + enumerate: () => ({ + success: true as const, + data: (async function* () { + for (const path of readPaths()) { + yield path; + } + })(), + }), + } as unknown as IFileSystem; + return { openTree: async () => { throw new Error('openTree is not used by WorkspaceFileIndexService tests'); @@ -164,14 +229,7 @@ function filesRuntime(readPaths: () => readonly string[]): IFilesRuntime { watchChanges: () => { throw new Error('watchChanges is not used by WorkspaceFileIndexService tests'); }, - enumerate: () => ({ - success: true, - data: (async function* () { - for (const path of readPaths()) { - yield path as RelPath; - } - })(), - }), + fileSystem: () => ({ success: true, data: fileSystem }), dispose: async () => {}, }; } @@ -201,7 +259,7 @@ class FakeStore implements IWorkspaceFileIndexStore { this.operations.push(`refresh:${workspaceId}`); } - syncRows(workspaceId: string, paths: RelPath[]): void { + syncRows(workspaceId: string, paths: string[]): void { this.operations.push(`sync:${paths.join(',')}`); this.paths.set(workspaceId, new Set(paths)); } diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts index 6f97c018d6..175e080555 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts @@ -70,6 +70,12 @@ export class WorkspaceFileIndexService { this.activeSources.set(workspaceId, source); const meta = this.store.getMeta(workspaceId); + if (meta && meta.rootPath !== source.rootPath) { + this.store.deleteIndex(workspaceId); + await this.reindex(workspaceId); + return; + } + if (meta?.status === 'complete') { this.store.refreshMetaTimestamp(workspaceId); return; @@ -108,7 +114,16 @@ export class WorkspaceFileIndexService { const source = this.activeSources.get(workspaceId); if (!source) return; - const enumeration = source.filesRuntime.enumerate(source.rootPath); + const fileSystem = source.filesRuntime.fileSystem(); + if (!fileSystem.success) { + log.warn('WorkspaceFileIndexService: failed to open filesystem', { + workspaceId, + error: fileSystem.error, + }); + return; + } + + const enumeration = fileSystem.data.enumerate(source.rootPath); if (!enumeration.success) { log.warn('WorkspaceFileIndexService: enumerate failed to start', { workspaceId, @@ -122,11 +137,12 @@ export class WorkspaceFileIndexService { timeoutMs: this.reindexTimeoutMs, now: this.options.now, }); - if (!this.activeSources.has(workspaceId)) return; + if (this.activeSources.get(workspaceId) !== source) return; this.store.transaction(() => { this.store.syncRows(workspaceId, result.paths); this.store.recordMeta(workspaceId, { + rootPath: source.rootPath, status: result.truncated ? 'truncated' : 'complete', fileCount: result.paths.length, truncateReason: result.truncateReason ?? null, @@ -207,6 +223,7 @@ export class WorkspaceFileIndexService { } this.store.recordMeta(workspaceId, { + rootPath: this.metaRootPath(workspaceId), status: 'complete', fileCount: this.store.countIndexedFiles(workspaceId), truncateReason: null, @@ -243,6 +260,7 @@ export class WorkspaceFileIndexService { private markStale(workspaceId: string): void { try { this.store.recordMeta(workspaceId, { + rootPath: this.metaRootPath(workspaceId), status: 'stale', fileCount: this.store.countIndexedFiles(workspaceId), truncateReason: null, @@ -263,6 +281,14 @@ export class WorkspaceFileIndexService { private get reindexDebounceMs(): number { return this.options.reindexDebounceMs ?? DEFAULT_REINDEX_DEBOUNCE_MS; } + + private metaRootPath(workspaceId: string): string { + return ( + this.activeSources.get(workspaceId)?.rootPath ?? + this.store.getMeta(workspaceId)?.rootPath ?? + '' + ); + } } export const workspaceFileIndexService = new WorkspaceFileIndexService({ diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts index d3da924796..1b691f12c3 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts @@ -1,7 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { RelPath } from '@emdash/core/files'; import type BetterSqlite3 from 'better-sqlite3'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -25,12 +24,14 @@ describe('WorkspaceFileIndexStore', () => { const { store } = loadedStore; store.recordMeta('ws-1', { + rootPath: '/repo', status: 'truncated', fileCount: 50_000, truncateReason: 'maxEntries', }); expect(store.getMeta('ws-1')).toEqual({ + rootPath: '/repo', status: 'truncated', fileCount: 50_000, truncateReason: 'maxEntries', @@ -41,10 +42,10 @@ describe('WorkspaceFileIndexStore', () => { loadedStore = await loadStore(); const { store, sqlite } = loadedStore; - store.syncRows('ws-1', relPaths(['a.ts', 'b.ts'])); - store.syncRows('ws-1', relPaths(['b.ts', 'c.ts'])); + store.syncRows('ws-1', paths(['/repo/a.ts', '/repo/b.ts'])); + store.syncRows('ws-1', paths(['/repo/b.ts', '/repo/c.ts'])); - expect(indexedPaths(sqlite, 'ws-1')).toEqual(['b.ts', 'c.ts']); + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['/repo/b.ts', '/repo/c.ts']); expect(store.countIndexedFiles('ws-1')).toBe(2); }); @@ -52,10 +53,10 @@ describe('WorkspaceFileIndexStore', () => { loadedStore = await loadStore(); const { store, sqlite } = loadedStore; - expect(store.insertPath('ws-1', 'src/index.ts')).toBe(true); - expect(store.insertPath('ws-1', 'src/index.ts')).toBe(false); + expect(store.insertPath('ws-1', '/repo/src/index.ts')).toBe(true); + expect(store.insertPath('ws-1', '/repo/src/index.ts')).toBe(false); - expect(indexedPaths(sqlite, 'ws-1')).toEqual(['src/index.ts']); + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['/repo/src/index.ts']); expect(store.countIndexedFiles('ws-1')).toBe(1); }); @@ -63,28 +64,38 @@ describe('WorkspaceFileIndexStore', () => { loadedStore = await loadStore(); const { store } = loadedStore; - store.syncRows('ws-1', relPaths(['README.md', 'src/index.ts', 'src/router.ts'])); + store.syncRows('ws-1', paths(['/repo/README.md', '/repo/src/index.ts', '/repo/src/router.ts'])); - expect(store.search('ws-1', 'index')).toEqual([{ path: 'src/index.ts', filename: 'index.ts' }]); + expect(store.search('ws-1', 'index')).toEqual([ + { path: '/repo/src/index.ts', filename: 'index.ts' }, + ]); expect(store.search('ws-1', 'in')).toEqual([]); }); it('deletes exact paths and escaped subtrees', async () => { loadedStore = await loadStore(); const { store, sqlite } = loadedStore; - store.syncRows('ws-1', relPaths(['foo_%', 'foo_%/a.ts', 'foo_%/nested/b.ts', 'foo-x/a.ts'])); + store.syncRows( + 'ws-1', + paths(['/repo/foo_%', '/repo/foo_%/a.ts', '/repo/foo_%/nested/b.ts', '/repo/foo-x/a.ts']) + ); - store.deletePath('ws-1', 'foo_%/a.ts'); - store.deleteSubtree('ws-1', 'foo_%'); + store.deletePath('ws-1', '/repo/foo_%/a.ts'); + store.deleteSubtree('ws-1', '/repo/foo_%'); - expect(indexedPaths(sqlite, 'ws-1')).toEqual(['foo-x/a.ts']); + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['/repo/foo-x/a.ts']); }); it('deletes an entire workspace index', async () => { loadedStore = await loadStore(); const { store, sqlite } = loadedStore; - store.syncRows('ws-1', relPaths(['a.ts'])); - store.recordMeta('ws-1', { status: 'complete', fileCount: 1, truncateReason: null }); + store.syncRows('ws-1', paths(['/repo/a.ts'])); + store.recordMeta('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); store.deleteIndex('ws-1'); @@ -97,12 +108,27 @@ describe('WorkspaceFileIndexStore', () => { const { store, sqlite } = loadedStore; sqlite.prepare(`INSERT INTO workspaces (id) VALUES (?)`).run('fresh'); - store.syncRows('stale', relPaths(['stale.ts'])); - store.recordMeta('stale', { status: 'complete', fileCount: 1, truncateReason: null }); - store.syncRows('orphan', relPaths(['orphan.ts'])); - store.recordMeta('orphan', { status: 'complete', fileCount: 1, truncateReason: null }); - store.syncRows('fresh', relPaths(['fresh.ts'])); - store.recordMeta('fresh', { status: 'complete', fileCount: 1, truncateReason: null }); + store.syncRows('stale', paths(['/repo/stale.ts'])); + store.recordMeta('stale', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + store.syncRows('orphan', paths(['/repo/orphan.ts'])); + store.recordMeta('orphan', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + store.syncRows('fresh', paths(['/repo/fresh.ts'])); + store.recordMeta('fresh', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); sqlite .prepare(`UPDATE workspace_file_index_meta SET indexed_at = ? WHERE workspace_id = ?`) .run(Math.floor(Date.now() / 1000) - 15 * 86400, 'stale'); @@ -110,7 +136,7 @@ describe('WorkspaceFileIndexStore', () => { store.evict(14); expect(allIndexedWorkspaces(sqlite)).toEqual(['fresh']); - expect(indexedPaths(sqlite, 'fresh')).toEqual(['fresh.ts']); + expect(indexedPaths(sqlite, 'fresh')).toEqual(['/repo/fresh.ts']); }); }); @@ -143,6 +169,7 @@ function createTables(sqlite: BetterSqlite3.Database): void { CREATE TABLE workspace_file_index_meta ( workspace_id TEXT PRIMARY KEY, indexed_at INTEGER NOT NULL, + root_path TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('complete', 'stale', 'truncated')), file_count INTEGER NOT NULL, @@ -155,8 +182,8 @@ function createTables(sqlite: BetterSqlite3.Database): void { `); } -function relPaths(paths: string[]): RelPath[] { - return paths as RelPath[]; +function paths(values: string[]): string[] { + return values; } function indexedPaths(sqlite: BetterSqlite3.Database, workspaceId: string): string[] { diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts index 311302bd4f..12062a4822 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts @@ -1,5 +1,4 @@ import { basename } from 'node:path'; -import type { RelPath } from '@emdash/core/files'; import { sqlite } from '@main/db/client'; import { log } from '@main/lib/logger'; @@ -7,6 +6,7 @@ export type FileHit = { path: string; filename: string }; export type FileIndexStatus = 'complete' | 'stale' | 'truncated'; export type FileIndexTruncateReason = 'maxEntries' | 'timeBudget'; export type FileIndexMeta = { + rootPath: string; status: FileIndexStatus; fileCount: number; truncateReason: FileIndexTruncateReason | null; @@ -17,7 +17,7 @@ export interface IWorkspaceFileIndexStore { getMeta(workspaceId: string): FileIndexMeta | null; recordMeta(workspaceId: string, meta: FileIndexMeta): void; refreshMetaTimestamp(workspaceId: string): void; - syncRows(workspaceId: string, paths: RelPath[]): void; + syncRows(workspaceId: string, paths: string[]): void; insertPath(workspaceId: string, path: string): boolean; deletePath(workspaceId: string, path: string): boolean; deleteSubtree(workspaceId: string, path: string): void; @@ -36,16 +36,17 @@ export class WorkspaceFileIndexStore implements IWorkspaceFileIndexStore { try { const row = sqlite .prepare( - `SELECT status, file_count, truncate_reason + `SELECT root_path, status, file_count, truncate_reason FROM workspace_file_index_meta WHERE workspace_id = ?` ) .get(workspaceId) as - | { status: string; file_count: number; truncate_reason: string | null } + | { root_path: string; status: string; file_count: number; truncate_reason: string | null } | undefined; if (!row || !isFileIndexStatus(row.status)) return null; return { + rootPath: row.root_path, status: row.status, fileCount: row.file_count, truncateReason: isFileIndexTruncateReason(row.truncate_reason) ? row.truncate_reason : null, @@ -62,13 +63,14 @@ export class WorkspaceFileIndexStore implements IWorkspaceFileIndexStore { `INSERT OR REPLACE INTO workspace_file_index_meta ( workspace_id, indexed_at, + root_path, status, file_count, truncate_reason ) - VALUES (?, unixepoch(), ?, ?, ?)` + VALUES (?, unixepoch(), ?, ?, ?, ?)` ) - .run(workspaceId, meta.status, meta.fileCount, meta.truncateReason); + .run(workspaceId, meta.rootPath, meta.status, meta.fileCount, meta.truncateReason); } refreshMetaTimestamp(workspaceId: string): void { @@ -88,7 +90,7 @@ export class WorkspaceFileIndexStore implements IWorkspaceFileIndexStore { } } - syncRows(workspaceId: string, paths: RelPath[]): void { + syncRows(workspaceId: string, paths: string[]): void { const existingPaths = this.indexedPathSet(workspaceId); const desiredPaths = new Set(paths); const deletePath = sqlite.prepare( diff --git a/apps/emdash-desktop/src/main/db/initialize.ts b/apps/emdash-desktop/src/main/db/initialize.ts index 48fe415844..5bfb3ba1bc 100644 --- a/apps/emdash-desktop/src/main/db/initialize.ts +++ b/apps/emdash-desktop/src/main/db/initialize.ts @@ -91,7 +91,7 @@ function ensureSearchIndex(connection: BetterSqlite3.Database): void { * changes without a full Drizzle migration. */ function ensureFileIndex(connection: BetterSqlite3.Database): void { - const FILE_INDEX_VERSION = '2'; + const FILE_INDEX_VERSION = '3'; const row = connection.prepare(`SELECT value FROM kv WHERE key = 'file_index_version'`).get() as | { value: string } @@ -113,6 +113,7 @@ function ensureFileIndex(connection: BetterSqlite3.Database): void { CREATE TABLE workspace_file_index_meta ( workspace_id TEXT PRIMARY KEY, indexed_at INTEGER NOT NULL, + root_path TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('complete', 'stale', 'truncated')), file_count INTEGER NOT NULL, diff --git a/apps/emdash-desktop/tooling/fixtures/baseline.db b/apps/emdash-desktop/tooling/fixtures/baseline.db index 6ac5a6c26646b2ab947db3258845a6d404d6e084..4d0e3bab97c1e5c11724134910813e22ae819c34 100644 GIT binary patch delta 914 zcmZozAl9%zY=dIFtdW_5fw7gLsg;3&k%5t!u7Q~@LTIvG{1W^sHvf-*W-o1=W&3W6 z2qTL!KTBFkaeP^7QE_H|9$0kKf|PpvMoe#%XH3MeWcz=4#-8)|)l6UUp3zDU;dU(E zF<5j!VbKMJMGgXO4ouq|m<4)(KE~sHEJjRsIKzC2bX`^Fm`$daon^j4vexZDt$d6m zYnu+#X3_rPJoEMs=UGH2D6;Nj;E(2W<~_&j!n2ddj{7**d#*&z{hS&cQ#e>y_pz_s zY$z~;m8&_Um|fi7p0O={`bPs6sp%r=tWnGg3JTM6(^=K(feZytzYte<*B}Ky{}2Vg zP#+&1E|92#LQ#HxNqj+KNd|pt}J{kq=mvXE4~`3jRR~NOBM}NM|+B001}k@4x^6 delta 606 zcmZozAl9%zY=dIFtf7H|fq|8gsgGt8Gr*Hv|n*<^azS>`JwYuygi%Ew5u zw&_4^7VRI-GjIQJo<($mBHITB{%AgD-gCSzJUe;pxQ}za=St+<&#A#Ng@c9d1N+L& zh61P9xEjrh*~RVc85^soe>7l`n*J!2HEOy;8mp>|r(cMxyK9hwpMQvgU#O1{mjV!I b29~gir`k_vjAN9TzQ&Y=bNhvKRs#(H>wB39 diff --git a/apps/emdash-desktop/tooling/fixtures/empty.db b/apps/emdash-desktop/tooling/fixtures/empty.db index c4a19c793d63b057f56f8e8500c41ad4e6a598c1..c13366e273d433a515212338c2b9f3bfe23d8058 100644 GIT binary patch delta 471 zcmZozAl9%zY{S#}(#Bb~@3x3AvMBSjq?HuMm!%dJXXfXDMVl71EnsY0z|^*YdD{XO z)&q*H`xyA6`J8#r@w)KroYo%wXke&M0OVx3_0( zi=Y0{fJJJ$NIGj2vx0)c^xSk-wR#{!!P76q)!j8n!OuTL!7tRuM~4d}s-RGmpI;JR zkXVud;VLMExJCd~ps6Y@Nh~QXhA9EkD5{`FDJVF5xH@|)Xh2na`YC9rC+FwZ7v!Xt zq^j#Er~@T(z>Jcj(!AuvlGGG+O--0f(lT>WiHSi&Zr WYCoMZj!|Oz8dDa|?HAHn4Kx6gu!$1@ delta 163 zcmZozAl9%zY{S#}(uP?Y>-j_&S(N!%(n^Zs%TkMqGxPI|L846y+7>XjEnsR}z`Sh% z3+n+zwhs*a(R|Ll=XhOscJkP9ALn|{mB_iDQ-fm)2MgN=_LZ9r1x~SXHJTN(i`&~X zHdasnXuu*h{ZT4w)O3e5R#h2KzYte<*B}Ky{}2VgP#+&I1t8E2EMXH*wV%!y$0#v< OjVTM~_6zB(1{wf8sWQ6& From 6fe0b8ba54c60cfc79f5db459e3908e6582f49be Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:14:22 -0700 Subject: [PATCH 27/37] feat(terminals): use absolute workspace file paths --- .../src/main/core/pty/controller.test.ts | 54 ++++++++++++++++++- .../src/main/core/pty/controller.ts | 23 ++++---- .../terminals/lifecycle-script-settings.ts | 26 ++++++--- .../core/terminals/prepareLifecycleScript.ts | 11 ++-- .../core/terminals/runLifecycleScript.test.ts | 9 +++- .../main/core/terminals/runLifecycleScript.ts | 15 ++++-- .../tasks/stores/lifecycle-scripts.test.ts | 23 ++++++-- .../tasks/stores/lifecycle-scripts.ts | 12 +++-- .../src/renderer/lib/drag-files.ts | 16 ++---- .../src/renderer/lib/pty/pty-session.test.ts | 12 +++++ .../src/renderer/lib/pty/pty-session.ts | 7 ++- .../src/renderer/tests/drag-files.test.ts | 21 ++------ 12 files changed, 160 insertions(+), 69 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/pty/controller.test.ts b/apps/emdash-desktop/src/main/core/pty/controller.test.ts index d164e9be27..f0416a2197 100644 --- a/apps/emdash-desktop/src/main/core/pty/controller.test.ts +++ b/apps/emdash-desktop/src/main/core/pty/controller.test.ts @@ -1,7 +1,27 @@ +import type * as nodeCrypto from 'node:crypto'; +import type * as fsPromises from 'node:fs/promises'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { conversationEvents } from '@main/core/conversations/conversation-events'; import { ptySessionRegistry } from './pty-session-registry'; +const mocks = vi.hoisted(() => ({ + getTask: vi.fn(), + getWorkspace: vi.fn(), + getWorkspaceId: vi.fn(), + randomUUID: vi.fn(), + readFile: vi.fn(), +})); + +vi.mock('node:crypto', async (importActual) => { + const actual = await importActual(); + return { ...actual, randomUUID: mocks.randomUUID }; +}); + +vi.mock('node:fs/promises', async (importActual) => { + const actual = await importActual(); + return { ...actual, readFile: mocks.readFile }; +}); + vi.mock('./persist-dropped-blob', () => ({ cleanupExpiredDroppedBlobs: vi.fn().mockResolvedValue(undefined), persistClipboardImagePath: vi.fn(), @@ -20,11 +40,16 @@ vi.mock('@main/lib/events', () => ({ })); vi.mock('../tasks/task-session-manager', () => ({ - taskSessionManager: {}, + taskSessionManager: { + getTask: mocks.getTask, + getWorkspaceId: mocks.getWorkspaceId, + }, })); vi.mock('../workspaces/workspace-registry', () => ({ - workspaceRegistry: {}, + workspaceRegistry: { + get: mocks.getWorkspace, + }, })); const emitSpy = vi.spyOn(conversationEvents, '_emit'); @@ -44,6 +69,7 @@ function makePty(write = vi.fn()) { describe('ptyController', () => { beforeEach(() => { emitSpy.mockClear(); + vi.clearAllMocks(); }); it('emits input-submitted for remote agent PTYs on enter', () => { @@ -66,4 +92,28 @@ describe('ptyController', () => { ptySessionRegistry.unregister(sessionId); }); + + it('uploads files to absolute workspace paths', async () => { + const bytes = Buffer.from('content'); + const writeBytes = vi.fn().mockResolvedValue({ success: true }); + mocks.randomUUID.mockReturnValue('upload-id'); + mocks.readFile.mockResolvedValue(bytes); + mocks.getTask.mockReturnValue({}); + mocks.getWorkspaceId.mockReturnValue('workspace-1'); + mocks.getWorkspace.mockReturnValue({ + path: '/remote/repo', + fileSystem: { writeBytes }, + }); + + const result = await ptyController.uploadFiles({ + sessionId: 'project-1:task-1:terminal-1', + localPaths: ['/tmp/screenshot.png'], + }); + + expect(result).toEqual({ + success: true, + data: { remotePaths: ['/remote/repo/upload-id-screenshot.png'] }, + }); + expect(writeBytes).toHaveBeenCalledWith('/remote/repo/upload-id-screenshot.png', bytes); + }); }); diff --git a/apps/emdash-desktop/src/main/core/pty/controller.ts b/apps/emdash-desktop/src/main/core/pty/controller.ts index 1265dc6b59..678afd8a61 100644 --- a/apps/emdash-desktop/src/main/core/pty/controller.ts +++ b/apps/emdash-desktop/src/main/core/pty/controller.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; import { err, ok } from '@emdash/shared'; import { conversationEvents } from '@main/core/conversations/conversation-events'; +import { joinMachinePath } from '@main/core/files/path-utils'; import { log } from '@main/lib/logger'; import { parsePtySessionId } from '@shared/core/pty/ptySessionId'; import { createRPCController } from '@shared/lib/ipc/rpc'; @@ -153,15 +155,18 @@ export const ptyController = createRPCController({ const workspaceId = taskSessionManager.getWorkspaceId(scopeId) ?? ''; const workspace = workspaceRegistry.get(workspaceId); - if (!workspace?.fs.copyLocalFile) return err({ type: 'not_ssh' as const }); - - const remotePaths = await Promise.all( - args.localPaths.map(async (localPath) => { - const remoteName = `${randomUUID()}-${basename(localPath)}`; - await workspace.fs.copyLocalFile!(localPath, remoteName); - return `${workspace.path}/${remoteName}`; - }) - ); + if (!workspace) return err({ type: 'not_ssh' as const }); + const remotePaths: string[] = []; + for (const localPath of args.localPaths) { + const remoteName = `${randomUUID()}-${basename(localPath)}`; + const remotePath = joinMachinePath(workspace.path, remoteName); + const bytes = await readFile(localPath); + const written = await workspace.fileSystem.writeBytes(remotePath, bytes); + if (!written.success) { + return err({ type: 'upload_failed' as const, message: written.error.message }); + } + remotePaths.push(remotePath); + } return ok({ remotePaths }); } catch (e: unknown) { log.error('pty:uploadFiles failed', { diff --git a/apps/emdash-desktop/src/main/core/terminals/lifecycle-script-settings.ts b/apps/emdash-desktop/src/main/core/terminals/lifecycle-script-settings.ts index 56c51c1506..a925ac8a3e 100644 --- a/apps/emdash-desktop/src/main/core/terminals/lifecycle-script-settings.ts +++ b/apps/emdash-desktop/src/main/core/terminals/lifecycle-script-settings.ts @@ -1,8 +1,13 @@ +import { err, ok, type Result } from '@emdash/shared'; import type { Workspace } from '@main/core/workspaces/workspace'; import type { LifecycleScriptType } from '@shared/core/tasks/taskEvents'; import { getEffectiveTaskSettings } from '../projects/settings/effective-task-settings'; import { resolveWorkspace } from '../projects/utils'; +export type LifecycleScriptSettingsError = + | { type: 'not_found'; entity: 'workspace'; workspaceId: string } + | { type: 'fs_error'; message: string }; + /** * Reads the effective lifecycle script config for an already-resolved workspace. * This is used by callers that already have a Workspace, such as workspace setup/teardown hooks. @@ -10,15 +15,16 @@ import { resolveWorkspace } from '../projects/utils'; export async function resolveLifecycleScriptForWorkspace( workspace: Workspace, type: LifecycleScriptType -): Promise<{ script?: string; shellSetup?: string }> { +): Promise> { const settings = await getEffectiveTaskSettings({ projectSettings: workspace.settings, - taskFs: workspace.fs, + taskFs: workspace.fileSystem, + taskConfigPath: workspace.configPath, }); - return { + return ok({ script: settings.scripts?.[type], shellSetup: settings.shellSetup, - }; + }); } /** @@ -33,10 +39,16 @@ export async function resolveLifecycleScript({ projectId: string; workspaceId: string; type: LifecycleScriptType; -}): Promise<{ workspace: Workspace; script?: string; shellSetup?: string }> { +}): Promise< + Result< + { workspace: Workspace; script?: string; shellSetup?: string }, + LifecycleScriptSettingsError + > +> { const workspace = resolveWorkspace(projectId, workspaceId); - if (!workspace) throw new Error('Workspace not found'); + if (!workspace) return err({ type: 'not_found', entity: 'workspace', workspaceId }); const settings = await resolveLifecycleScriptForWorkspace(workspace, type); - return { workspace, ...settings }; + if (!settings.success) return settings; + return ok({ workspace, ...settings.data }); } diff --git a/apps/emdash-desktop/src/main/core/terminals/prepareLifecycleScript.ts b/apps/emdash-desktop/src/main/core/terminals/prepareLifecycleScript.ts index e4e2a68ea0..1b4d146fc5 100644 --- a/apps/emdash-desktop/src/main/core/terminals/prepareLifecycleScript.ts +++ b/apps/emdash-desktop/src/main/core/terminals/prepareLifecycleScript.ts @@ -1,4 +1,6 @@ +import { ok, type Result } from '@emdash/shared'; import { resolveLifecycleScript } from './lifecycle-script-settings'; +import type { LifecycleScriptSettingsError } from './lifecycle-script-settings'; export async function prepareLifecycleScript({ projectId, @@ -8,17 +10,20 @@ export async function prepareLifecycleScript({ projectId: string; workspaceId: string; type: 'setup' | 'run' | 'teardown'; -}): Promise { - const { workspace, script, shellSetup } = await resolveLifecycleScript({ +}): Promise> { + const resolved = await resolveLifecycleScript({ projectId, workspaceId, type, }); - if (!script) return; + if (!resolved.success) return resolved; + const { workspace, script, shellSetup } = resolved.data; + if (!script) return ok(); await workspace.lifecycleService.prepareLifecycleScript({ type, script, shellSetup, }); + return ok(); } diff --git a/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.test.ts b/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.test.ts index 8a8dfde1f9..7ed19ade3f 100644 --- a/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.test.ts @@ -36,8 +36,12 @@ describe('runLifecycleScript', () => { it('runs manual lifecycle scripts with exit and restores the prompt afterward', async () => { const lifecycleRun = vi.fn(async () => {}); vi.mocked(resolveWorkspace).mockReturnValue({ + path: '/workspace', settings: {}, - fs: {}, + files: { + fileSystem: () => ({ success: true, data: {} }), + path: { join: (...parts: string[]) => parts.join('/') }, + }, lifecycleService: { runLifecycleScript: lifecycleRun, }, @@ -49,13 +53,14 @@ describe('runLifecycleScript', () => { }, } as never); - await runLifecycleScript({ + const result = await runLifecycleScript({ projectId: 'project-1', taskId: 'task-1', workspaceId: 'branch:feature', type: 'run', }); + expect(result).toEqual({ success: true, data: undefined }); expect(lifecycleRun).toHaveBeenCalledWith( { type: 'run', script: 'pnpm dev', shellSetup: 'source .envrc' }, { exit: true, waitForExit: true, respawnAfterExit: true } diff --git a/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.ts b/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.ts index 9bddde20b0..1ba1c72647 100644 --- a/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.ts +++ b/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.ts @@ -1,5 +1,9 @@ +import { ok, type Result } from '@emdash/shared'; import { runLifecycleScriptWithPolicy } from './lifecycle-script-coordinator'; -import { resolveLifecycleScript } from './lifecycle-script-settings'; +import { + resolveLifecycleScript, + type LifecycleScriptSettingsError, +} from './lifecycle-script-settings'; export async function runLifecycleScript({ projectId, @@ -11,13 +15,15 @@ export async function runLifecycleScript({ taskId: string; workspaceId: string; type: 'setup' | 'run' | 'teardown'; -}) { - const { workspace, script, shellSetup } = await resolveLifecycleScript({ +}): Promise> { + const resolved = await resolveLifecycleScript({ projectId, workspaceId, type, }); - if (!script) return; + if (!resolved.success) return resolved; + const { workspace, script, shellSetup } = resolved.data; + if (!script) return ok(); await runLifecycleScriptWithPolicy({ workspace, projectId, @@ -35,4 +41,5 @@ export async function runLifecycleScript({ }, logPrefix: 'TerminalsController', }); + return ok(); } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.test.ts index 109c6b50d8..005fc867c8 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.test.ts @@ -1,3 +1,4 @@ +import { ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fileChangesChannel } from '@shared/core/fs/fsEvents'; import { projectSettingsChangedChannel } from '@shared/core/projects/projectEvents'; @@ -100,8 +101,8 @@ describe('LifecycleScriptsStore', () => { it('uses stable script IDs and reconciles command changes from .emdash.json watch events', async () => { getSettings - .mockResolvedValueOnce({ scripts: { run: 'pnpm dev' } }) - .mockResolvedValueOnce({ scripts: { run: 'pnpm start' } }); + .mockResolvedValueOnce(ok({ scripts: { run: 'pnpm dev' } })) + .mockResolvedValueOnce(ok({ scripts: { run: 'pnpm start' } })); const store = new LifecycleScriptsStore('project-1', 'workspace-1'); await (store as unknown as { load(): Promise }).load(); @@ -127,8 +128,8 @@ describe('LifecycleScriptsStore', () => { it('reloads lifecycle scripts when project settings change', async () => { getSettings - .mockResolvedValueOnce({ scripts: { setup: 'pnpm install' } }) - .mockResolvedValueOnce({ scripts: { setup: 'corepack install', run: 'pnpm dev' } }); + .mockResolvedValueOnce(ok({ scripts: { setup: 'pnpm install' } })) + .mockResolvedValueOnce(ok({ scripts: { setup: 'corepack install', run: 'pnpm dev' } })); const store = new LifecycleScriptsStore('project-1', 'workspace-1'); await (store as unknown as { load(): Promise }).load(); @@ -151,9 +152,21 @@ describe('LifecycleScriptsStore', () => { const loadPromise = (store as unknown as { load(): Promise }).load(); store.dispose(); - resolveSettings({ scripts: { run: 'pnpm dev' } }); + resolveSettings(ok({ scripts: { run: 'pnpm dev' } })); await loadPromise; expect(store.tabs).toEqual([]); }); + + it('keeps lifecycle script tabs empty when settings fail to load', async () => { + getSettings.mockResolvedValue({ + success: false, + error: { type: 'fs_error', message: 'filesystem unavailable' }, + }); + const store = new LifecycleScriptsStore('project-1', 'workspace-1'); + + await (store as unknown as { load(): Promise }).load(); + + expect(store.tabs).toEqual([]); + }); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.ts index 63cf3958f2..fe64630d40 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/lifecycle-scripts.ts @@ -40,12 +40,14 @@ export class LifecycleScriptStore { this.data = data; this.session = new PtySession( makePtySessionId(projectId, workspaceId, data.id), - () => - rpc.terminals.prepareLifecycleScript({ + async () => { + const result = await rpc.terminals.prepareLifecycleScript({ projectId, workspaceId, type: data.type, - }), + }); + return result.success ? undefined : false; + }, undefined, undefined ); @@ -184,8 +186,10 @@ export class LifecycleScriptsStore implements TabViewProvider { if (this._disposed) return; const refreshSeq = ++this._refreshSeq; - const settings = await rpc.projectSettings.getSettings(this.workspaceId); + const result = await rpc.projectSettings.getSettings(this.workspaceId); if (this._disposed) return; + if (!result.success) return; + const settings = result.data; const entries: { type: ScriptType; command: string; label: string }[] = []; if (settings.scripts?.setup) { diff --git a/apps/emdash-desktop/src/renderer/lib/drag-files.ts b/apps/emdash-desktop/src/renderer/lib/drag-files.ts index a879c3ec0b..384f8fa584 100644 --- a/apps/emdash-desktop/src/renderer/lib/drag-files.ts +++ b/apps/emdash-desktop/src/renderer/lib/drag-files.ts @@ -17,7 +17,6 @@ export const WORKSPACE_FILE_DRAG_TYPE = 'application/x-emdash-workspace-file'; export type DraggedWorkspaceFile = { workspaceId: string; - relPath: string; /** Absolute path in the workspace environment where the target agent runs. */ targetPath: string; /** Remote workspaces are Linux targets even when the renderer runs elsewhere. */ @@ -26,8 +25,8 @@ export type DraggedWorkspaceFile = { type DraggedWorkspaceFileInput = { workspaceId: string; - workspaceRootPath: string; - relPath: string; + /** Absolute path in the workspace environment where the target agent runs. */ + targetPath: string; targetPlatform?: NodeJS.Platform; }; @@ -38,21 +37,13 @@ type DraggedWorkspaceFileInput = { // accepted by an unrelated drop. let draggedWorkspaceFile: DraggedWorkspaceFile | null = null; -export function resolveWorkspaceFileTargetPath(rootPath: string, relPath: string): string { - const separator = rootPath.includes('\\') ? '\\' : '/'; - const normalizedRoot = rootPath.replace(/[\\/]+$/, ''); - const normalizedPath = relPath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, separator); - return `${normalizedRoot}${separator}${normalizedPath}`; -} - export function setDraggedWorkspaceFile( dataTransfer: DataTransfer, input: DraggedWorkspaceFileInput ): void { const payload: DraggedWorkspaceFile = { workspaceId: input.workspaceId, - relPath: input.relPath, - targetPath: resolveWorkspaceFileTargetPath(input.workspaceRootPath, input.relPath), + targetPath: input.targetPath, targetPlatform: input.targetPlatform, }; @@ -95,7 +86,6 @@ function isDraggedWorkspaceFile(value: unknown): value is DraggedWorkspaceFile { const candidate = value as Partial; return ( typeof candidate.workspaceId === 'string' && - typeof candidate.relPath === 'string' && typeof candidate.targetPath === 'string' && (candidate.targetPlatform === undefined || isNodePlatform(candidate.targetPlatform)) ); diff --git a/apps/emdash-desktop/src/renderer/lib/pty/pty-session.test.ts b/apps/emdash-desktop/src/renderer/lib/pty/pty-session.test.ts index aa5710cb7c..06ff9a055c 100644 --- a/apps/emdash-desktop/src/renderer/lib/pty/pty-session.test.ts +++ b/apps/emdash-desktop/src/renderer/lib/pty/pty-session.test.ts @@ -82,6 +82,18 @@ describe('PtySession', () => { expect(frontendDispose).toHaveBeenCalledTimes(1); }); + it('does not create a frontend PTY when prepare aborts connection', async () => { + const prepare = vi.fn(async () => false as const); + const session = new PtySession('session-1', prepare); + + await session.connect(); + + expect(prepare).toHaveBeenCalledTimes(1); + expect(frontendInstances).toEqual([]); + expect(frontendConnect).not.toHaveBeenCalled(); + expect(session.status).toBe('disconnected'); + }); + it('unsubscribes from backend start events only when destroyed', () => { const offPtyStarted = vi.fn(); vi.mocked(events.on).mockReturnValue(offPtyStarted); diff --git a/apps/emdash-desktop/src/renderer/lib/pty/pty-session.ts b/apps/emdash-desktop/src/renderer/lib/pty/pty-session.ts index 4e54250da0..ce9bf34661 100644 --- a/apps/emdash-desktop/src/renderer/lib/pty/pty-session.ts +++ b/apps/emdash-desktop/src/renderer/lib/pty/pty-session.ts @@ -9,6 +9,8 @@ export type PtySessionOptions = { clearOnBackendStart?: boolean; }; +type PtySessionPrepareResult = void | false; + export class PtySession { pty: FrontendPty | null = null; status: PtySessionStatus = 'disconnected'; @@ -20,7 +22,7 @@ export class PtySession { constructor( readonly sessionId: string, - private readonly prepare?: () => Promise, + private readonly prepare?: () => Promise, private readonly onOpenFile?: (filePath: string) => void, private readonly onOpenExternal?: (filePath: string) => void, options: PtySessionOptions = {} @@ -47,7 +49,8 @@ export class PtySession { const version = this.version; this.connectPromise = (async () => { - await this.prepare?.(); + const prepared = await this.prepare?.(); + if (prepared === false) return; if (version !== this.version) return; if (this.pty) return; const pty = new FrontendPty(this.sessionId, undefined, this.onOpenFile, this.onOpenExternal); diff --git a/apps/emdash-desktop/src/renderer/tests/drag-files.test.ts b/apps/emdash-desktop/src/renderer/tests/drag-files.test.ts index 48c0503d7a..02ed967d07 100644 --- a/apps/emdash-desktop/src/renderer/tests/drag-files.test.ts +++ b/apps/emdash-desktop/src/renderer/tests/drag-files.test.ts @@ -3,7 +3,6 @@ import { clearDraggedWorkspaceFile, getDraggedWorkspaceFile, hasDraggedWorkspaceFile, - resolveWorkspaceFileTargetPath, setDraggedWorkspaceFile, } from '@renderer/lib/drag-files'; @@ -25,29 +24,18 @@ function makeDataTransfer(): DataTransfer { } describe('drag-files', () => { - it('resolves workspace-relative paths into the workspace target path', () => { - expect(resolveWorkspaceFileTargetPath('/tmp/repo/', 'src/file name.ts')).toBe( - '/tmp/repo/src/file name.ts' - ); - expect(resolveWorkspaceFileTargetPath('C:\\repo\\', 'src/file name.ts')).toBe( - 'C:\\repo\\src\\file name.ts' - ); - }); - it('carries workspace file payloads for same-window drops', () => { const dataTransfer = makeDataTransfer(); setDraggedWorkspaceFile(dataTransfer, { workspaceId: 'workspace-1', - workspaceRootPath: '/remote/repo', - relPath: 'src/index.ts', + targetPath: '/remote/repo/src/index.ts', targetPlatform: 'linux', }); expect(hasDraggedWorkspaceFile(dataTransfer)).toBe(true); expect(getDraggedWorkspaceFile(dataTransfer)).toEqual({ workspaceId: 'workspace-1', - relPath: 'src/index.ts', targetPath: '/remote/repo/src/index.ts', targetPlatform: 'linux', }); @@ -58,8 +46,7 @@ describe('drag-files', () => { const sourceTransfer = makeDataTransfer(); setDraggedWorkspaceFile(sourceTransfer, { workspaceId: 'workspace-1', - workspaceRootPath: '/repo', - relPath: 'src/index.ts', + targetPath: '/repo/src/index.ts', }); const unrelatedTransfer = makeDataTransfer(); @@ -73,14 +60,12 @@ describe('drag-files', () => { const dataTransfer = makeDataTransfer(); setDraggedWorkspaceFile(dataTransfer, { workspaceId: 'workspace-1', - workspaceRootPath: '/repo', - relPath: 'src/index.ts', + targetPath: '/repo/src/index.ts', }); clearDraggedWorkspaceFile(); expect(getDraggedWorkspaceFile(dataTransfer)).toEqual({ workspaceId: 'workspace-1', - relPath: 'src/index.ts', targetPath: '/repo/src/index.ts', }); }); From 77895bb78a8197c4aa15550b2d23b73cf15ab11d Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:14:32 -0700 Subject: [PATCH 28/37] feat(git): normalize legacy file paths --- .../main/core/git/legacy/git-service.test.ts | 4 +-- .../src/main/core/git/legacy/git-service.ts | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/git/legacy/git-service.test.ts b/apps/emdash-desktop/src/main/core/git/legacy/git-service.test.ts index faaee8a1da..7a4ea0581c 100644 --- a/apps/emdash-desktop/src/main/core/git/legacy/git-service.test.ts +++ b/apps/emdash-desktop/src/main/core/git/legacy/git-service.test.ts @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; +import type { IFileSystem } from '@emdash/core/files'; import { computeBaseRef } from '@emdash/core/git'; import { describe, expect, it } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { GitService } from './git-service'; // --------------------------------------------------------------------------- @@ -45,7 +45,7 @@ const BRANCH_FORMAT = const OID = '1111111111111111111111111111111111111111'; const OID2 = '2222222222222222222222222222222222222222'; -const stubFs = {} as FileSystemProvider; +const stubFs = {} as IFileSystem; function makeContext(exec: MockExec, root = '/repo'): IExecutionContext { return { diff --git a/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts b/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts index dad1f19afc..41304cca1f 100644 --- a/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts +++ b/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; import { computeBaseRef, MAX_STATUS_FILES, @@ -29,7 +30,6 @@ import { import { err, ok, type Result } from '@emdash/shared'; import type { IDisposable } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { GIT_EXECUTABLE } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { @@ -131,7 +131,7 @@ export class GitService implements IDisposable { constructor( private readonly ctx: IExecutionContext, - private readonly fs: FileSystemProvider + private readonly fs: IFileSystem ) {} dispose(): void { @@ -281,9 +281,11 @@ export class GitService implements IDisposable { if (additions === 0 && deletions === 0 && code.includes('?')) { try { - const result = await this.fs.read(filePath, MAX_DIFF_CONTENT_BYTES); - if (!result.truncated) { - additions = (result.content.match(/\n/g) ?? []).length; + const result = await this.fs.readText(this.toFsPath(filePath), { + maxBytes: MAX_DIFF_CONTENT_BYTES, + }); + if (result.success && !result.data.truncated) { + additions = (result.data.content.match(/\n/g) ?? []).length; } } catch {} } @@ -371,12 +373,21 @@ export class GitService implements IDisposable { // Untracked files don't exist in git history — remove them from disk for (const filePath of untracked) { try { - const exists = await this.fs.exists(filePath); - if (exists) await this.fs.remove(filePath); + const fsPath = this.toFsPath(filePath); + const exists = await this.fs.exists(fsPath); + if (exists.success && exists.data) await this.fs.remove(fsPath); } catch {} } } + private toFsPath(filePath: string): string { + if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath)) return filePath; + const root = this.ctx.root; + return root + ? path.posix.join(root.replace(/\\/g, '/'), filePath.replace(/\\/g, '/')) + : filePath; + } + async revertAllFiles(): Promise { // Reset index and working tree for all tracked changes back to HEAD, // then remove any untracked files/directories. From 3a044697bd2712472d4fd842c2357ee784674673 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:14:51 -0700 Subject: [PATCH 29/37] feat(diff-view): use absolute file identities --- .../components/changes-list-item.tsx | 10 +++++++-- .../components/changes-tree-utils.test.ts | 21 +++++++++++++++++++ .../components/changes-tree-utils.ts | 19 ++++++++++++++--- .../components/virtualized-changes-list.tsx | 3 +++ .../components/virtualized-changes-tree.tsx | 4 +++- .../changes-panel/staged-section.tsx | 1 + .../changes-panel/unstaged-section.tsx | 1 + .../tasks/diff-view/diff-tab-provider.tsx | 10 +++++++-- .../diff-view/main-panel/image-diff-view.tsx | 2 +- 9 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-tree-utils.test.ts diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-list-item.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-list-item.tsx index 6a2a3fb2d7..b4dac64a0e 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-list-item.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-list-item.tsx @@ -6,17 +6,23 @@ import { FileIcon } from '@renderer/lib/editor/file-icon'; import { Checkbox } from '@renderer/lib/ui/checkbox'; import { formatDiffLineCount } from '@renderer/utils/format-diff-line-count'; import { cn } from '@renderer/utils/utils'; +import { displayPathForChange } from './changes-tree-utils'; interface ChangesListItemProps extends ButtonHTMLAttributes { change: GitChange; + rootPath?: string; isSelected?: boolean; isActive?: boolean; onToggleSelect?: (path: string) => void; } export const ChangesListItem = forwardRef( - ({ change, isSelected, isActive, onToggleSelect, className, ...props }, ref) => { - const { filename, directory } = useMemo(() => splitPath(change.path), [change.path]); + ({ change, rootPath, isSelected, isActive, onToggleSelect, className, ...props }, ref) => { + const displayPath = useMemo( + () => displayPathForChange(change.path, rootPath), + [change.path, rootPath] + ); + const { filename, directory } = useMemo(() => splitPath(displayPath), [displayPath]); return (