Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
94a156d
fix(file-tree): filter excluded paths in core updates
jschwxrz Jun 23, 2026
a8f523f
feat(live): notify collection mirror applied updates
jschwxrz Jun 23, 2026
1c04239
feat(file-tree): wire desktop runtime and rpc
jschwxrz Jun 23, 2026
0b9b47e
feat(file-tree): consume live collection in renderer
jschwxrz Jun 23, 2026
c5b2843
fix(file-tree): normalize ssh listed paths
jschwxrz Jun 23, 2026
6940574
perf(file-tree): stat listed children concurrently
jschwxrz Jun 23, 2026
81c31ee
fix(file-tree): load restored expanded directories
jschwxrz Jun 23, 2026
a274024
fix(file-tree): serialize tree mutations
jschwxrz Jun 24, 2026
ed2ad96
fix(file-tree): ignore symlinks consistently
jschwxrz Jun 24, 2026
ba5bee1
refactor(file-tree): centralize ignore and path safety
jschwxrz Jun 24, 2026
45ffa26
fix(markdown): resolve root-anchored workspace images
jschwxrz Jun 24, 2026
ca327d5
Merge remote-tracking branch 'origin/main' into feat-migrate-file-tree
jschwxrz Jun 24, 2026
af3f1a0
refactor(core): introduce files and watch domains
jschwxrz Jun 24, 2026
d5f4710
feat(ssh): add legacy files runtime adapter
jschwxrz Jun 24, 2026
eddfe9e
refactor(desktop): consume files runtime updates
jschwxrz Jun 24, 2026
678b60b
feat(core): add files enumeration API
jschwxrz Jun 25, 2026
f2191b1
feat(ssh): stream workspace file enumeration
jschwxrz Jun 25, 2026
cbf6fa4
feat(search): index workspace files through files runtime
jschwxrz Jun 25, 2026
979bbb5
feat: add filesystem to file runtime
jschwxrz Jun 25, 2026
d6191be
feat(core-files): use absolute machine paths
jschwxrz Jun 26, 2026
9d0ead4
feat(core-git): return absolute worktree paths
jschwxrz Jun 26, 2026
0621ee5
feat(desktop-files): add core-backed file RPCs
jschwxrz Jun 26, 2026
a6b82b0
feat(desktop-runtime): add core file runtime adapters
jschwxrz Jun 26, 2026
91a6954
feat(workspaces): wire core file capabilities
jschwxrz Jun 26, 2026
c1ba925
feat(project-settings): read config through file runtime
jschwxrz Jun 26, 2026
a848753
feat(agent-hooks): trust absolute workspace paths
jschwxrz Jun 26, 2026
09e1d37
feat(search): index absolute workspace file paths
jschwxrz Jun 26, 2026
6fe0b8b
feat(terminals): use absolute workspace file paths
jschwxrz Jun 26, 2026
77895bb
feat(git): normalize legacy file paths
jschwxrz Jun 26, 2026
3a04469
feat(diff-view): use absolute file identities
jschwxrz Jun 26, 2026
bc44a45
feat(task-editor): use absolute workspace paths
jschwxrz Jun 26, 2026
247c08b
feat(editor): resolve absolute document paths
jschwxrz Jun 26, 2026
6689f4b
fix: path containment
jschwxrz Jun 26, 2026
7185f02
chore: fix test
jschwxrz Jun 26, 2026
4c61226
fix(ssh): refresh git status from remote file changes
jschwxrz Jun 26, 2026
7ff8196
fix(markdown): open workspace links in file preview
jschwxrz Jun 26, 2026
11ba571
fix(command-palette): show relative file paths
jschwxrz Jun 26, 2026
1d31678
Merge remote-tracking branch 'origin/main' into feat-migrate-file-tree
jschwxrz Jun 26, 2026
994719b
chore: format
jschwxrz Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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());
Expand Down Expand Up @@ -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: () =>
Expand All @@ -54,16 +44,49 @@ function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): ClaudeTr
}

function makeRemoteFs(
overrides: Partial<Pick<FileSystemProvider, 'realPath' | 'read' | 'write'>> = {}
): Pick<FileSystemProvider, 'realPath' | 'read' | 'write'> {
overrides: Partial<Pick<IFileSystem, 'realPath' | 'readText' | 'writeText'>> = {}
): Pick<IFileSystem, 'realPath' | 'readText' | 'writeText'> {
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<IFileSystem, 'realPath' | 'readText' | 'writeText'>;
}): 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();
Expand All @@ -79,7 +102,7 @@ describe('ClaudeTrustService', () => {

await service.maybeAutoTrustLocal({
providerId: 'codex',
cwd: '/tmp/worktree',
workspacePath: '/tmp/worktree',
homedir: '/home/local-user',
});

Expand All @@ -92,7 +115,7 @@ describe('ClaudeTrustService', () => {

await service.maybeAutoTrustLocal({
providerId: 'claude',
cwd: '/tmp/worktree',
workspacePath: '/tmp/worktree',
homedir: '/home/local-user',
});

Expand All @@ -105,7 +128,7 @@ describe('ClaudeTrustService', () => {

await service.maybeAutoTrustLocal({
providerId: 'claude',
cwd: '/tmp/worktree',
workspacePath: '/tmp/worktree',
homedir: '/home/local-user',
force: true,
});
Expand All @@ -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',
});

Expand All @@ -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',
});

Expand All @@ -170,7 +210,7 @@ describe('ClaudeTrustService', () => {

await service.maybeAutoTrustLocal({
providerId: 'copilot',
cwd: '/tmp/worktree',
workspacePath: '/tmp/worktree',
homedir: '/home/local-user',
});

Expand All @@ -194,7 +234,7 @@ describe('ClaudeTrustService', () => {

await service.maybeAutoTrustLocal({
providerId: 'claude',
cwd: trustedPath,
workspacePath: trustedPath,
homedir: '/home/local-user',
});

Expand All @@ -208,7 +248,7 @@ describe('ClaudeTrustService', () => {

await service.maybeAutoTrustLocal({
providerId: 'claude',
cwd: '/tmp/worktree',
workspacePath: '/tmp/worktree',
homedir: '/home/local-user',
});

Expand All @@ -226,7 +266,7 @@ describe('ClaudeTrustService', () => {

await service.maybeAutoTrustLocal({
providerId: 'claude',
cwd: '/tmp/worktree',
workspacePath: '/tmp/worktree',
homedir: '/home/local-user',
});

Expand All @@ -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',
}),
]);
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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' }
);
});
});
Loading
Loading