Skip to content

Commit 9938fa4

Browse files
feat(desktop): workspace for each design projects (#173)
## Summary - Adds a `NewDesignDialog` modal that appears whenever the user clicks "New Design" (Recent tab, Design Switcher, Designs view) - The dialog lets the user pick a workspace folder via the native file picker, shows the chosen path, and offers a "Change" button - Skipping is allowed; "Skip" creates the design without a bound workspace - Workspace scope is per-design only ## Type of change - [x] New feature ## Linked issue close #133 ## Checklist - [x] I read [`docs/VISION.md`](../docs/VISION.md), [`docs/PRINCIPLES.md`](../docs/PRINCIPLES.md), and [`CLAUDE.md`](../CLAUDE.md) before starting - [x] Commits are signed with DCO (`git commit -s`) - [x] `pnpm lint && pnpm typecheck && pnpm test` passes locally - [x] Added/updated tests for the change - [x] Added a changeset (`pnpm changeset`) if user-visible - [x] Updated docs if behavior changed ## Changes - `store.ts` — new `newDesignDialogOpen` state + open/close actions; `createNewDesign` now accepts optional `workspacePath` - `NewDesignDialog.tsx` — new modal component (created) - `App.tsx` — mounts `<NewDesignDialog />` - `RecentTab.tsx`, `DesignSwitcher.tsx`, `DesignsView.tsx` — call `openNewDesignDialog()` instead of `createNewDesign` directly - `FilesTabView.tsx` — WorkspaceSection single-row layout, fire-and-forget bug fixed - `en.json` / `zh-CN.json` — new `canvas.newDesignDialog.*` i18n keys - `snapshots-db.ts` — pre-existing `getLogger()` missing-scope fix - `FilesPanel.test.tsx` — pre-existing lint/type errors fixed ## Screenshots / recordings (UI changes) <img width="1918" height="1006" alt="image" src="https://github.com/user-attachments/assets/c91a862d-89a4-4373-9e93-344ce148e0f5" /> <img width="1920" height="1026" alt="image" src="https://github.com/user-attachments/assets/5eebab71-6472-4b1b-963a-eec79378be63" /> <img width="1920" height="1020" alt="image" src="https://github.com/user-attachments/assets/ecd8b373-144e-43a1-88a7-2f3751b1d136" /> --------- Signed-off-by: roy1994 <dev.mancitrus@outlook.com> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 8f596f6 commit 9938fa4

30 files changed

Lines changed: 3180 additions & 303 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@open-codesign/desktop': minor
3+
---
4+
5+
feat(desktop): per-design workspace folder linking
6+
7+
Users can now bind any open design to a local folder directly from the Files panel. Every file the agent writes is mirrored to that folder in real time, and the binding persists across restarts.
8+
9+
- **Bind on first use** — click "Choose folder" in the Files panel to pick a workspace directory; the design is linked immediately and files are synced.
10+
- **Rebind with migration** — choosing a different folder prompts a confirmation dialog; existing tracked files are copied to the new location before the binding switches.
11+
- **Clear binding** — a "Disconnect folder" action removes the link without touching files on disk.
12+
- **Error surfacing** — write-through failures and IPC errors (migration collision, missing tracked file) are now reported to the UI instead of being silently swallowed.
13+
- **Cross-platform path comparison** — the rebind dialog no longer triggers falsely when paths differ only by trailing slash or directory-separator style.

apps/desktop/src/main/design-files.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
listDesignFilesInDir,
1515
normalizeDesignFilePath,
1616
strReplaceInDesignFile,
17+
upsertDesignFile,
1718
viewDesignFile,
1819
} from './snapshots-db';
1920

@@ -100,6 +101,30 @@ describe('insert', () => {
100101
});
101102
});
102103

