From 23939ba00feb4897bf6efc22998e9b2dfc8e5b28 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 20 Apr 2026 14:51:48 +0200 Subject: [PATCH 01/22] fix: ts error --- src/renderer/features/settings/components/AccountTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/features/settings/components/AccountTab.tsx b/src/renderer/features/settings/components/AccountTab.tsx index c2bf690896..e9d18dc95d 100644 --- a/src/renderer/features/settings/components/AccountTab.tsx +++ b/src/renderer/features/settings/components/AccountTab.tsx @@ -39,7 +39,7 @@ export function AccountTab() { } toast({ title: 'Signed in to Emdash', - description: `Connected as @${result.user.username}`, + description: `Connected as @${result.user?.username}`, }); } catch (err) { const message = err instanceof Error ? err.message : 'Sign in failed'; From 576150db4c5ebfd281533bcfe96049c33fd80913 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 20 Apr 2026 15:27:16 +0200 Subject: [PATCH 02/22] refactor: extract resolveRemoteHome to utils --- src/main/core/agent-hooks/claude-trust-service.ts | 10 +--------- src/main/core/ssh/utils.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/core/agent-hooks/claude-trust-service.ts b/src/main/core/agent-hooks/claude-trust-service.ts index f4c657c26b..745fec9ec2 100644 --- a/src/main/core/agent-hooks/claude-trust-service.ts +++ b/src/main/core/agent-hooks/claude-trust-service.ts @@ -8,6 +8,7 @@ import { type FileSystemProvider, } from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; +import { resolveRemoteHome } from '@main/core/ssh/utils'; import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; @@ -205,15 +206,6 @@ async function writeRemoteConfigAtomic( } } -async function resolveRemoteHome(exec: ExecFn): Promise { - const { stdout } = await exec('sh', ['-c', 'printf %s "$HOME"']); - const home = stdout.trim(); - if (!home) { - throw new Error('Remote home directory is empty'); - } - return home; -} - function isNodeNotFound(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; } diff --git a/src/main/core/ssh/utils.ts b/src/main/core/ssh/utils.ts index 2329ba996c..fea6897201 100644 --- a/src/main/core/ssh/utils.ts +++ b/src/main/core/ssh/utils.ts @@ -1,4 +1,5 @@ import { parseSshConfigFile } from '@main/core/ssh/sshConfigParser'; +import type { ExecFn } from '@main/core/utils/exec'; export async function resolveIdentityAgent(hostname: string): Promise { try { @@ -13,3 +14,12 @@ export async function resolveIdentityAgent(hostname: string): Promise { + const { stdout } = await exec('sh', ['-c', 'printf %s "$HOME"']); + const home = stdout.trim(); + if (!home) { + throw new Error('Remote home directory is empty'); + } + return home; +} From cc0fa79eb7da1faf2bc7d997f0c52ffad5d32dd8 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 20 Apr 2026 15:31:21 +0200 Subject: [PATCH 03/22] feat: trim dir in zod schema --- src/main/core/projects/settings/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/core/projects/settings/schema.ts b/src/main/core/projects/settings/schema.ts index 89fa3350cd..27dd761107 100644 --- a/src/main/core/projects/settings/schema.ts +++ b/src/main/core/projects/settings/schema.ts @@ -29,7 +29,7 @@ export const projectSettingsSchema = z.object({ teardown: z.string().optional(), }) .optional(), - worktreeDirectory: z.string().optional(), + worktreeDirectory: z.string().trim().optional(), defaultBranch: defaultBranchSettingSchema.optional(), remote: z.string().optional(), }); From bc3cdea68d2b13ce9a58ae80b08d4a6dfb4d5b78 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 20 Apr 2026 16:16:58 +0200 Subject: [PATCH 04/22] feat: WIP wire project level settings worktree dir --- .../projects/impl/local-project-provider.ts | 15 ++++++----- .../projects/impl/ssh-project-provider.ts | 25 +++++++++++++++---- src/main/core/settings/settings-registry.ts | 3 ++- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index 33b685d917..14a5035fc6 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -17,7 +17,6 @@ import { GitService } from '@main/core/git/impl/git-service'; import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; -import { appSettingsService } from '@main/core/settings/settings-service'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { LocalTerminalProvider } from '@main/core/terminals/impl/local-terminal-provider'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; @@ -61,13 +60,16 @@ export async function createLocalProvider( project: LocalProject, rootFs: FileSystemProvider ): Promise { - const defaultWorktreeDirectory = (await appSettingsService.get('localProject')) - .defaultWorktreeDirectory; - const worktreePoolPath = path.join(defaultWorktreeDirectory, project.name); + const settings = new LocalProjectSettingsProvider( + project.path, + bareRefName(project.baseRef), + rootFs + ); + const worktreePoolPath = path.join(await settings.getWorktreeDirectory(), project.name); await fs.promises.mkdir(worktreePoolPath, { recursive: true }); - return new LocalProjectProvider(project, rootFs, { worktreePoolPath }); + return new LocalProjectProvider(project, rootFs, { settings, worktreePoolPath }); } export class LocalProjectProvider implements ProjectProvider { @@ -89,10 +91,11 @@ export class LocalProjectProvider implements ProjectProvider { private readonly project: LocalProject, readonly rootFs: FileSystemProvider, options: { + settings: ProjectSettingsProvider; worktreePoolPath: string; } ) { - this.settings = new LocalProjectSettingsProvider(project.path, bareRefName(project.baseRef)); + this.settings = options.settings; this.fs = new LocalFileSystem(project.path); const gitExec = getGitLocalExec(() => githubConnectionService.getToken()); const repoGit = new GitService(project.path, gitExec, this.fs); diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index d932b96c3b..a79720cf75 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -64,10 +64,23 @@ export async function createSshProvider( proxy: SshClientProxy ): Promise { try { - // hardcoded to next to project path, TODO: let user configure path - const worktreePoolPath = path.join(path.dirname(project.path), 'worktrees', project.name); + const projectFs = new SshFileSystem(proxy, project.path); + const exec = getSshExec(proxy); + + const settings = new SshProjectSettingsProvider( + projectFs, + bareRefName(project.baseRef), + rootFs, + project.path, + exec + ); + const worktreePoolPath = path.posix.join(await settings.getWorktreeDirectory(), project.name); + await rootFs.mkdir(worktreePoolPath, { recursive: true }); + return new SshProjectProvider(project, rootFs, proxy, { - worktreePoolPath: worktreePoolPath, + fs: projectFs, + settings, + worktreePoolPath, }); } catch (error) { log.warn('createSshProvider: SSH connection failed', { @@ -99,11 +112,13 @@ export class SshProjectProvider implements ProjectProvider { rootFs: FileSystemProvider, private readonly proxy: SshClientProxy, options: { + fs: SshFileSystem; + settings: ProjectSettingsProvider; worktreePoolPath: string; } ) { - this.fs = new SshFileSystem(this.proxy, project.path); - this.settings = new SshProjectSettingsProvider(this.fs, bareRefName(project.baseRef)); + this.fs = options.fs; + this.settings = options.settings; const gitExec = getGitSshExec(this.proxy, () => githubConnectionService.getToken()); const repoGit = new GitService(project.path, gitExec, this.fs, false); this.repository = new GitRepositoryService(repoGit, this.settings); diff --git a/src/main/core/settings/settings-registry.ts b/src/main/core/settings/settings-registry.ts index 0b2257ef94..f9c842a11f 100644 --- a/src/main/core/settings/settings-registry.ts +++ b/src/main/core/settings/settings-registry.ts @@ -2,6 +2,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import type { AppSettings, AppSettingsKey } from '@shared/app-settings'; import type { OpenInAppId } from '@shared/openInApps'; +import { getDefaultLocalWorktreeDirectory } from './worktree-defaults'; export const DEFAULT_AGENT_ID = 'claude'; export const DEFAULT_REVIEW_PROMPT = @@ -14,7 +15,7 @@ type SettingsDefaultsMap = { export const SETTINGS_DEFAULTS = { localProject: () => ({ defaultProjectsDirectory: join(homedir(), 'emdash', 'repositories'), - defaultWorktreeDirectory: join(homedir(), 'emdash', 'worktrees'), + defaultWorktreeDirectory: getDefaultLocalWorktreeDirectory(), branchPrefix: 'emdash', pushOnCreate: true, writeAgentConfigToGitIgnore: true, From d59805c89b3db44b37fe7cd503167025ab52ae68 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 20 Apr 2026 16:17:30 +0200 Subject: [PATCH 05/22] wip --- .../settings/project-settings.test.ts | 167 ++++++++++++++++++ .../projects/settings/project-settings.ts | 105 ++++++++++- .../settings/worktree-directory.test.ts | 70 ++++++++ .../projects/settings/worktree-directory.ts | 76 ++++++++ src/main/core/settings/worktree-defaults.ts | 14 ++ src/main/core/ssh/utils.test.ts | 16 ++ 6 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 src/main/core/projects/settings/project-settings.test.ts create mode 100644 src/main/core/projects/settings/worktree-directory.test.ts create mode 100644 src/main/core/projects/settings/worktree-directory.ts create mode 100644 src/main/core/settings/worktree-defaults.ts create mode 100644 src/main/core/ssh/utils.test.ts diff --git a/src/main/core/projects/settings/project-settings.test.ts b/src/main/core/projects/settings/project-settings.test.ts new file mode 100644 index 0000000000..c46fca7752 --- /dev/null +++ b/src/main/core/projects/settings/project-settings.test.ts @@ -0,0 +1,167 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import type { ExecFn } from '@main/core/utils/exec'; +import { LocalProjectSettingsProvider, SshProjectSettingsProvider } from './project-settings'; + +vi.mock('@main/core/settings/settings-service', () => ({ + appSettingsService: { + get: vi.fn().mockResolvedValue({ + defaultWorktreeDirectory: '/tmp/emdash/worktrees', + }), + }, +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn().mockReturnValue('/tmp'), + }, +})); + +describe('ProjectSettingsProvider worktreeDirectory validation', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('normalizes and canonicalizes local worktreeDirectory on update', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); + tempDirs.push(projectPath); + const rootFs = { + mkdir: vi.fn().mockResolvedValue(undefined), + realPath: vi.fn().mockResolvedValue('/canonical/worktrees'), + }; + + const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); + + expect(rootFs.mkdir).toHaveBeenCalledWith(path.resolve(projectPath, 'worktrees'), { + recursive: true, + }); + expect(rootFs.realPath).toHaveBeenCalledWith(path.resolve(projectPath, 'worktrees')); + + const persisted = JSON.parse(fs.readFileSync(path.join(projectPath, '.emdash.json'), 'utf8')); + expect(persisted.worktreeDirectory).toBe('/canonical/worktrees'); + }); + + it('surfaces local worktreeDirectory validation errors', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); + tempDirs.push(projectPath); + const rootFs = { + mkdir: vi.fn().mockRejectedValue(new Error('EACCES')), + realPath: vi.fn(), + }; + + const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + await expect( + provider.update({ preservePatterns: [], worktreeDirectory: '/restricted' }) + ).rejects.toThrow('Invalid worktree directory'); + }); + + it('clears blank local worktreeDirectory values', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); + tempDirs.push(projectPath); + const rootFs = { + mkdir: vi.fn().mockResolvedValue(undefined), + realPath: vi.fn().mockResolvedValue('/unused'), + }; + + const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + await provider.update({ preservePatterns: [], worktreeDirectory: ' ' }); + + expect(rootFs.mkdir).not.toHaveBeenCalled(); + const persisted = JSON.parse(fs.readFileSync(path.join(projectPath, '.emdash.json'), 'utf8')); + expect(persisted.worktreeDirectory).toBeUndefined(); + }); + + it('normalizes and canonicalizes ssh worktreeDirectory on update', async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); + const projectFs = { + write: writeMock, + } as unknown as SshFileSystem; + const rootFs = { + mkdir: vi.fn().mockResolvedValue(undefined), + realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + }; + + const provider = new SshProjectSettingsProvider(projectFs, 'main', rootFs, '/remote/repo'); + await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); + + expect(rootFs.mkdir).toHaveBeenCalledWith('/remote/repo/worktrees', { recursive: true }); + expect(rootFs.realPath).toHaveBeenCalledWith('/remote/repo/worktrees'); + + expect(writeMock).toHaveBeenCalledTimes(1); + const persisted = JSON.parse(writeMock.mock.calls[0][1]); + expect(persisted.worktreeDirectory).toBe('/canonical/ssh-worktrees'); + }); + + it('uses project-scoped ssh default worktree directory when not configured', async () => { + const projectFs = { + exists: vi.fn().mockResolvedValue(false), + } as unknown as SshFileSystem; + + const provider = new SshProjectSettingsProvider(projectFs, 'main', undefined, '/remote/repo'); + await expect(provider.getWorktreeDirectory()).resolves.toBe('/remote/repo/.emdash/worktrees'); + }); + + it('rejects tilde worktreeDirectory for ssh projects', async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); + const projectFs = { + write: writeMock, + } as unknown as SshFileSystem; + const rootFs = { + mkdir: vi.fn().mockResolvedValue(undefined), + realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + }; + + const provider = new SshProjectSettingsProvider(projectFs, 'main', rootFs, '/remote/repo'); + await expect( + provider.update({ preservePatterns: [], worktreeDirectory: '~/worktrees' }) + ).rejects.toThrow('Unable to resolve remote home directory for SSH project'); + expect(writeMock).not.toHaveBeenCalled(); + }); + + 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 provider = new SshProjectSettingsProvider(projectFs, 'main', undefined, '/remote/repo'); + await expect(provider.getWorktreeDirectory()).resolves.toBe('/remote/repo/.emdash/worktrees'); + }); + + it('expands and caches ssh home for tilde worktreeDirectory values', async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); + const projectFs = { + write: writeMock, + } as unknown as SshFileSystem; + const rootFs = { + mkdir: vi.fn().mockResolvedValue(undefined), + realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + }; + const exec = vi.fn().mockResolvedValue({ stdout: '/home/ubuntu', stderr: '' }) as ExecFn; + + const provider = new SshProjectSettingsProvider( + projectFs, + 'main', + rootFs, + '/remote/repo', + exec + ); + await provider.update({ preservePatterns: [], worktreeDirectory: '~/worktrees' }); + await provider.update({ preservePatterns: [], worktreeDirectory: '~' }); + + expect(exec).toHaveBeenCalledTimes(1); + expect(rootFs.mkdir).toHaveBeenCalledWith('/home/ubuntu/worktrees', { recursive: true }); + expect(rootFs.realPath).toHaveBeenCalledWith('/home/ubuntu/worktrees'); + expect(writeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/main/core/projects/settings/project-settings.ts b/src/main/core/projects/settings/project-settings.ts index 161a39ca82..0790a31994 100644 --- a/src/main/core/projects/settings/project-settings.ts +++ b/src/main/core/projects/settings/project-settings.ts @@ -1,9 +1,19 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import type { FileSystemProvider } from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; +import { getDefaultSshWorktreeDirectory } from '@main/core/settings/worktree-defaults'; +import { resolveRemoteHome } from '@main/core/ssh/utils'; +import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { ProjectSettings, ProjectSettingsProvider, projectSettingsSchema } from './schema'; +import { + defaultLocalWorktreeFs, + normalizeWorktreeDirectory, + resolveAndValidateWorktreeDirectory, +} from './worktree-directory'; const defaults = () => projectSettingsSchema.parse({}); @@ -19,7 +29,8 @@ function parseSettingsOrDefault(raw: string, source: string): ProjectSettings { export class LocalProjectSettingsProvider implements ProjectSettingsProvider { constructor( private readonly projectPath: string, - private readonly defaultBranchFallback: string = 'main' + private readonly defaultBranchFallback: string = 'main', + private readonly rootFs?: Pick ) {} async get(): Promise { @@ -31,8 +42,24 @@ export class LocalProjectSettingsProvider implements ProjectSettingsProvider { } async update(settings: ProjectSettings): Promise { + const nextSettings = projectSettingsSchema.parse(settings); + try { + nextSettings.worktreeDirectory = await resolveAndValidateWorktreeDirectory( + nextSettings.worktreeDirectory, + { + projectPath: this.projectPath, + pathApi: path, + fs: this.rootFs ?? defaultLocalWorktreeFs, + homeDirectory: os.homedir(), + } + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid worktree directory: ${message}`); + } + const settingsPath = path.join(this.projectPath, '.emdash.json'); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2)); } async ensure(): Promise { @@ -56,19 +83,49 @@ export class LocalProjectSettingsProvider implements ProjectSettingsProvider { async getWorktreeDirectory(): Promise { const settings = await this.get(); + const defaultWorktreeDirectory = (await appSettingsService.get('localProject')) + .defaultWorktreeDirectory; if (settings.worktreeDirectory) { - return settings.worktreeDirectory; + try { + return await normalizeWorktreeDirectory(settings.worktreeDirectory, { + projectPath: this.projectPath, + pathApi: path, + homeDirectory: os.homedir(), + }); + } catch (error: unknown) { + log.warn( + 'LocalProjectSettingsProvider: invalid worktreeDirectory, falling back to default', + { + worktreeDirectory: settings.worktreeDirectory, + defaultWorktreeDirectory, + error: String(error), + } + ); + } } - return (await appSettingsService.get('localProject')).defaultWorktreeDirectory; + return defaultWorktreeDirectory; } } export class SshProjectSettingsProvider implements ProjectSettingsProvider { constructor( private readonly fs: SshFileSystem, - private readonly defaultBranchFallback: string = 'main' + private readonly defaultBranchFallback: string = 'main', + private readonly rootFs?: Pick, + private readonly projectPath: string = '/', + private readonly exec?: ExecFn ) {} + private homeDirectory?: Promise; + + private async getHomeDirectory(): Promise { + if (!this.exec) { + throw new Error('Unable to resolve remote home directory for SSH project'); + } + this.homeDirectory ??= resolveRemoteHome(this.exec); + return this.homeDirectory; + } + async get(): Promise { const exists = await this.fs.exists('.emdash.json'); if (!exists) { @@ -82,7 +139,26 @@ export class SshProjectSettingsProvider implements ProjectSettingsProvider { } async update(settings: ProjectSettings): Promise { - await this.fs.write('.emdash.json', JSON.stringify(settings, null, 2)); + const nextSettings = projectSettingsSchema.parse(settings); + if (!this.rootFs) { + throw new Error('Unable to validate worktree directory for SSH project'); + } + try { + nextSettings.worktreeDirectory = await resolveAndValidateWorktreeDirectory( + nextSettings.worktreeDirectory, + { + projectPath: this.projectPath, + pathApi: path.posix, + fs: this.rootFs, + resolveHomeDirectory: () => this.getHomeDirectory(), + } + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid worktree directory: ${message}`); + } + + await this.fs.write('.emdash.json', JSON.stringify(nextSettings, null, 2)); } async ensure(): Promise { @@ -106,9 +182,22 @@ export class SshProjectSettingsProvider implements ProjectSettingsProvider { async getWorktreeDirectory(): Promise { const settings = await this.get(); + const defaultWorktreeDirectory = getDefaultSshWorktreeDirectory(this.projectPath); if (settings.worktreeDirectory) { - return settings.worktreeDirectory; + try { + return await normalizeWorktreeDirectory(settings.worktreeDirectory, { + projectPath: this.projectPath, + pathApi: path.posix, + resolveHomeDirectory: () => this.getHomeDirectory(), + }); + } catch (error: unknown) { + log.warn('SshProjectSettingsProvider: invalid worktreeDirectory, falling back to default', { + worktreeDirectory: settings.worktreeDirectory, + defaultWorktreeDirectory, + error: String(error), + }); + } } - return path.join('emdash', 'worktrees'); + return defaultWorktreeDirectory; } } diff --git a/src/main/core/projects/settings/worktree-directory.test.ts b/src/main/core/projects/settings/worktree-directory.test.ts new file mode 100644 index 0000000000..51ede489be --- /dev/null +++ b/src/main/core/projects/settings/worktree-directory.test.ts @@ -0,0 +1,70 @@ +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { canonicalizeWorktreeDirectory, normalizeWorktreeDirectory } from './worktree-directory'; + +describe('worktree-directory', () => { + describe('normalizeWorktreeDirectory', () => { + it('resolves local relative paths from project path', async () => { + await expect( + normalizeWorktreeDirectory('worktrees', { + projectPath: '/repo', + pathApi: path, + homeDirectory: '/Users/test', + }) + ).resolves.toBe(path.resolve('/repo', 'worktrees')); + }); + + it('expands local tilde paths from home', async () => { + await expect( + normalizeWorktreeDirectory('~/worktrees', { + projectPath: '/repo', + pathApi: path, + homeDirectory: '/Users/test', + }) + ).resolves.toBe(path.resolve('/Users/test', 'worktrees')); + }); + + it('resolves ssh relative paths with posix semantics', async () => { + await expect( + normalizeWorktreeDirectory('worktrees', { + projectPath: '/remote/repo', + pathApi: path.posix, + }) + ).resolves.toBe('/remote/repo/worktrees'); + }); + + it('rejects tilde paths when home cannot be resolved', async () => { + await expect( + normalizeWorktreeDirectory('~/worktrees', { + projectPath: '/remote/repo', + pathApi: path.posix, + }) + ).rejects.toThrow('cannot use "~" without a home directory resolver'); + }); + + it('expands ssh tilde paths with async home resolver', async () => { + await expect( + normalizeWorktreeDirectory('~/worktrees', { + projectPath: '/remote/repo', + pathApi: path.posix, + resolveHomeDirectory: async () => '/home/ubuntu', + }) + ).resolves.toBe('/home/ubuntu/worktrees'); + }); + }); + + describe('canonicalizeWorktreeDirectory', () => { + it('creates and canonicalizes directory through fs provider', async () => { + const fs = { + mkdir: vi.fn().mockResolvedValue(undefined), + realPath: vi.fn().mockResolvedValue('/canonical/path'), + }; + + const resolved = await canonicalizeWorktreeDirectory('/input/path', fs); + + expect(resolved).toBe('/canonical/path'); + expect(fs.mkdir).toHaveBeenCalledWith('/input/path', { recursive: true }); + expect(fs.realPath).toHaveBeenCalledWith('/input/path'); + }); + }); +}); diff --git a/src/main/core/projects/settings/worktree-directory.ts b/src/main/core/projects/settings/worktree-directory.ts new file mode 100644 index 0000000000..8d206698d6 --- /dev/null +++ b/src/main/core/projects/settings/worktree-directory.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { FileSystemProvider } from '@main/core/fs/types'; + +export type WorktreeDirectoryFs = Pick; + +type PathApi = Pick; + +export async function normalizeWorktreeDirectory( + input: string, + options: { + projectPath: string; + pathApi: PathApi; + homeDirectory?: string; + resolveHomeDirectory?: () => Promise; + } +): Promise { + const trimmed = input.trim(); + let normalized = trimmed; + + if (trimmed === '~' || trimmed.startsWith('~/')) { + const resolvedHomeDirectory = options.resolveHomeDirectory + ? (await options.resolveHomeDirectory()).trim() + : undefined; + const homeDirectory = options.homeDirectory ?? resolvedHomeDirectory; + if (!homeDirectory) { + throw new Error('Worktree directory cannot use "~" without a home directory resolver.'); + } + normalized = + trimmed === '~' ? homeDirectory : options.pathApi.join(homeDirectory, trimmed.slice(2)); + } + + if (options.pathApi.isAbsolute(normalized)) { + return normalized; + } + return options.pathApi.resolve(options.projectPath, normalized); +} + +export async function canonicalizeWorktreeDirectory( + directory: string, + fs: WorktreeDirectoryFs +): Promise { + await fs.mkdir(directory, { recursive: true }); + return fs.realPath(directory); +} + +export const defaultLocalWorktreeFs: WorktreeDirectoryFs = { + mkdir: async (p, options) => { + await fs.promises.mkdir(p, options); + }, + realPath: async (p) => fs.promises.realpath(p), +}; + +export async function resolveAndValidateWorktreeDirectory( + input: string | undefined, + options: { + projectPath: string; + pathApi: Pick; + fs: WorktreeDirectoryFs; + homeDirectory?: string; + resolveHomeDirectory?: () => Promise; + } +): Promise { + const trimmed = input?.trim(); + if (!trimmed) { + return undefined; + } + + const normalized = await normalizeWorktreeDirectory(trimmed, { + projectPath: options.projectPath, + pathApi: options.pathApi, + homeDirectory: options.homeDirectory, + resolveHomeDirectory: options.resolveHomeDirectory, + }); + return canonicalizeWorktreeDirectory(normalized, options.fs); +} diff --git a/src/main/core/settings/worktree-defaults.ts b/src/main/core/settings/worktree-defaults.ts new file mode 100644 index 0000000000..caddfec15c --- /dev/null +++ b/src/main/core/settings/worktree-defaults.ts @@ -0,0 +1,14 @@ +import { homedir } from 'node:os'; +import path from 'node:path'; + +export const WORKTREE_POOL_DIR_NAME = 'worktrees'; +export const LOCAL_WORKTREE_ROOT_DIR_NAME = 'emdash'; +export const SSH_PROJECT_STATE_DIR_NAME = '.emdash'; + +export function getDefaultLocalWorktreeDirectory(homeDirectory: string = homedir()): string { + return path.join(homeDirectory, LOCAL_WORKTREE_ROOT_DIR_NAME, WORKTREE_POOL_DIR_NAME); +} + +export function getDefaultSshWorktreeDirectory(projectPath: string): string { + return path.posix.join(projectPath, SSH_PROJECT_STATE_DIR_NAME, WORKTREE_POOL_DIR_NAME); +} diff --git a/src/main/core/ssh/utils.test.ts b/src/main/core/ssh/utils.test.ts new file mode 100644 index 0000000000..eaafd58500 --- /dev/null +++ b/src/main/core/ssh/utils.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ExecFn } from '@main/core/utils/exec'; +import { resolveRemoteHome } from './utils'; + +describe('resolveRemoteHome', () => { + it('returns trimmed remote home', async () => { + const exec = vi.fn().mockResolvedValue({ stdout: ' /home/ubuntu \n', stderr: '' }) as ExecFn; + await expect(resolveRemoteHome(exec)).resolves.toBe('/home/ubuntu'); + expect(exec).toHaveBeenCalledWith('sh', ['-c', 'printf %s "$HOME"']); + }); + + it('throws when remote home is empty', async () => { + const exec = vi.fn().mockResolvedValue({ stdout: ' ', stderr: '' }) as ExecFn; + await expect(resolveRemoteHome(exec)).rejects.toThrow('Remote home directory is empty'); + }); +}); From 561e4d44119a92f7f2866edef1fe152669fe4562 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 20 Apr 2026 16:42:13 +0200 Subject: [PATCH 06/22] checkpoint --- .../operations/updateProjectSettings.ts | 8 +- .../settings/project-settings.test.ts | 37 +++-- .../projects/settings/project-settings.ts | 141 +++++++++++------- src/main/core/projects/settings/schema.ts | 4 +- .../settings/worktree-directory.test.ts | 31 +++- .../projects/settings/worktree-directory.ts | 57 ++++--- .../worktrees/worktree-service.test.ts | 3 +- .../projects/stores/project-settings-store.ts | 11 +- src/shared/projects.ts | 6 + 9 files changed, 196 insertions(+), 102 deletions(-) diff --git a/src/main/core/projects/operations/updateProjectSettings.ts b/src/main/core/projects/operations/updateProjectSettings.ts index 51aab78655..3d6e350313 100644 --- a/src/main/core/projects/operations/updateProjectSettings.ts +++ b/src/main/core/projects/operations/updateProjectSettings.ts @@ -1,13 +1,15 @@ +import type { UpdateProjectSettingsError } from '@shared/projects'; +import { err, type Result } from '@shared/result'; import { projectManager } from '../project-manager'; import { ProjectSettings } from '../settings/schema'; export async function updateProjectSettings( projectId: string, settings: ProjectSettings -): Promise { +): Promise> { const project = projectManager.getProject(projectId); if (!project) { - throw new Error(`Project ${projectId} not found`); + return err({ type: 'project-not-found' }); } - await project.settings.update(settings); + return project.settings.update(settings); } diff --git a/src/main/core/projects/settings/project-settings.test.ts b/src/main/core/projects/settings/project-settings.test.ts index c46fca7752..60f4f05467 100644 --- a/src/main/core/projects/settings/project-settings.test.ts +++ b/src/main/core/projects/settings/project-settings.test.ts @@ -38,7 +38,8 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }; const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); - await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); + const result = await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); + expect(result.success).toBe(true); expect(rootFs.mkdir).toHaveBeenCalledWith(path.resolve(projectPath, 'worktrees'), { recursive: true, @@ -58,9 +59,14 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }; const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); - await expect( - provider.update({ preservePatterns: [], worktreeDirectory: '/restricted' }) - ).rejects.toThrow('Invalid worktree directory'); + const result = await provider.update({ + preservePatterns: [], + worktreeDirectory: '/restricted', + }); + expect(result).toEqual({ + success: false, + error: { type: 'invalid-worktree-directory' }, + }); }); it('clears blank local worktreeDirectory values', async () => { @@ -72,7 +78,8 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }; const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); - await provider.update({ preservePatterns: [], worktreeDirectory: ' ' }); + const result = await provider.update({ preservePatterns: [], worktreeDirectory: ' ' }); + expect(result.success).toBe(true); expect(rootFs.mkdir).not.toHaveBeenCalled(); const persisted = JSON.parse(fs.readFileSync(path.join(projectPath, '.emdash.json'), 'utf8')); @@ -90,7 +97,8 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }; const provider = new SshProjectSettingsProvider(projectFs, 'main', rootFs, '/remote/repo'); - await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); + const result = await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); + expect(result.success).toBe(true); expect(rootFs.mkdir).toHaveBeenCalledWith('/remote/repo/worktrees', { recursive: true }); expect(rootFs.realPath).toHaveBeenCalledWith('/remote/repo/worktrees'); @@ -120,9 +128,14 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }; const provider = new SshProjectSettingsProvider(projectFs, 'main', rootFs, '/remote/repo'); - await expect( - provider.update({ preservePatterns: [], worktreeDirectory: '~/worktrees' }) - ).rejects.toThrow('Unable to resolve remote home directory for SSH project'); + const result = await provider.update({ + preservePatterns: [], + worktreeDirectory: '~/worktrees', + }); + expect(result).toEqual({ + success: false, + error: { type: 'invalid-worktree-directory' }, + }); expect(writeMock).not.toHaveBeenCalled(); }); @@ -156,8 +169,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { '/remote/repo', exec ); - await provider.update({ preservePatterns: [], worktreeDirectory: '~/worktrees' }); - await provider.update({ preservePatterns: [], worktreeDirectory: '~' }); + const first = await provider.update({ preservePatterns: [], worktreeDirectory: '~/worktrees' }); + const second = await provider.update({ preservePatterns: [], worktreeDirectory: '~' }); + expect(first.success).toBe(true); + expect(second.success).toBe(true); expect(exec).toHaveBeenCalledTimes(1); expect(rootFs.mkdir).toHaveBeenCalledWith('/home/ubuntu/worktrees', { recursive: true }); diff --git a/src/main/core/projects/settings/project-settings.ts b/src/main/core/projects/settings/project-settings.ts index 0790a31994..f89aaf2711 100644 --- a/src/main/core/projects/settings/project-settings.ts +++ b/src/main/core/projects/settings/project-settings.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { UpdateProjectSettingsError } from '@shared/projects'; +import { err, ok, type Result } from '@shared/result'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; @@ -41,25 +43,34 @@ export class LocalProjectSettingsProvider implements ProjectSettingsProvider { return parseSettingsOrDefault(fs.readFileSync(settingsPath, 'utf8'), settingsPath); } - async update(settings: ProjectSettings): Promise { - const nextSettings = projectSettingsSchema.parse(settings); - try { - nextSettings.worktreeDirectory = await resolveAndValidateWorktreeDirectory( - nextSettings.worktreeDirectory, - { - projectPath: this.projectPath, - pathApi: path, - fs: this.rootFs ?? defaultLocalWorktreeFs, - homeDirectory: os.homedir(), - } - ); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid worktree directory: ${message}`); + async update(settings: ProjectSettings): Promise> { + const parsed = projectSettingsSchema.safeParse(settings); + if (!parsed.success) { + return err({ type: 'invalid-settings' }); + } + const nextSettings = parsed.data; + const worktreeDirectoryResult = await resolveAndValidateWorktreeDirectory( + nextSettings.worktreeDirectory, + { + projectPath: this.projectPath, + pathApi: path, + fs: this.rootFs ?? defaultLocalWorktreeFs, + homeDirectory: os.homedir(), + } + ); + if (!worktreeDirectoryResult.success) { + return worktreeDirectoryResult; } - const settingsPath = path.join(this.projectPath, '.emdash.json'); - fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2)); + nextSettings.worktreeDirectory = worktreeDirectoryResult.data; + + try { + const settingsPath = path.join(this.projectPath, '.emdash.json'); + fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2)); + return ok(); + } catch { + return err({ type: 'error' }); + } } async ensure(): Promise { @@ -86,19 +97,21 @@ export class LocalProjectSettingsProvider implements ProjectSettingsProvider { const defaultWorktreeDirectory = (await appSettingsService.get('localProject')) .defaultWorktreeDirectory; if (settings.worktreeDirectory) { - try { - return await normalizeWorktreeDirectory(settings.worktreeDirectory, { - projectPath: this.projectPath, - pathApi: path, - homeDirectory: os.homedir(), - }); - } catch (error: unknown) { + const normalized = await normalizeWorktreeDirectory(settings.worktreeDirectory, { + projectPath: this.projectPath, + pathApi: path, + homeDirectory: os.homedir(), + }); + if (normalized.success) { + return normalized.data; + } + { log.warn( 'LocalProjectSettingsProvider: invalid worktreeDirectory, falling back to default', { worktreeDirectory: settings.worktreeDirectory, defaultWorktreeDirectory, - error: String(error), + error: normalized.error.type, } ); } @@ -118,12 +131,16 @@ export class SshProjectSettingsProvider implements ProjectSettingsProvider { private homeDirectory?: Promise; - private async getHomeDirectory(): Promise { + private async getHomeDirectory(): Promise> { if (!this.exec) { - throw new Error('Unable to resolve remote home directory for SSH project'); + return err({ type: 'invalid-worktree-directory' }); + } + try { + this.homeDirectory ??= resolveRemoteHome(this.exec); + return ok(await this.homeDirectory); + } catch { + return err({ type: 'invalid-worktree-directory' }); } - this.homeDirectory ??= resolveRemoteHome(this.exec); - return this.homeDirectory; } async get(): Promise { @@ -138,27 +155,38 @@ export class SshProjectSettingsProvider implements ProjectSettingsProvider { ); } - async update(settings: ProjectSettings): Promise { - const nextSettings = projectSettingsSchema.parse(settings); + async update(settings: ProjectSettings): Promise> { + const parsed = projectSettingsSchema.safeParse(settings); + if (!parsed.success) { + return err({ type: 'invalid-settings' }); + } + const nextSettings = parsed.data; if (!this.rootFs) { - throw new Error('Unable to validate worktree directory for SSH project'); + return err({ type: 'error' }); } - try { - nextSettings.worktreeDirectory = await resolveAndValidateWorktreeDirectory( - nextSettings.worktreeDirectory, - { - projectPath: this.projectPath, - pathApi: path.posix, - fs: this.rootFs, - resolveHomeDirectory: () => this.getHomeDirectory(), - } - ); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid worktree directory: ${message}`); + const worktreeDirectoryResult = await resolveAndValidateWorktreeDirectory( + nextSettings.worktreeDirectory, + { + projectPath: this.projectPath, + pathApi: path.posix, + fs: this.rootFs, + resolveHomeDirectory: async () => { + const homeDirectory = await this.getHomeDirectory(); + return homeDirectory.success ? homeDirectory.data : ''; + }, + } + ); + if (!worktreeDirectoryResult.success) { + return worktreeDirectoryResult; } - await this.fs.write('.emdash.json', JSON.stringify(nextSettings, null, 2)); + nextSettings.worktreeDirectory = worktreeDirectoryResult.data; + try { + await this.fs.write('.emdash.json', JSON.stringify(nextSettings, null, 2)); + return ok(); + } catch { + return err({ type: 'error' }); + } } async ensure(): Promise { @@ -184,17 +212,22 @@ export class SshProjectSettingsProvider implements ProjectSettingsProvider { const settings = await this.get(); const defaultWorktreeDirectory = getDefaultSshWorktreeDirectory(this.projectPath); if (settings.worktreeDirectory) { - try { - return await normalizeWorktreeDirectory(settings.worktreeDirectory, { - projectPath: this.projectPath, - pathApi: path.posix, - resolveHomeDirectory: () => this.getHomeDirectory(), - }); - } catch (error: unknown) { + const normalized = await normalizeWorktreeDirectory(settings.worktreeDirectory, { + projectPath: this.projectPath, + pathApi: path.posix, + resolveHomeDirectory: async () => { + const homeDirectory = await this.getHomeDirectory(); + return homeDirectory.success ? homeDirectory.data : ''; + }, + }); + if (normalized.success) { + return normalized.data; + } + { log.warn('SshProjectSettingsProvider: invalid worktreeDirectory, falling back to default', { worktreeDirectory: settings.worktreeDirectory, defaultWorktreeDirectory, - error: String(error), + error: normalized.error.type, }); } } diff --git a/src/main/core/projects/settings/schema.ts b/src/main/core/projects/settings/schema.ts index 27dd761107..4cb837e347 100644 --- a/src/main/core/projects/settings/schema.ts +++ b/src/main/core/projects/settings/schema.ts @@ -1,4 +1,6 @@ import z from 'zod'; +import type { UpdateProjectSettingsError } from '@shared/projects'; +import type { Result } from '@shared/result'; export const defaultBranchSettingSchema = z.union([ z.string(), @@ -41,6 +43,6 @@ export interface ProjectSettingsProvider { getRemote(): Promise; getWorktreeDirectory(): Promise; get(): Promise; - update(settings: ProjectSettings): Promise; + update(settings: ProjectSettings): Promise>; ensure(): Promise; } diff --git a/src/main/core/projects/settings/worktree-directory.test.ts b/src/main/core/projects/settings/worktree-directory.test.ts index 51ede489be..e1079386f3 100644 --- a/src/main/core/projects/settings/worktree-directory.test.ts +++ b/src/main/core/projects/settings/worktree-directory.test.ts @@ -11,7 +11,10 @@ describe('worktree-directory', () => { pathApi: path, homeDirectory: '/Users/test', }) - ).resolves.toBe(path.resolve('/repo', 'worktrees')); + ).resolves.toEqual({ + success: true, + data: path.resolve('/repo', 'worktrees'), + }); }); it('expands local tilde paths from home', async () => { @@ -21,7 +24,10 @@ describe('worktree-directory', () => { pathApi: path, homeDirectory: '/Users/test', }) - ).resolves.toBe(path.resolve('/Users/test', 'worktrees')); + ).resolves.toEqual({ + success: true, + data: path.resolve('/Users/test', 'worktrees'), + }); }); it('resolves ssh relative paths with posix semantics', async () => { @@ -30,7 +36,10 @@ describe('worktree-directory', () => { projectPath: '/remote/repo', pathApi: path.posix, }) - ).resolves.toBe('/remote/repo/worktrees'); + ).resolves.toEqual({ + success: true, + data: '/remote/repo/worktrees', + }); }); it('rejects tilde paths when home cannot be resolved', async () => { @@ -39,7 +48,10 @@ describe('worktree-directory', () => { projectPath: '/remote/repo', pathApi: path.posix, }) - ).rejects.toThrow('cannot use "~" without a home directory resolver'); + ).resolves.toEqual({ + success: false, + error: { type: 'invalid-worktree-directory' }, + }); }); it('expands ssh tilde paths with async home resolver', async () => { @@ -49,7 +61,10 @@ describe('worktree-directory', () => { pathApi: path.posix, resolveHomeDirectory: async () => '/home/ubuntu', }) - ).resolves.toBe('/home/ubuntu/worktrees'); + ).resolves.toEqual({ + success: true, + data: '/home/ubuntu/worktrees', + }); }); }); @@ -61,8 +76,10 @@ describe('worktree-directory', () => { }; const resolved = await canonicalizeWorktreeDirectory('/input/path', fs); - - expect(resolved).toBe('/canonical/path'); + expect(resolved).toEqual({ + success: true, + data: '/canonical/path', + }); expect(fs.mkdir).toHaveBeenCalledWith('/input/path', { recursive: true }); expect(fs.realPath).toHaveBeenCalledWith('/input/path'); }); diff --git a/src/main/core/projects/settings/worktree-directory.ts b/src/main/core/projects/settings/worktree-directory.ts index 8d206698d6..37c425b1c5 100644 --- a/src/main/core/projects/settings/worktree-directory.ts +++ b/src/main/core/projects/settings/worktree-directory.ts @@ -1,5 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import type { UpdateProjectSettingsError } from '@shared/projects'; +import { err, ok, type Result } from '@shared/result'; import type { FileSystemProvider } from '@main/core/fs/types'; export type WorktreeDirectoryFs = Pick; @@ -14,34 +16,42 @@ export async function normalizeWorktreeDirectory( homeDirectory?: string; resolveHomeDirectory?: () => Promise; } -): Promise { - const trimmed = input.trim(); - let normalized = trimmed; +): Promise> { + try { + const trimmed = input.trim(); + let normalized = trimmed; - if (trimmed === '~' || trimmed.startsWith('~/')) { - const resolvedHomeDirectory = options.resolveHomeDirectory - ? (await options.resolveHomeDirectory()).trim() - : undefined; - const homeDirectory = options.homeDirectory ?? resolvedHomeDirectory; - if (!homeDirectory) { - throw new Error('Worktree directory cannot use "~" without a home directory resolver.'); + if (trimmed === '~' || trimmed.startsWith('~/')) { + const resolvedHomeDirectory = options.resolveHomeDirectory + ? (await options.resolveHomeDirectory()).trim() + : undefined; + const homeDirectory = options.homeDirectory ?? resolvedHomeDirectory; + if (!homeDirectory) { + return err({ type: 'invalid-worktree-directory' }); + } + normalized = + trimmed === '~' ? homeDirectory : options.pathApi.join(homeDirectory, trimmed.slice(2)); } - normalized = - trimmed === '~' ? homeDirectory : options.pathApi.join(homeDirectory, trimmed.slice(2)); - } - if (options.pathApi.isAbsolute(normalized)) { - return normalized; + if (options.pathApi.isAbsolute(normalized)) { + return ok(normalized); + } + return ok(options.pathApi.resolve(options.projectPath, normalized)); + } catch { + return err({ type: 'invalid-worktree-directory' }); } - return options.pathApi.resolve(options.projectPath, normalized); } export async function canonicalizeWorktreeDirectory( directory: string, fs: WorktreeDirectoryFs -): Promise { - await fs.mkdir(directory, { recursive: true }); - return fs.realPath(directory); +): Promise> { + try { + await fs.mkdir(directory, { recursive: true }); + return ok(await fs.realPath(directory)); + } catch { + return err({ type: 'invalid-worktree-directory' }); + } } export const defaultLocalWorktreeFs: WorktreeDirectoryFs = { @@ -60,10 +70,10 @@ export async function resolveAndValidateWorktreeDirectory( homeDirectory?: string; resolveHomeDirectory?: () => Promise; } -): Promise { +): Promise> { const trimmed = input?.trim(); if (!trimmed) { - return undefined; + return ok(undefined); } const normalized = await normalizeWorktreeDirectory(trimmed, { @@ -72,5 +82,8 @@ export async function resolveAndValidateWorktreeDirectory( homeDirectory: options.homeDirectory, resolveHomeDirectory: options.resolveHomeDirectory, }); - return canonicalizeWorktreeDirectory(normalized, options.fs); + if (!normalized.success) { + return normalized; + } + return canonicalizeWorktreeDirectory(normalized.data, options.fs); } diff --git a/src/main/core/projects/worktrees/worktree-service.test.ts b/src/main/core/projects/worktrees/worktree-service.test.ts index 3b4d4d20b8..dc2c9e8a39 100644 --- a/src/main/core/projects/worktrees/worktree-service.test.ts +++ b/src/main/core/projects/worktrees/worktree-service.test.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { Remote } from '@shared/git'; +import { ok } from '@shared/result'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { getLocalExec, type ExecFn } from '@main/core/utils/exec'; import type { ProjectSettingsProvider } from '../settings/schema'; @@ -19,7 +20,7 @@ async function initRepo(dir: string, exec: ExecFn): Promise { function makeSettings(preservePatterns: string[] = []): ProjectSettingsProvider { return { get: async () => ({ preservePatterns }), - update: async () => {}, + update: async () => ok(), ensure: async () => {}, getWorktreeDirectory: async () => '', getDefaultBranch: async () => 'main', diff --git a/src/renderer/features/projects/stores/project-settings-store.ts b/src/renderer/features/projects/stores/project-settings-store.ts index 1a37de7a2f..da75036e0a 100644 --- a/src/renderer/features/projects/stores/project-settings-store.ts +++ b/src/renderer/features/projects/stores/project-settings-store.ts @@ -1,3 +1,5 @@ +import type { UpdateProjectSettingsError } from '@shared/projects'; +import type { Result } from '@shared/result'; import type { ProjectSettings } from '@main/core/projects/settings/schema'; import { rpc } from '@renderer/lib/ipc'; import { Resource } from '@renderer/lib/stores/resource'; @@ -16,9 +18,12 @@ export class ProjectSettingsStore { return this.settingsData.data; } - async save(settings: ProjectSettings): Promise { - await rpc.projects.updateProjectSettings(this.projectId, settings); - this.settingsData.invalidate(); + async save(settings: ProjectSettings): Promise> { + const result = await rpc.projects.updateProjectSettings(this.projectId, settings); + if (result.success) { + this.settingsData.invalidate(); + } + return result; } dispose(): void { diff --git a/src/shared/projects.ts b/src/shared/projects.ts index cfec92a560..c878327eb6 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -31,3 +31,9 @@ export type SshProject = { }; export type Project = LocalProject | SshProject; + +export type UpdateProjectSettingsError = + | { type: 'project-not-found' } + | { type: 'invalid-settings' } + | { type: 'invalid-worktree-directory' } + | { type: 'error' }; From 113766c2d202fc4637a8c0fa65dfd307cd6319f6 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 20 Apr 2026 17:42:47 +0200 Subject: [PATCH 07/22] fix(ssh-fs): retry recursive mkdir only for missing parents --- src/main/core/fs/impl/ssh-fs.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/core/fs/impl/ssh-fs.ts b/src/main/core/fs/impl/ssh-fs.ts index e185afba42..f4d81aa57d 100644 --- a/src/main/core/fs/impl/ssh-fs.ts +++ b/src/main/core/fs/impl/ssh-fs.ts @@ -886,6 +886,7 @@ export class SshFileSystem implements FileSystemProvider { msg.includes('already exists') || msg.includes('File exists') || (code === SFTP_STATUS.FAILURE && (msg === 'Failure' || msg === '')); + const isMissingParent = code === SFTP_STATUS.NO_SUCH_FILE || msg.includes('No such file'); if (isAlreadyExists) { resolve(); @@ -893,7 +894,12 @@ export class SshFileSystem implements FileSystemProvider { } const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/')); - if (parentPath && parentPath !== dirPath && parentPath.length >= this.remotePath.length) { + if ( + isMissingParent && + parentPath && + parentPath !== dirPath && + parentPath.length >= this.remotePath.length + ) { this.ensureRemoteDir(sftp, parentPath) .then(() => this.ensureRemoteDir(sftp, dirPath)) .then(resolve) From aae18ace45e83f2295aabab9519650ffad4d7715 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 20 Apr 2026 17:43:35 +0200 Subject: [PATCH 08/22] fix(project-settings): keep worktree error inline in the input --- .../settings-view/project-settings-form.tsx | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/src/renderer/features/projects/components/settings-view/project-settings-form.tsx b/src/renderer/features/projects/components/settings-view/project-settings-form.tsx index 88a4430bac..ee8e90c5d8 100644 --- a/src/renderer/features/projects/components/settings-view/project-settings-form.tsx +++ b/src/renderer/features/projects/components/settings-view/project-settings-form.tsx @@ -2,6 +2,8 @@ import { Check, Loader2, Undo2 } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import { useMemo, useState } from 'react'; import type { Branch } from '@shared/git'; +import type { UpdateProjectSettingsError } from '@shared/projects'; +import { err, type Result } from '@shared/result'; import type { ProjectSettings } from '@main/core/projects/settings/schema'; import { getRepositoryStore } from '@renderer/features/projects/stores/project-selectors'; import { ProjectBranchSelector } from '@renderer/lib/components/project-branch-selector'; @@ -19,6 +21,7 @@ import { import { Separator } from '@renderer/lib/ui/separator'; import { Switch } from '@renderer/lib/ui/switch'; import { Textarea } from '@renderer/lib/ui/textarea'; +import { cn } from '@renderer/utils/utils'; type FormState = { preservePatterns: string; @@ -101,7 +104,7 @@ export interface ProjectSettingsFormProps { projectId: string; initial: ProjectSettings; onSuccess: () => void; - save: (settings: ProjectSettings) => Promise; + save: (settings: ProjectSettings) => Promise>; } type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; @@ -125,6 +128,7 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ const [form, setForm] = useState(baseline); const [savedForm, setSavedForm] = useState(baseline); const [saveStatus, setSaveStatus] = useState('idle'); + const [worktreeDirectoryError, setWorktreeDirectoryError] = useState(null); const formSnapshot = useMemo(() => JSON.stringify(form), [form]); const savedSnapshot = useMemo(() => JSON.stringify(savedForm), [savedForm]); @@ -136,21 +140,33 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ function update(key: K, value: FormState[K]) { setForm((current) => ({ ...current, [key]: value })); setSaveStatus((current) => (current === 'idle' ? current : 'idle')); + if (key === 'worktreeDirectory' && worktreeDirectoryError) { + setWorktreeDirectoryError(null); + } } async function handleSave() { const formAtSubmit = form; - setSaveStatus('saving'); - try { - await save(formToSettings(formAtSubmit)); + const result = await save(formToSettings(formAtSubmit)).catch(() => err({ type: 'error' })); + + if (result.success) { + setWorktreeDirectoryError(null); setSavedForm(formAtSubmit); setSaveStatus('saved'); onSuccess(); - } catch { - setSaveStatus('error'); + return; + } + + if (result.error.type === 'invalid-worktree-directory') { + setWorktreeDirectoryError('Invalid worktree directory'); + setSaveStatus('idle'); + return; } + + setWorktreeDirectoryError(null); + setSaveStatus('error'); } return ( @@ -180,11 +196,20 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ Override where worktrees are created. Defaults to the app-level worktree directory setting. - update('worktreeDirectory', e.target.value)} - /> +
+ update('worktreeDirectory', e.target.value)} + /> + {worktreeDirectoryError ? ( + + {worktreeDirectoryError} + + ) : null} +
@@ -307,6 +332,7 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ variant="outline" onClick={() => { setForm(savedForm); + setWorktreeDirectoryError(null); if (saveStatus === 'error') setSaveStatus('idle'); }} disabled={!dirty || saving} From 314af8475636fd6d005da7e06c5406b1fa893261 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:11:38 +0200 Subject: [PATCH 09/22] fix: remove monaco diff text borders --- src/renderer/lib/monaco/monaco-themes.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/renderer/lib/monaco/monaco-themes.ts b/src/renderer/lib/monaco/monaco-themes.ts index 3f9ef45c8b..1b220ee328 100644 --- a/src/renderer/lib/monaco/monaco-themes.ts +++ b/src/renderer/lib/monaco/monaco-themes.ts @@ -14,10 +14,8 @@ export function defineMonacoThemes(monaco: Monaco): void { 'editorGutter.background': cssVar('--monaco-gutter'), 'diffEditor.insertedTextBackground': cssVar('--monaco-inserted-text-bg'), 'diffEditor.insertedLineBackground': cssVar('--monaco-inserted-line-bg'), - 'diffEditor.insertedTextBorder': cssVar('--monaco-inserted-text-border'), 'diffEditor.removedTextBackground': cssVar('--monaco-removed-text-bg'), 'diffEditor.removedLineBackground': cssVar('--monaco-removed-line-bg'), - 'diffEditor.removedTextBorder': cssVar('--monaco-removed-text-border'), 'diffEditor.unchangedRegionBackground': cssVar('--monaco-unchanged-region-bg'), 'diffEditor.border': cssVar('--monaco-diff-border'), 'diffEditor.diagonalFill': cssVar('--monaco-diff-diagonal-fill'), @@ -36,9 +34,7 @@ export function defineMonacoThemes(monaco: Monaco): void { 'editorGutter.background': cssVar('--monaco-gutter'), 'diffEditor.insertedTextBackground': cssVar('--monaco-inserted-text-bg'), 'diffEditor.insertedLineBackground': cssVar('--monaco-inserted-line-bg'), - 'diffEditor.insertedTextBorder': cssVar('--monaco-inserted-text-border'), 'diffEditor.removedTextBackground': cssVar('--monaco-removed-text-bg'), - 'diffEditor.removedTextBorder': cssVar('--monaco-removed-text-border'), 'diffEditor.removedLineBackground': cssVar('--monaco-removed-line-bg'), 'diffEditor.unchangedRegionBackground': cssVar('--monaco-unchanged-region-bg'), 'diffEditor.border': cssVar('--monaco-diff-border'), @@ -58,10 +54,8 @@ export function defineMonacoThemes(monaco: Monaco): void { 'editorGutter.background': cssVar('--monaco-gutter'), 'diffEditor.insertedTextBackground': cssVar('--monaco-inserted-text-bg'), 'diffEditor.insertedLineBackground': cssVar('--monaco-inserted-line-bg'), - 'diffEditor.insertedTextBorder': cssVar('--monaco-inserted-text-border'), 'diffEditor.removedTextBackground': cssVar('--monaco-removed-text-bg'), 'diffEditor.removedLineBackground': cssVar('--monaco-removed-line-bg'), - 'diffEditor.removedTextBorder': cssVar('--monaco-removed-text-border'), 'diffEditor.unchangedRegionBackground': cssVar('--monaco-unchanged-region-bg'), 'diffEditor.border': cssVar('--monaco-diff-border'), 'diffEditor.diagonalFill': cssVar('--monaco-diff-diagonal-fill'), From 403a081a806152d420eb5c7489ba61c56a52a09f Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 21 Apr 2026 10:00:14 +0200 Subject: [PATCH 10/22] chore: gitignore build artifacts --- .gitignore | 2 ++ src/main/appConfig.json | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 src/main/appConfig.json diff --git a/.gitignore b/.gitignore index a796abfbab..ff7dafab98 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ Thumbs.db .env .cursor .codex/config.toml + +src/main/appConfig.json \ No newline at end of file diff --git a/src/main/appConfig.json b/src/main/appConfig.json deleted file mode 100644 index 12a855e784..0000000000 --- a/src/main/appConfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "posthogHost": "https://us.i.posthog.com", - "posthogKey": "phc_HwVnk5sjxn8S0xnVyTjEl5S2GAP2kxC8tpbR1qyww9" -} From d516f3581e6e914b1b6b0911ea441ad2c784d112 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 21 Apr 2026 10:13:24 +0200 Subject: [PATCH 11/22] fix: appconfig and merge --- src/main/db/schema.ts | 2 +- src/main/lib/telemetry.ts | 15 ++++++++++++--- .../features/settings/components/AccountTab.tsx | 4 ---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 75ad75bfa8..bb0f26c68d 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -40,7 +40,7 @@ export const projects = sqliteTable( id: text('id').primaryKey(), name: text('name').notNull(), path: text('path').notNull(), - workspaceProvider: text('workspace_provider').notNull().default('local'), // 'local' | 'ssh' | 'vm' + workspaceProvider: text('workspace_provider').notNull().default('local'), // 'local' | 'ssh' baseRef: text('base_ref'), sshConnectionId: text('ssh_connection_id').references(() => sshConnections.id, { onDelete: 'set null', diff --git a/src/main/lib/telemetry.ts b/src/main/lib/telemetry.ts index 6b1a5dc7c6..0575d3dde8 100644 --- a/src/main/lib/telemetry.ts +++ b/src/main/lib/telemetry.ts @@ -1,11 +1,20 @@ import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { app } from 'electron'; import type { TelemetryEnvelope, TelemetryEvent, TelemetryProperties } from '@shared/telemetry'; -import rawAppConfig from '@main/appConfig.json'; import { KV } from '@main/db/kv'; -// Build-time defaults from appConfig.json (bundled by electron-vite) -const appConfig: { posthogHost?: string; posthogKey?: string } = rawAppConfig; +// Production-only: appConfig.json is injected into dist/main/ by the release pipeline. +const appConfig: { posthogHost?: string; posthogKey?: string } = (() => { + if (!import.meta.env.PROD) return {}; + try { + const raw = readFileSync(join(__dirname, 'appConfig.json'), 'utf-8'); + return JSON.parse(raw) as { posthogHost?: string; posthogKey?: string }; + } catch { + return {}; + } +})(); interface InitOptions { installSource?: string; diff --git a/src/renderer/features/settings/components/AccountTab.tsx b/src/renderer/features/settings/components/AccountTab.tsx index 86f816329d..ffbf0afd4e 100644 --- a/src/renderer/features/settings/components/AccountTab.tsx +++ b/src/renderer/features/settings/components/AccountTab.tsx @@ -39,11 +39,7 @@ export function AccountTab() { } toast({ title: 'Signed in to Emdash', -<<<<<<< refactor-rm-reserves-ry2gn - description: `Connected as @${result.user?.username}`, -======= description: result.user ? `Connected as @${result.user.username}` : 'Signed in', ->>>>>>> v1 }); } catch (err) { const message = err instanceof Error ? err.message : 'Sign in failed'; From bd3027554f9f7c39a4a8d45211445f864537d55d Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 21 Apr 2026 11:22:20 +0200 Subject: [PATCH 12/22] chore: remove docs --- docs/.gitignore | 22 - docs/README.md | 14 - docs/app/[[...slug]]/layout.tsx | 12 - docs/app/[[...slug]]/page.tsx | 87 - docs/app/api/search/route.ts | 6 - docs/app/global.css | 18 - docs/app/layout.tsx | 21 - docs/components/Changelog.tsx | 284 -- docs/components/CopyMarkdownButton.tsx | 31 - docs/components/LastUpdated.tsx | 19 - docs/content/docs/best-of-n.mdx | 66 - docs/content/docs/changelog.mdx | 22 - docs/content/docs/ci-checks.mdx | 57 - docs/content/docs/contributing.mdx | 189 -- docs/content/docs/diff-view.mdx | 51 - docs/content/docs/file-editor.mdx | 55 - docs/content/docs/index.mdx | 37 - docs/content/docs/installation.mdx | 59 - docs/content/docs/issues.mdx | 66 - docs/content/docs/kanban-view.mdx | 53 - docs/content/docs/meta.json | 26 - docs/content/docs/project-config.mdx | 137 - docs/content/docs/providers.mdx | 66 - docs/content/docs/remote-projects.mdx | 168 - docs/content/docs/roadmap.mdx | 25 - docs/content/docs/skills.mdx | 114 - docs/content/docs/tasks.mdx | 65 - docs/content/docs/telemetry.mdx | 157 - docs/content/docs/tmux-sessions.mdx | 92 - docs/lib/layout.shared.tsx | 32 - docs/lib/source.ts | 15 - docs/next.config.mjs | 14 - docs/package.json | 34 - docs/pnpm-lock.yaml | 3784 ---------------------- docs/postcss.config.js | 5 - docs/public/brand/favicon.ico | Bin 15406 -> 0 bytes docs/public/brand/icon-dark.png | Bin 8322 -> 0 bytes docs/public/brand/icon-light.png | Bin 34966 -> 0 bytes docs/public/media/addremoteproject.png | Bin 598144 -> 0 bytes docs/public/media/bestofn.png | Bin 163480 -> 0 bytes docs/public/media/bestofn2.png | Bin 30014 -> 0 bytes docs/public/media/cicd.png | Bin 978793 -> 0 bytes docs/public/media/createtask.png | Bin 312154 -> 0 bytes docs/public/media/demo.mp4 | Bin 4364934 -> 0 bytes docs/public/media/diffviewer.png | Bin 1044092 -> 0 bytes docs/public/media/disabletelemetry.png | Bin 183109 -> 0 bytes docs/public/media/downloadforlinux.png | Bin 18033 -> 0 bytes docs/public/media/downloadformacos.png | Bin 13866 -> 0 bytes docs/public/media/downloadforwindows.png | Bin 18902 -> 0 bytes docs/public/media/edit-config.png | Bin 38017 -> 0 bytes docs/public/media/emdash-screenshot.png | Bin 314717 -> 0 bytes docs/public/media/emdash.mp4 | Bin 699559 -> 0 bytes docs/public/media/feedback.png | Bin 189642 -> 0 bytes docs/public/media/fileeditor.png | Bin 996521 -> 0 bytes docs/public/media/kanban.png | Bin 954946 -> 0 bytes docs/public/media/modelselector.png | Bin 34711 -> 0 bytes docs/public/media/openpr.mp4 | Bin 3855542 -> 0 bytes docs/public/media/parallel.mp4 | Bin 641111 -> 0 bytes docs/public/media/passissues.png | Bin 346167 -> 0 bytes docs/public/media/product.jpeg | Bin 275801 -> 0 bytes docs/public/media/providers.png | Bin 97517 -> 0 bytes docs/public/media/remoteproj1.png | Bin 134556 -> 0 bytes docs/public/media/remoteproj2.png | Bin 136509 -> 0 bytes docs/public/media/remoteproj3.png | Bin 162364 -> 0 bytes docs/public/media/skills.png | Bin 838024 -> 0 bytes docs/scripts/copy-md-sources.js | 122 - docs/source.config.ts | 10 - docs/tsconfig.json | 30 - docs/tsconfig.tsbuildinfo | 1 - src/shared/git.ts | 1 - 70 files changed, 6067 deletions(-) delete mode 100644 docs/.gitignore delete mode 100644 docs/README.md delete mode 100644 docs/app/[[...slug]]/layout.tsx delete mode 100644 docs/app/[[...slug]]/page.tsx delete mode 100644 docs/app/api/search/route.ts delete mode 100644 docs/app/global.css delete mode 100644 docs/app/layout.tsx delete mode 100644 docs/components/Changelog.tsx delete mode 100644 docs/components/CopyMarkdownButton.tsx delete mode 100644 docs/components/LastUpdated.tsx delete mode 100644 docs/content/docs/best-of-n.mdx delete mode 100644 docs/content/docs/changelog.mdx delete mode 100644 docs/content/docs/ci-checks.mdx delete mode 100644 docs/content/docs/contributing.mdx delete mode 100644 docs/content/docs/diff-view.mdx delete mode 100644 docs/content/docs/file-editor.mdx delete mode 100644 docs/content/docs/index.mdx delete mode 100644 docs/content/docs/installation.mdx delete mode 100644 docs/content/docs/issues.mdx delete mode 100644 docs/content/docs/kanban-view.mdx delete mode 100644 docs/content/docs/meta.json delete mode 100644 docs/content/docs/project-config.mdx delete mode 100644 docs/content/docs/providers.mdx delete mode 100644 docs/content/docs/remote-projects.mdx delete mode 100644 docs/content/docs/roadmap.mdx delete mode 100644 docs/content/docs/skills.mdx delete mode 100644 docs/content/docs/tasks.mdx delete mode 100644 docs/content/docs/telemetry.mdx delete mode 100644 docs/content/docs/tmux-sessions.mdx delete mode 100644 docs/lib/layout.shared.tsx delete mode 100644 docs/lib/source.ts delete mode 100644 docs/next.config.mjs delete mode 100644 docs/package.json delete mode 100644 docs/pnpm-lock.yaml delete mode 100644 docs/postcss.config.js delete mode 100644 docs/public/brand/favicon.ico delete mode 100644 docs/public/brand/icon-dark.png delete mode 100644 docs/public/brand/icon-light.png delete mode 100644 docs/public/media/addremoteproject.png delete mode 100644 docs/public/media/bestofn.png delete mode 100644 docs/public/media/bestofn2.png delete mode 100644 docs/public/media/cicd.png delete mode 100644 docs/public/media/createtask.png delete mode 100644 docs/public/media/demo.mp4 delete mode 100644 docs/public/media/diffviewer.png delete mode 100644 docs/public/media/disabletelemetry.png delete mode 100644 docs/public/media/downloadforlinux.png delete mode 100644 docs/public/media/downloadformacos.png delete mode 100644 docs/public/media/downloadforwindows.png delete mode 100644 docs/public/media/edit-config.png delete mode 100644 docs/public/media/emdash-screenshot.png delete mode 100644 docs/public/media/emdash.mp4 delete mode 100644 docs/public/media/feedback.png delete mode 100644 docs/public/media/fileeditor.png delete mode 100644 docs/public/media/kanban.png delete mode 100644 docs/public/media/modelselector.png delete mode 100644 docs/public/media/openpr.mp4 delete mode 100644 docs/public/media/parallel.mp4 delete mode 100644 docs/public/media/passissues.png delete mode 100644 docs/public/media/product.jpeg delete mode 100644 docs/public/media/providers.png delete mode 100644 docs/public/media/remoteproj1.png delete mode 100644 docs/public/media/remoteproj2.png delete mode 100644 docs/public/media/remoteproj3.png delete mode 100644 docs/public/media/skills.png delete mode 100644 docs/scripts/copy-md-sources.js delete mode 100644 docs/source.config.ts delete mode 100644 docs/tsconfig.json delete mode 100644 docs/tsconfig.tsbuildinfo diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 906741640e..0000000000 --- a/docs/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -node_modules - -.DS_Store -.cache -.vercel -.output -.nitro -.next -/build/ -/api/ -/server/build -/public/build -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ -.tanstack - -src/routeTree.gen.ts -.source/ -next-env.d.ts -public/md-src/ diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index d219822c4d..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Emdash - -This is a Tanstack Start application generated with -[Create Fumadocs](https://github.com/fuma-nama/fumadocs). - -Run development server: - -```bash -pnpm run dev -# or -pnpm dev -# or -yarn dev -``` diff --git a/docs/app/[[...slug]]/layout.tsx b/docs/app/[[...slug]]/layout.tsx deleted file mode 100644 index db4f0cf258..0000000000 --- a/docs/app/[[...slug]]/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; -import type { ReactNode } from 'react'; -import { baseOptions } from '@/lib/layout.shared'; -import { source } from '@/lib/source'; - -export default function Layout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/docs/app/[[...slug]]/page.tsx b/docs/app/[[...slug]]/page.tsx deleted file mode 100644 index af4996ace4..0000000000 --- a/docs/app/[[...slug]]/page.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { getGithubLastEdit } from 'fumadocs-core/content/github'; -import defaultMdxComponents from 'fumadocs-ui/mdx'; -import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/page'; -import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import { CopyMarkdownButton } from '@/components/CopyMarkdownButton'; -import { LastUpdated } from '@/components/LastUpdated'; -import { source } from '@/lib/source'; - -async function getLastModifiedFromGitHub(filePath: string): Promise { - if (process.env.NODE_ENV === 'development') { - return null; - } - - try { - const time = await getGithubLastEdit({ - owner: 'generalaction', - repo: 'emdash', - path: `docs/content/docs/${filePath}.mdx`, - token: process.env.GIT_TOKEN ? `Bearer ${process.env.GIT_TOKEN}` : undefined, - }); - return time ? new Date(time) : null; - } catch { - return null; - } -} - -export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) { - const { slug } = await params; - const page = source.getPage(slug); - - if (!page) { - notFound(); - } - - const MDX = page.data.body; - - // Prefer plugin-derived lastModified, fallback to GitHub API - let lastModified: Date | undefined = page.data.lastModified; - if (!lastModified) { - const filePath = slug?.join('/') || 'index'; - lastModified = (await getLastModifiedFromGitHub(filePath)) ?? undefined; - } - - return ( - - {page.data.title} - {page.data.description} -
- -
- - - - {lastModified && } -
- ); -} - -export async function generateStaticParams() { - return source.generateParams(); -} - -export async function generateMetadata(props: { - params: Promise<{ slug?: string[] }>; -}): Promise { - const params = await props.params; - const page = source.getPage(params.slug); - - if (!page) notFound(); - - return { - title: page.data.title, - description: page.data.description, - }; -} diff --git a/docs/app/api/search/route.ts b/docs/app/api/search/route.ts deleted file mode 100644 index 97cf05821d..0000000000 --- a/docs/app/api/search/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createFromSource } from 'fumadocs-core/search/server'; -import { source } from '@/lib/source'; - -export const { GET } = createFromSource(source, { - language: 'english', -}); diff --git a/docs/app/global.css b/docs/app/global.css deleted file mode 100644 index b17f8a2e97..0000000000 --- a/docs/app/global.css +++ /dev/null @@ -1,18 +0,0 @@ -@import 'tailwindcss'; -@import 'fumadocs-ui/css/neutral.css'; -@import 'fumadocs-ui/css/preset.css'; - -/* Custom colors from Emdash palette */ -:root { - --color-fd-primary: #c26157; - --color-fd-primary-foreground: #f7fbfc; - --color-fd-accent: #e8ebee; - --color-fd-accent-foreground: #1f2931; -} - -.dark { - --color-fd-primary: #c26157; - --color-fd-primary-foreground: #eef0f2; - --color-fd-accent: #212a2d; - --color-fd-accent-foreground: #eef0f2; -} diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx deleted file mode 100644 index 59df42f33f..0000000000 --- a/docs/app/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import './global.css'; -import { RootProvider } from 'fumadocs-ui/provider/next'; -import type { ReactNode } from 'react'; - -export default function RootLayout({ children }: { children: ReactNode }) { - return ( - - - {children} - - - ); -} - -export const metadata = { - title: 'Emdash - Docs', - description: 'Open source Agentic Development Environment', - icons: { - icon: '/brand/favicon.ico', - }, -}; diff --git a/docs/components/Changelog.tsx b/docs/components/Changelog.tsx deleted file mode 100644 index 8d8ff65487..0000000000 --- a/docs/components/Changelog.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React from 'react'; - -interface GitHubRelease { - id: number; - tag_name: string; - name: string; - body: string | null; - published_at: string; - html_url: string; - draft: boolean; - prerelease: boolean; -} - -async function getGithubReleases(): Promise { - try { - const response = await fetch('https://api.github.com/repos/generalaction/emdash/releases', { - headers: { - Accept: 'application/vnd.github.v3+json', - ...(process.env.GITHUB_TOKEN && { - Authorization: `token ${process.env.GITHUB_TOKEN}`, - }), - }, - next: { revalidate: 3600 }, // Cache for 1 hour - }); - - if (!response.ok) { - console.error('GitHub API error:', response.status); - return []; - } - - const releases = await response.json(); - return releases.filter((r: GitHubRelease) => !r.draft); - } catch (error) { - console.error('Failed to fetch releases:', error); - return []; - } -} - -function formatDate(dateString: string): string { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); -} - -function formatVersion(tagName: string): string { - return tagName.startsWith('v') ? tagName.substring(1) : tagName; -} - -export async function Changelog() { - const releases = await getGithubReleases(); - - if (releases.length === 0) { - return ( -
-

- No releases found. Check the{' '} - - GitHub Releases - {' '} - page. -

-
- ); - } - - return ( -
- {releases.slice(0, 20).map((release) => ( -
-
-

- - v{formatVersion(release.tag_name)} - -

- -
- - {release.body ? ( -
- -
- ) : ( -

No release notes available.

- )} -
- ))} -
- ); -} - -function ReleaseNotes({ content }: { content: string }) { - // Process content line by line, handling both markdown and HTML - const lines = content.split('\n'); - const elements: React.ReactNode[] = []; - let currentListItems: React.ReactNode[] = []; - let inCodeBlock = false; - let codeBlockContent: string[] = []; - let codeBlockStartIndex = 0; - - // Helper function to flush list items - const flushListItems = () => { - if (currentListItems.length > 0) { - elements.push( -
    - {currentListItems} -
- ); - currentListItems = []; - } - }; - - // Helper function to process links in text - const processLinks = (text: string): string => { - return ( - text - // Handle GitHub URLs, but not if they're followed by punctuation - .replace( - /https:\/\/github\.com\/[^\s\)\]]+/g, - (url) => - `${url.replace(/[.,;!?]+$/, '')}` - ) - // Handle @mentions, but avoid matching email addresses - // Look for @mentions that are preceded by whitespace or start of string - .replace( - /(^|[\s\(])@(\w+)(?=\s|$|[^\w@])/g, - (match, prefix, username) => - `${prefix}@${username}` - ) - ); - }; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Check for code block start/end - if (line.startsWith('```')) { - if (inCodeBlock) { - // End code block - elements.push( -
-            {codeBlockContent.join('\n')}
-          
- ); - codeBlockContent = []; - inCodeBlock = false; - } else { - // Start code block - flushListItems(); // Flush any pending list items - inCodeBlock = true; - codeBlockStartIndex = i; - } - continue; - } - - // If we're in a code block, just collect the content - if (inCodeBlock) { - codeBlockContent.push(line); - continue; - } - - // Handle HTML img tags - if (line.includes(']+>/); - if (imgMatch) { - const srcMatch = imgMatch[0].match(/src="([^"]+)"/); - const altMatch = imgMatch[0].match(/alt="([^"]+)"/); - const widthMatch = imgMatch[0].match(/width="([^"]+)"/); - const heightMatch = imgMatch[0].match(/height="([^"]+)"/); - - if (srcMatch) { - elements.push( -
- {altMatch -
- ); - continue; - } - } - } - - // Headers - if (line.startsWith('## ')) { - flushListItems(); // Flush any pending list items - elements.push( -