104+
describe('upsert', () => {
105+
it('creates a missing design file', () => {
106+
const { db, designId } = seed();
107+
108+
const file = upsertDesignFile(db, designId, 'nested\\index.html', '<main>first</main>');
109+
110+
expect(file.path).toBe('nested/index.html');
111+
expect(file.content).toBe('<main>first</main>');
112+
expect(viewDesignFile(db, designId, 'nested/index.html')?.content).toBe('<main>first</main>');
113+
});
114+
115+
it('updates an existing design file in place', () => {
116+
const { db, designId } = seed();
117+
const created = createDesignFile(db, designId, 'index.html', '<main>before</main>');
118+
119+
const updated = upsertDesignFile(db, designId, 'index.html', '<main>after</main>');
120+
121+
expect(updated.id).toBe(created.id);
122+
expect(updated.content).toBe('<main>after</main>');
123+
expect(updated.updatedAt >= created.updatedAt).toBe(true);
124+
expect(viewDesignFile(db, designId, 'index.html')?.content).toBe('<main>after</main>');
125+
});
126+
});
127+
103128
describe('list', () => {
104129
it('lists files by design', () => {
105130
const { db, designId } = seed();
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import {
6+
createDesign,
7+
createDesignFile,
8+
initInMemoryDb,
9+
updateDesignWorkspace,
10+
} from './snapshots-db';
11+
12+
vi.mock('electron', () => ({
13+
dialog: {
14+
showOpenDialog: vi.fn(),
15+
},
16+
shell: {
17+
openPath: vi.fn(),
18+
},
19+
}));
20+
21+
import { dialog, shell } from 'electron';
22+
import {
23+
bindWorkspace,
24+
normalizeWorkspacePath,
25+
openWorkspaceFolder,
26+
pickWorkspaceFolder,
27+
} from './design-workspace';
28+
29+
const showOpenDialog = vi.mocked(dialog.showOpenDialog);
30+
const openPath = vi.mocked(shell.openPath);
31+
32+
const tempDirs: string[] = [];
33+
34+
async function withMockedPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
35+
const original = Object.getOwnPropertyDescriptor(process, 'platform');
36+
Object.defineProperty(process, 'platform', {
37+
value: platform,
38+
configurable: true,
39+
});
40+
try {
41+
return await run();
42+
} finally {
43+
if (original) {
44+
Object.defineProperty(process, 'platform', original);
45+
}
46+
}
47+
}
48+
49+
async function makeTempDir(prefix: string): Promise<string> {
50+
const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
51+
tempDirs.push(dir);
52+
return dir;
53+
}
54+
55+
async function writeWorkspaceFile(
56+
root: string,
57+
relativePath: string,
58+
content: string,
59+
): Promise<void> {
60+
const filePath = path.join(root, relativePath);
61+
await mkdir(path.dirname(filePath), { recursive: true });
62+
await writeFile(filePath, content, 'utf8');
63+
}
64+
65+
afterEach(async () => {
66+
showOpenDialog.mockReset();
67+
openPath.mockReset();
68+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
69+
});
70+
71+
describe('normalizeWorkspacePath', () => {
72+
it('strips trailing slash, resolves absolute path, and normalizes separators', () => {
73+
const relative = path.join('tmp', 'designs', '..', 'designs', 'workspace') + path.sep;
74+
const normalized = normalizeWorkspacePath(relative);
75+
76+
expect(path.isAbsolute(normalized)).toBe(true);
77+
expect(normalized).toBe(path.resolve('tmp/designs/workspace').replaceAll('\\', '/'));
78+
expect(normalized.endsWith('/')).toBe(false);
79+
});
80+
});
81+
82+
describe('pickWorkspaceFolder', () => {
83+
it('returns the selected folder path', async () => {
84+
showOpenDialog.mockResolvedValue({
85+
canceled: false,
86+
filePaths: ['/tmp/workspace'],
87+
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);
88+
89+
await expect(pickWorkspaceFolder({} as never)).resolves.toBe('/tmp/workspace');
90+
});
91+
92+
it('returns null when the picker is canceled', async () => {
93+
showOpenDialog.mockResolvedValue({
94+
canceled: true,
95+
filePaths: [],
96+
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);
97+
98+
await expect(pickWorkspaceFolder({} as never)).resolves.toBeNull();
99+
});
100+
});
101+
102+
describe('openWorkspaceFolder', () => {
103+
it('opens the folder in the OS file manager', async () => {
104+
openPath.mockResolvedValue('');
105+
106+
await expect(openWorkspaceFolder('/tmp/workspace')).resolves.toBeUndefined();
107+
expect(openPath).toHaveBeenCalledWith('/tmp/workspace');
108+
});
109+
110+
it('throws when Electron reports an open error', async () => {
111+
openPath.mockResolvedValue('no application is associated');
112+
113+
await expect(openWorkspaceFolder('/tmp/workspace')).rejects.toThrow(
114+
'Failed to open workspace folder: no application is associated',
115+
);
116+
});
117+
});
118+
119+
describe('bindWorkspace', () => {
120+
it('returns the current design unchanged when rebinding the same normalized path', async () => {
121+
const db = initInMemoryDb();
122+
const design = createDesign(db);
123+
const workspace = await makeTempDir('ocd-ws-same-');
124+
const normalized = normalizeWorkspacePath(workspace);
125+
const bound = updateDesignWorkspace(db, design.id, normalized);
126+
await writeWorkspaceFile(workspace, 'tracked.txt', 'tracked');
127+
createDesignFile(db, design.id, 'tracked.txt', 'tracked');
128+
const destinationBefore = await stat(path.join(workspace, 'tracked.txt'));
129+
130+
const rebound = await bindWorkspace(db, design.id, `${workspace}${path.sep}`, true);
131+
132+
expect(rebound).toEqual(bound);
133+
expect(await stat(path.join(workspace, 'tracked.txt'))).toEqual(destinationBefore);
134+
});
135+
136+
it('throws when another active design already owns the workspace path', async () => {
137+
const db = initInMemoryDb();
138+
const design = createDesign(db);
139+
const otherDesign = createDesign(db);
140+
const conflictPath = normalizeWorkspacePath(await makeTempDir('ocd-ws-conflict-'));
141+
updateDesignWorkspace(db, otherDesign.id, conflictPath);
142+
143+
await expect(bindWorkspace(db, design.id, conflictPath, false)).rejects.toThrow(
144+
'Workspace path is already bound to another design',
145+
);
146+
expect(db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(design.id)).toEqual({
147+
workspace_path: null,
148+
});
149+
});
150+
151+
it('treats case-only workspace differences as the same path on Windows for the same design', async () => {
152+
await withMockedPlatform('win32', async () => {
153+
const db = initInMemoryDb();
154+
const design = createDesign(db);
155+
const storedPath = normalizeWorkspacePath('/Users/Roy/Workspace');
156+
updateDesignWorkspace(db, design.id, storedPath);
157+
158+
const rebound = await bindWorkspace(db, design.id, '/users/roy/workspace/', false);
159+
160+
expect(rebound.workspacePath).toBe(storedPath);
161+
expect(db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(design.id)).toEqual({
162+
workspace_path: storedPath,
163+
});
164+
});
165+
});
166+
167+
it('treats case-only workspace differences as conflicts on Windows across designs', async () => {
168+
await withMockedPlatform('win32', async () => {
169+
const db = initInMemoryDb();
170+
const design = createDesign(db);
171+
const otherDesign = createDesign(db);
172+
updateDesignWorkspace(db, otherDesign.id, normalizeWorkspacePath('/Users/Roy/Workspace'));
173+
174+
await expect(bindWorkspace(db, design.id, '/users/roy/workspace', false)).rejects.toThrow(
175+
'Workspace path is already bound to another design',
176+
);
177+
});
178+
});
179+
180+
it('copies tracked files only during migration', async () => {
181+
const db = initInMemoryDb();
182+
const design = createDesign(db);
183+
const source = await makeTempDir('ocd-ws-source-');
184+
const destination = await makeTempDir('ocd-ws-dest-');
185+
updateDesignWorkspace(db, design.id, normalizeWorkspacePath(source));
186+
createDesignFile(db, design.id, 'tracked.txt', 'tracked root');
187+
createDesignFile(db, design.id, 'nested/child.txt', 'tracked nested');
188+
await writeWorkspaceFile(source, 'tracked.txt', 'tracked root');
189+
await writeWorkspaceFile(source, 'nested/child.txt', 'tracked nested');
190+
await writeWorkspaceFile(source, 'ignored.txt', 'untracked');
191+
192+
const updated = await bindWorkspace(db, design.id, destination, true);
193+
194+
expect(updated.workspacePath).toBe(normalizeWorkspacePath(destination));
195+
expect(await readFile(path.join(destination, 'tracked.txt'), 'utf8')).toBe('tracked root');
196+
expect(await readFile(path.join(destination, 'nested/child.txt'), 'utf8')).toBe(
197+
'tracked nested',
198+
);
199+
await expect(readFile(path.join(destination, 'ignored.txt'), 'utf8')).rejects.toMatchObject({
200+
code: 'ENOENT',
201+
});
202+
expect(await readFile(path.join(source, 'tracked.txt'), 'utf8')).toBe('tracked root');
203+
expect(await readFile(path.join(source, 'ignored.txt'), 'utf8')).toBe('untracked');
204+
});
205+
206+
it('aborts migration on destination collision and leaves the binding unchanged', async () => {
207+
const db = initInMemoryDb();
208+
const design = createDesign(db);
209+
const source = await makeTempDir('ocd-ws-source-');
210+
const destination = await makeTempDir('ocd-ws-dest-');
211+
const sourcePath = normalizeWorkspacePath(source);
212+
updateDesignWorkspace(db, design.id, sourcePath);
213+
createDesignFile(db, design.id, 'tracked.txt', 'tracked root');
214+
await writeWorkspaceFile(source, 'tracked.txt', 'tracked root');
215+
await writeWorkspaceFile(destination, 'tracked.txt', 'existing destination');
216+
217+
await expect(bindWorkspace(db, design.id, destination, true)).rejects.toThrow(
218+
'Workspace migration collision: tracked.txt',
219+
);
220+
expect(db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(design.id)).toEqual({
221+
workspace_path: sourcePath,
222+
});
223+
expect(await readFile(path.join(destination, 'tracked.txt'), 'utf8')).toBe(
224+
'existing destination',
225+
);
226+
});
227+
228+
it('clears the workspace binding without touching the filesystem', async () => {
229+
const db = initInMemoryDb();
230+
const design = createDesign(db);
231+
const source = await makeTempDir('ocd-ws-clear-');
232+
const normalizedSource = normalizeWorkspacePath(source);
233+
updateDesignWorkspace(db, design.id, normalizedSource);
234+
await writeWorkspaceFile(source, 'tracked.txt', 'tracked root');
235+
const beforeEntries = await readdir(source);
236+
237+
const cleared = await bindWorkspace(db, design.id, null, false);
238+
239+
expect(cleared.workspacePath).toBeNull();
240+
expect(await readdir(source)).toEqual(beforeEntries);
241+
});
242+
});

0 commit comments

Comments
 (0)