- {line.substring(3)} -

- ); - continue; - } - if (line.startsWith('### ')) { - flushListItems(); // Flush any pending list items - elements.push( -

- {line.substring(4)} -

- ); - continue; - } - - // List items (support both * and - prefixes) - if (line.startsWith('* ') || line.startsWith('- ')) { - const item = line.substring(2); - const withLinks = processLinks(item); - currentListItems.push(
  • ); - continue; - } - - // If we hit a non-list item, flush any pending list items - if (currentListItems.length > 0 && !line.startsWith(' ')) { - flushListItems(); - } - - // Bold text (like **New Contributors**) - if (line.includes('**')) { - flushListItems(); // Flush any pending list items - const formatted = line.replace(/\*\*(.*?)\*\*/g, '$1'); - const withLinks = processLinks(formatted); - elements.push( -

    - ); - continue; - } - - // Full Changelog link - if (line.includes('Full Changelog:')) { - flushListItems(); // Flush any pending list items - const match = line.match(/https:\/\/github\.com\/[^\s\)\]]+/); - if (match) { - elements.push( -

    - Full Changelog:{' '} - - View diff - -

    - ); - continue; - } - } - - // Empty lines - if (line.trim() === '') { - flushListItems(); // Flush any pending list items - continue; - } - - // Regular text with link processing - flushListItems(); // Flush any pending list items - const withLinks = processLinks(line); - elements.push(

    ); - } - - // Flush any remaining list items - flushListItems(); - - return <>{elements}; -} diff --git a/docs/components/CopyMarkdownButton.tsx b/docs/components/CopyMarkdownButton.tsx deleted file mode 100644 index f6d8fc0e26..0000000000 --- a/docs/components/CopyMarkdownButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client'; - -import * as React from 'react'; - -export function CopyMarkdownButton({ markdownUrl }: { markdownUrl: string }) { - const [state, setState] = React.useState<'idle' | 'copied' | 'error'>('idle'); - - async function onCopy() { - try { - setState('idle'); - const res = await fetch(markdownUrl, { cache: 'no-store' }); - if (!res.ok) throw new Error(`Failed to fetch markdown: ${res.status}`); - const md = await res.text(); - await navigator.clipboard.writeText(md); - setState('copied'); - window.setTimeout(() => setState('idle'), 1200); - } catch { - setState('error'); - window.setTimeout(() => setState('idle'), 1500); - } - } - - return ( - - ); -} diff --git a/docs/components/LastUpdated.tsx b/docs/components/LastUpdated.tsx deleted file mode 100644 index b0670491ad..0000000000 --- a/docs/components/LastUpdated.tsx +++ /dev/null @@ -1,19 +0,0 @@ -export function LastUpdated({ date }: { date: Date }) { - if (!date || isNaN(date.getTime())) { - return null; - } - - const formatted = new Intl.DateTimeFormat(undefined, { - year: 'numeric', - month: 'long', - day: 'numeric', - }).format(date); - - return ( -

    -
    - Last updated on {formatted} -
    -
    - ); -} diff --git a/docs/content/docs/best-of-n.mdx b/docs/content/docs/best-of-n.mdx deleted file mode 100644 index 5c730617f8..0000000000 --- a/docs/content/docs/best-of-n.mdx +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Best of N -description: Run multiple agents in parallel and pick the best result ---- - -Run multiple agents on the same task, let them work in parallel, then pick the best output. Each agent gets its own worktree so they don't interfere with each other. - -Running multiple agents in parallel - -## Why Use It - -Different agents have different strengths. Running them in parallel lets you: - -- Compare solutions from different providers (Claude vs Codex vs Gemini) -- Get multiple attempts at a tricky problem -- Race agents to see which finishes first - -## How to Start - -When creating a task: - -1. Click the provider dropdown -2. Select multiple providers, or increase the count for a single provider (e.g., 3× Claude Code) -3. Click Create - -Emdash creates a separate git branch and worktree for each agent. They all start from the same base commit but work independently. - -Multiple agents working in separate worktrees - -## Working with Multiple Agents - -Each agent spawns in its own terminal tab. Switch between tabs to watch them work, or use the shared input bar to send the same message to all of them. - -## Comparing Results - -After agents finish, review their changes in the [diff view](/diff-view). Each agent's worktree has its own set of changes. Check the diff stats (files changed, lines added/removed) for a quick sense of each approach. - -Pick the best solution and merge that branch. Discard the rest. - -## Tips - -- Start with 2-3 agents. More than that gets hard to compare. -- Use the same initial prompt for fair comparison. -- Complex tasks benefit most from multiple perspectives. diff --git a/docs/content/docs/changelog.mdx b/docs/content/docs/changelog.mdx deleted file mode 100644 index 1a0eec111a..0000000000 --- a/docs/content/docs/changelog.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: Changelog -description: Version history and release notes for Emdash ---- - -import { Changelog } from '../../components/Changelog'; - - - ---- - -## Release Process - -Emdash follows [Semantic Versioning](https://semver.org/): - -- **Patch versions** (0.0.x): Bug fixes and minor improvements -- **Minor versions** (0.x.0): New features and capabilities -- **Major versions** (x.0.0): Breaking changes - -## Contributing - -See our [Contributing Guide](/contributing) to learn how you can help improve Emdash. diff --git a/docs/content/docs/ci-checks.mdx b/docs/content/docs/ci-checks.mdx deleted file mode 100644 index ad892b4389..0000000000 --- a/docs/content/docs/ci-checks.mdx +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: CI/CD Checks -description: Monitor GitHub Actions check runs directly in Emdash ---- - -Emdash surfaces your GitHub Actions CI/CD status inside the app so you can monitor check runs without switching to a browser. - -CI/CD checks panel in Emdash - -## Viewing Checks - -Once a task has a Pull Request, a **Checks** tab appears in the file changes panel. Click it to see all check runs for that PR's branch. - -Each check shows: - -- **Status**: Green checkmark (pass), red X (fail), amber spinner (pending), or grey dash (skipped/cancelled) -- **Name and workflow**: Which check ran and from which workflow -- **Duration**: How long the check took -- **External link**: Click to open the full check run on GitHub - -A summary at the top shows the total count of passed, failed, and pending checks. - -## Auto-Refresh - -Emdash automatically polls for check updates: - -- **Every 10 seconds** while checks are still running -- **Every 60 seconds** once all checks complete -- **On window focus** — checks refresh when you switch back to Emdash - -Polling pauses when the app is in the background to save resources. - -## Requirements - -- The [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated -- The task's branch must have an open Pull Request on GitHub -- Your repository must have GitHub Actions workflows configured - -If the Checks tab is disabled, it means no PR exists for the current task yet. Push a branch and open a PR to start seeing checks. - -## Tips - -- The Checks tab automatically activates when a PR exists but there are no local uncommitted changes. -- Failed checks show a red dot on the tab badge so you can spot failures at a glance. -- A spinning indicator on the tab badge means checks are still in progress. -- Click the external link icon on any check to jump straight to the run details on GitHub. diff --git a/docs/content/docs/contributing.mdx b/docs/content/docs/contributing.mdx deleted file mode 100644 index d78e6a5f28..0000000000 --- a/docs/content/docs/contributing.mdx +++ /dev/null @@ -1,189 +0,0 @@ ---- -title: Contributing -description: Help improve Emdash - contribution guidelines and development setup ---- - -Thanks for your interest in contributing! We favor small, focused PRs and clear intent over big bangs. This guide explains how to get set up, the workflow we use, and a few project‑specific conventions. - -## Quick Start - -### Prerequisites - -- **Node.js 20.0.0+ (recommended: 22.20.0)** and Git -- Optional (recommended for end‑to‑end testing): - - GitHub CLI (`brew install gh`; then `gh auth login`) - - At least one supported coding agent CLI (see [Providers](/providers)) - -### Setup - -```bash -# Fork this repo, then clone your fork -git clone https://github.com//emdash.git -cd emdash - -# Use the correct Node.js version (if using nvm) -nvm use - -# Quick start: install dependencies and run dev server -pnpm run d - -# Or run separately: -pnpm install -pnpm run dev - -# Type checking, lint, build -pnpm run typecheck -pnpm run lint -pnpm run build -``` - -**Tip:** During development, the renderer hot‑reloads. Changes to the Electron main process (files in `src/main`) require a restart of the dev app. - -## Project Overview - -- `src/main/` – Electron main process, IPC handlers, services (Git, worktrees, PTY manager, DB, etc.) -- `src/renderer/` – React UI (Vite), hooks, components -- Local database – SQLite file created under the OS userData folder (see "Local DB" below) -- Worktrees – Git worktrees are created outside your repo root in a sibling `worktrees/` folder -- Logs – Agent terminal output and app logs are written to the OS userData folder (not inside repos) - -## Development Workflow - -### 1. Create a feature branch - -```bash -git checkout -b feat/ -``` - -### 2. Make changes and keep PRs small and focused - -- Prefer a series of small PRs over one large one. -- Include UI screenshots/GIFs when modifying the interface. -- Update docs (README or inline help) when behavior changes. - -### 3. Run checks locally - -```bash -pnpm run format # Format code with Prettier (required) -pnpm run typecheck # TypeScript type checking -pnpm run lint # ESLint -pnpm run build # Build both main and renderer -``` - -Pre-commit hooks run automatically via Husky + lint-staged. On each commit, staged files are auto-formatted with Prettier and linted with ESLint. You don't need to remember to run these manually. Type checking and tests run in CI only since they need the full project context and are slower to execute. - -If you need to skip the hook for a work-in-progress commit, use `git commit --no-verify`. The checks will still run in CI when you open a PR. - -### 4. Commit using Conventional Commits - -- `feat:` – new user‑facing capability -- `fix:` – bug fix -- `chore:`, `refactor:`, `docs:`, `perf:`, `test:` etc. - -**Examples:** - -``` -fix(opencode): change initialPromptFlag from -p to --prompt for TUI - -feat(docs): add changelog tab with GitHub releases integration -``` - -### 5. Open a Pull Request - -- Describe the change, rationale, and testing steps. -- Link related Issues. -- Keep the PR title in Conventional Commit format if possible. - -## Code Style and Patterns - -### TypeScript + ESLint + Prettier - -Pre-commit hooks handle formatting and linting automatically on staged files. For full-project checks you can run them manually: - -- `pnpm run format` -- format all files with Prettier -- `pnpm run typecheck` -- TypeScript type checking (whole project) -- `pnpm run lint` -- ESLint across all files -- `pnpm exec vitest run` -- run the test suite - -### Electron main (Node side) - -- Prefer `execFile` over `exec` to avoid shell quoting issues. -- Never write logs into Git worktrees. All logs belong in the Electron `userData` folder. -- Be conservative with console logging; noisy logs reduce signal. Use clear prefixes. - -### Git and worktrees - -- The app creates worktrees in a sibling `../worktrees/` folder. -- Do not delete worktree folders from Finder/Explorer; if you need cleanup, use: - - `git worktree prune` (from the main repo) - - or the in‑app workspace removal - -### Documentation - -When writing or updating docs, keep the tone clear and conversational. Use complete sentences and natural language rather than long bullet point lists. Avoid em dashes (—); use commas, periods, or rephrase instead. - -### Renderer (React) - -- Components live under `src/renderer/components`; hooks under `src/renderer/hooks`. -- Agent CLIs are embedded via terminal emulation (xterm.js) - each agent runs in its own PTY. -- Use existing UI primitives and Tailwind utility classes for consistency. -- Aim for accessible elements (labels, `aria-*` where appropriate). - -### Local DB (SQLite) - -**Location** (Electron `app.getPath('userData')`): - -- macOS: `~/Library/Application Support/emdash/emdash.db` -- Linux: `~/.config/emdash/emdash.db` -- Windows: `%APPDATA%\emdash\emdash.db` - -**Reset:** quit the app, delete the file, relaunch (the schema is recreated). - -## Issue Reports and Feature Requests - -Use GitHub Issues. Include: - -- OS, Node version -- Steps to reproduce -- Relevant logs (renderer console, terminal output) -- Screenshots/GIFs for UI issues - -## Release Process (maintainers) - -Use pnpm's built-in versioning to ensure consistency: - -```bash -# For bug fixes (0.2.9 → 0.2.10) -pnpm version patch - -# For new features (0.2.9 → 0.3.0) -pnpm version minor - -# For breaking changes (0.2.9 → 1.0.0) -pnpm version major -``` - -This automatically: - -1. Updates `package.json` and `pnpm-lock.yaml` -2. Creates a git commit with the version number (e.g., `"0.2.10"`) -3. Creates a git tag (e.g., `v0.2.10`) - -Then push to trigger the CI/CD pipeline. - -### What happens next - -Two GitHub Actions workflows trigger on version tags: - -**macOS Release** (`.github/workflows/release.yml`): - -1. Builds the TypeScript and Vite bundles -2. Signs the app with Apple Developer ID -3. Notarizes via Apple's notary service -4. Creates a GitHub Release with DMG artifacts for arm64 and x64 - -**Linux/Nix Build** (`.github/workflows/nix-build.yml`): - -1. Computes the correct dependency hash from `pnpm-lock.yaml` -2. Builds the x86_64-linux package via Nix flake -3. Pushes build artifacts to Cachix diff --git a/docs/content/docs/diff-view.mdx b/docs/content/docs/diff-view.mdx deleted file mode 100644 index 8c8ad22d8d..0000000000 --- a/docs/content/docs/diff-view.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Diff View -description: Review and manage code changes ---- - -The diff view shows all file changes in a task's worktree. Stage files, review diffs, write commit messages, and create pull requests from one panel. - -Diff view interface in Emdash - -## Where to Find It - -The diff view lives in the right sidebar when you have a task open. It updates automatically as the agent makes changes (polls every 5 seconds). - -## What You See - -For each changed file: - -- File path and type icon -- Lines added (green) and removed (red) -- Staged or unstaged status - -The header shows total files changed, overall additions/deletions, and PR status if a pull request exists. - -## Actions - -**Stage a file**: Click the + icon to add a file to the staging area. - -**Unstage or revert**: Click the undo icon. For staged files, this unstages them. For unstaged files, this discards all changes (resets to last commit). - -**View diff**: Click a file to open it in the diff viewer. You can edit the file directly in the diff view and save your changes. - -**View all changes**: Click "View All" to see diffs for every changed file in one scrollable view. - -**Commit and push**: Type a commit message and press Enter. Emdash commits staged changes and pushes to the branch. - -**Create PR**: After pushing, a "Create PR" button appears if your branch is ahead of main. - -## Inline Editing - -The diff viewer isn't read-only. Edit the modified version directly, then save. This is useful for quick fixes without switching to your editor. diff --git a/docs/content/docs/file-editor.mdx b/docs/content/docs/file-editor.mdx deleted file mode 100644 index 3243aa564a..0000000000 --- a/docs/content/docs/file-editor.mdx +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: File Editor -description: Edit files directly in Emdash ---- - -The file editor lets you browse and edit code in a task's worktree without switching to an external editor. Use it to explore the codebase, make edits yourself, or review what an agent has done. - -File editor interface in Emdash - -## Opening the Editor - -Click the code icon in the titlebar when viewing a task. The editor opens in a side panel with a file tree on the left and the code editor on the right. - -## Navigating Files - -The left panel shows your project's file tree. Click a file to open it. Files open in tabs at the top of the editor, so you can switch between multiple files. - -Common system directories like `node_modules`, `.git`, and build output are hidden by default. Click the eye icon to show or hide them. - -## Editing - -The editor is a full-featured code editor with syntax highlighting, find/replace, and the usual keyboard shortcuts. Changes auto-save after 2 seconds of inactivity, or press `⌘S` to save immediately. - -Git diff markers appear in the gutter: - -- Green dots for added lines -- Orange dots for modified lines -- Red markers for deleted lines - -These update automatically as you edit, so you can see what's changed compared to the last commit. - -## Saving - -Files with unsaved changes show a dot in their tab. Use `⌘⇧S` to save all open files at once, or click "Save All" in the header. - -After saving, the [diff view](/diff-view) updates to reflect your changes. - -## Images - -The editor also handles images. Click an image file to preview it instead of showing raw bytes. - -## Resizing - -Drag the divider between the file tree and editor to adjust panel widths. The layout remembers your preference. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx deleted file mode 100644 index b1caebf069..0000000000 --- a/docs/content/docs/index.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Emdash Overview -description: An Open Source Agentic Development Environment (ADE) ---- - -Emdash is an open source desktop app for running multiple coding agents in parallel. Each agent works in an isolated Git worktree, so they don't interfere with each other. - -## Capabilities - -- **[Parallel agents](/parallel-agents):** Run multiple agents simultaneously, each in its own worktree -- **[Provider support](/providers):** Use any of 18+ CLI-based agents: Claude Code, Codex, Gemini, OpenCode, and more -- **[Best-of-N](/best-of-n):** Run multiple agents on the same task and pick the best result -- **[Diff view](/diff-view):** Review changes across agents side-by-side -- **[Kanban view](/kanban-view):** Organize tasks visually across your workflow -- **[Issue integration](/issues):** Pull tasks from Linear, Jira, or GitHub Issues directly - -## How It Works - -Click "Add Task" to create one or more worktrees. Each worktree runs its own agent. You can then review the diff when done, iterate if needed, and open a PR inside of Emdash. - -## Demo - -