Skip to content

Commit 75f2e2a

Browse files
authored
feat(workspace): read existing files in bound folders + visible Files panel (#271)
Treat bound workspace folders as the design source of truth: seed existing text files into the agent runtime, serve workspace assets through workspace://, expose real files in the Files panel, and support workspace file tabs. Includes bounded async workspace seeding, workspace protocol hardening, and symlink escape protection for workspace:// file serving. Co-authored-by: Musson <6579209+mussonking@users.noreply.github.com> Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 76a0043 commit 75f2e2a

24 files changed

Lines changed: 1578 additions & 131 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@open-codesign/desktop": patch
3+
"@open-codesign/ui": patch
4+
---
5+
6+
Expose bound workspace files to the agent and preview before the first generation, including workspace asset loading through `workspace://`, with bounded async workspace seeding.

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

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,19 +133,22 @@ describe('bindWorkspace', () => {
133133
expect(await stat(path.join(workspace, 'tracked.txt'))).toEqual(destinationBefore);
134134
});
135135

136-
it('throws when another active design already owns the workspace path', async () => {
136+
it('allows another design to share an already-bound workspace path', async () => {
137137
const db = initInMemoryDb();
138138
const design = createDesign(db);
139139
const otherDesign = createDesign(db);
140-
const conflictPath = normalizeWorkspacePath(await makeTempDir('ocd-ws-conflict-'));
141-
updateDesignWorkspace(db, otherDesign.id, conflictPath);
140+
const sharedPath = normalizeWorkspacePath(await makeTempDir('ocd-ws-shared-'));
141+
updateDesignWorkspace(db, otherDesign.id, sharedPath);
142142

143-
await expect(bindWorkspace(db, design.id, conflictPath, false)).rejects.toThrow(
144-
'Workspace path is already bound to another design',
145-
);
143+
const bound = await bindWorkspace(db, design.id, sharedPath, false);
144+
145+
expect(bound.workspacePath).toBe(sharedPath);
146146
expect(db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(design.id)).toEqual({
147-
workspace_path: null,
147+
workspace_path: sharedPath,
148148
});
149+
expect(
150+
db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(otherDesign.id),
151+
).toEqual({ workspace_path: sharedPath });
149152
});
150153

151154
it('treats case-only workspace differences as the same path on Windows for the same design', async () => {
@@ -164,16 +167,16 @@ describe('bindWorkspace', () => {
164167
});
165168
});
166169

167-
it('treats case-only workspace differences as conflicts on Windows across designs', async () => {
170+
it('case-only differences on Windows still resolve to a shared bind across designs', async () => {
168171
await withMockedPlatform('win32', async () => {
169172
const db = initInMemoryDb();
170173
const design = createDesign(db);
171174
const otherDesign = createDesign(db);
172-
updateDesignWorkspace(db, otherDesign.id, normalizeWorkspacePath('/Users/Roy/Workspace'));
175+
const stored = normalizeWorkspacePath('/Users/Roy/Workspace');
176+
updateDesignWorkspace(db, otherDesign.id, stored);
173177

174-
await expect(bindWorkspace(db, design.id, '/users/roy/workspace', false)).rejects.toThrow(
175-
'Workspace path is already bound to another design',
176-
);
178+
const bound = await bindWorkspace(db, design.id, '/users/roy/workspace', false);
179+
expect(bound.workspacePath).toBe(normalizeWorkspacePath('/users/roy/workspace'));
177180
});
178181
});
179182

apps/desktop/src/main/design-workspace.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,14 @@ export async function bindWorkspace(
137137
logger.info('workspace.bind.noop', { designId, workspacePath: normalizedPath });
138138
return current;
139139
}
140+
// Upstream rejected binding the same folder to two designs. Their own v0.2
141+
// doc explicitly says multiple sessions can share a workspace, so the
142+
// conflict guard is over-zealous: it forces the user to either duplicate
143+
// the folder or shuffle bindings just to spin up a second design view of
144+
// the same project. We log the overlap (so the issue is auditable) but
145+
// let the bind proceed.
140146
if (checkWorkspaceConflict(db, designId, normalizedPath)) {
141-
throw new Error('Workspace path is already bound to another design');
147+
logger.info('workspace.bind.shared', { designId, workspacePath: normalizedPath });
142148
}
143149

144150
logger.info('workspace.bind.start', {

apps/desktop/src/main/done-verify.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function makeRuntimeVerifier(): DoneRuntimeVerifier {
6666
const onConsole = (...args: unknown[]) => {
6767
// Electron 35+ emits a single Event-like object; older majors emit
6868
// positional (event, level, message, line, sourceId). Detect by
69-
// arity a single object argument means the new shape.
69+
// arity: a single object argument means the new shape.
7070
let level: ConsoleMessageEvent['level'];
7171
let message: string;
7272
let line: number | undefined;
@@ -99,7 +99,7 @@ export function makeRuntimeVerifier(): DoneRuntimeVerifier {
9999
};
100100

101101
try {
102-
// Cast through `any` for the event-name overloads Electron's WebContents
102+
// Cast through `any` for the event-name overloads: Electron's WebContents
103103
// event union doesn't include 'did-fail-load' / 'preload-error' in the
104104
// overload set this TS lib resolves, even though the events fire at runtime.
105105
const wc = win.webContents as unknown as {

apps/desktop/src/main/electron-runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ const require = createRequire(import.meta.url);
44

55
export const electron = require('electron') as typeof import('electron');
66

7-
export const { app, BrowserWindow, clipboard, dialog, ipcMain, safeStorage, shell } = electron;
7+
export const { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, safeStorage, shell } =
8+
electron;
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { beforeEach, describe, expect, it } from 'vitest';
5+
import { walkWorkspaceFiles } from './files-ipc';
6+
7+
describe('walkWorkspaceFiles', () => {
8+
let workspaceDir: string;
9+
10+
beforeEach(async () => {
11+
workspaceDir = await mkdtemp(path.join(os.tmpdir(), 'oc-files-ipc-'));
12+
});
13+
14+
it('returns empty array for an empty workspace', async () => {
15+
const files = await walkWorkspaceFiles(workspaceDir);
16+
expect(files).toEqual([]);
17+
});
18+
19+
it('lists html and asset files at the root', async () => {
20+
await writeFile(path.join(workspaceDir, 'index.html'), '<html></html>');
21+
await writeFile(path.join(workspaceDir, 'styles.css'), 'body{}');
22+
await writeFile(path.join(workspaceDir, 'app.js'), '');
23+
const files = await walkWorkspaceFiles(workspaceDir);
24+
const paths = files.map((f) => f.path);
25+
expect(paths).toContain('index.html');
26+
expect(paths).toContain('styles.css');
27+
expect(paths).toContain('app.js');
28+
});
29+
30+
it('marks .html and .htm as kind=html', async () => {
31+
await writeFile(path.join(workspaceDir, 'a.html'), '');
32+
await writeFile(path.join(workspaceDir, 'b.htm'), '');
33+
await writeFile(path.join(workspaceDir, 'c.css'), '');
34+
const files = await walkWorkspaceFiles(workspaceDir);
35+
const byPath = Object.fromEntries(files.map((f) => [f.path, f.kind]));
36+
expect(byPath['a.html']).toBe('html');
37+
expect(byPath['b.htm']).toBe('html');
38+
expect(byPath['c.css']).toBe('asset');
39+
});
40+
41+
it('sorts html files first, then assets, both alphabetical', async () => {
42+
await writeFile(path.join(workspaceDir, 'zzz.html'), '');
43+
await writeFile(path.join(workspaceDir, 'aaa.css'), '');
44+
await writeFile(path.join(workspaceDir, 'bbb.html'), '');
45+
const files = await walkWorkspaceFiles(workspaceDir);
46+
expect(files.map((f) => f.path)).toEqual(['bbb.html', 'zzz.html', 'aaa.css']);
47+
});
48+
49+
it('walks nested directories', async () => {
50+
await mkdir(path.join(workspaceDir, 'sub'));
51+
await writeFile(path.join(workspaceDir, 'sub', 'page.html'), '');
52+
await writeFile(path.join(workspaceDir, 'sub', 'logo.svg'), '');
53+
const files = await walkWorkspaceFiles(workspaceDir);
54+
const paths = files.map((f) => f.path);
55+
expect(paths).toContain('sub/page.html');
56+
expect(paths).toContain('sub/logo.svg');
57+
});
58+
59+
it('skips standard heavy/build directories', async () => {
60+
for (const dir of ['node_modules', '.git', 'dist', 'build', '.next']) {
61+
await mkdir(path.join(workspaceDir, dir));
62+
await writeFile(path.join(workspaceDir, dir, 'inside.html'), '');
63+
}
64+
await writeFile(path.join(workspaceDir, 'visible.html'), '');
65+
const files = await walkWorkspaceFiles(workspaceDir);
66+
const paths = files.map((f) => f.path);
67+
expect(paths).toEqual(['visible.html']);
68+
});
69+
70+
it('skips hidden (dotfile) entries', async () => {
71+
await writeFile(path.join(workspaceDir, '.env'), 'SECRET=1');
72+
await writeFile(path.join(workspaceDir, '.hidden.html'), '');
73+
await writeFile(path.join(workspaceDir, 'visible.html'), '');
74+
const files = await walkWorkspaceFiles(workspaceDir);
75+
expect(files.map((f) => f.path)).toEqual(['visible.html']);
76+
});
77+
78+
it('skips files without an allowed extension', async () => {
79+
await writeFile(path.join(workspaceDir, 'binary.exe'), '');
80+
await writeFile(path.join(workspaceDir, 'Makefile'), '');
81+
await writeFile(path.join(workspaceDir, 'page.html'), '');
82+
const files = await walkWorkspaceFiles(workspaceDir);
83+
expect(files.map((f) => f.path)).toEqual(['page.html']);
84+
});
85+
86+
it('returns size and ISO mtime per entry', async () => {
87+
const content = 'hello world';
88+
await writeFile(path.join(workspaceDir, 'page.html'), content);
89+
const files = await walkWorkspaceFiles(workspaceDir);
90+
expect(files).toHaveLength(1);
91+
expect(files[0]?.size).toBe(content.length);
92+
expect(files[0]?.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
93+
});
94+
95+
it('respects the max-files cap', async () => {
96+
for (let i = 0; i < 10; i++) {
97+
await writeFile(path.join(workspaceDir, `file-${i}.html`), '');
98+
}
99+
const files = await walkWorkspaceFiles(workspaceDir, 3);
100+
expect(files).toHaveLength(3);
101+
});
102+
103+
it('handles unreadable directories gracefully', async () => {
104+
// Walking a non-existent path returns [] rather than throwing -- the
105+
// workspace folder might have been deleted between the bind and the
106+
// list call and the panel should just go empty.
107+
const files = await walkWorkspaceFiles(path.join(workspaceDir, 'does-not-exist'));
108+
expect(files).toEqual([]);
109+
});
110+
});

apps/desktop/src/main/files-ipc.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { Dirent } from 'node:fs';
2+
import { readdir, stat } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import type { CoreLogger } from '@open-codesign/core';
5+
import { CodesignError } from '@open-codesign/shared';
6+
import type Database from 'better-sqlite3';
7+
import { ipcMain } from './electron-runtime';
8+
import { getDesign } from './snapshots-db';
9+
import {
10+
WORKSPACE_WALK_MAX_FILES,
11+
shouldSkipDirEntry,
12+
shouldSkipFileEntry,
13+
} from './workspace-walk';
14+
15+
const HTML_EXTS = new Set(['.html', '.htm']);
16+
// Anything renderable in an iframe directly (or useful to surface in the
17+
// Files panel) gets an entry. Non-listed extensions are skipped to keep
18+
// the panel uncluttered -- if the agent or user needs a niche file type
19+
// they can edit the list here.
20+
const ASSET_EXTS = new Set([
21+
'.css',
22+
'.js',
23+
'.mjs',
24+
'.cjs',
25+
'.jsx',
26+
'.ts',
27+
'.tsx',
28+
'.json',
29+
'.svg',
30+
'.png',
31+
'.jpg',
32+
'.jpeg',
33+
'.gif',
34+
'.webp',
35+
'.avif',
36+
'.ico',
37+
'.bmp',
38+
'.woff',
39+
'.woff2',
40+
'.ttf',
41+
'.otf',
42+
'.md',
43+
'.txt',
44+
'.xml',
45+
'.yaml',
46+
'.yml',
47+
'.toml',
48+
]);
49+
50+
export type FilesIpcEntryKind = 'html' | 'asset';
51+
52+
export interface FilesIpcEntry {
53+
path: string;
54+
kind: FilesIpcEntryKind;
55+
size: number;
56+
updatedAt: string;
57+
}
58+
59+
export async function walkWorkspaceFiles(
60+
workspacePath: string,
61+
max: number = WORKSPACE_WALK_MAX_FILES,
62+
): Promise<FilesIpcEntry[]> {
63+
const out: FilesIpcEntry[] = [];
64+
65+
async function walk(absDir: string, relDir: string): Promise<void> {
66+
if (out.length >= max) return;
67+
let entries: Dirent[];
68+
try {
69+
entries = await readdir(absDir, { withFileTypes: true });
70+
} catch {
71+
return;
72+
}
73+
entries.sort((a, b) => a.name.localeCompare(b.name));
74+
75+
for (const entry of entries) {
76+
if (out.length >= max) return;
77+
const absPath = path.join(absDir, entry.name);
78+
const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`;
79+
80+
if (entry.isDirectory()) {
81+
if (shouldSkipDirEntry(entry.name)) continue;
82+
await walk(absPath, relPath);
83+
continue;
84+
}
85+
if (!entry.isFile() || shouldSkipFileEntry(entry.name)) continue;
86+
87+
const ext = path.extname(entry.name).toLowerCase();
88+
const kind: FilesIpcEntryKind | null = HTML_EXTS.has(ext)
89+
? 'html'
90+
: ASSET_EXTS.has(ext)
91+
? 'asset'
92+
: null;
93+
if (kind === null) continue;
94+
95+
try {
96+
const st = await stat(absPath);
97+
out.push({
98+
path: relPath,
99+
kind,
100+
size: st.size,
101+
updatedAt: st.mtime.toISOString(),
102+
});
103+
} catch {
104+
// unreadable entries are skipped silently; the Files panel doesn't
105+
// surface them and the user already has read access via the picker.
106+
}
107+
}
108+
}
109+
110+
await walk(workspacePath, '');
111+
// HTML files first (the user-visible focus), then everything else.
112+
// Stable secondary sort by path keeps the order deterministic.
113+
out.sort((a, b) => {
114+
if (a.kind !== b.kind) return a.kind === 'html' ? -1 : 1;
115+
return a.path.localeCompare(b.path);
116+
});
117+
return out;
118+
}
119+
120+
export interface RegisterFilesIpcOptions {
121+
db: Database.Database;
122+
logger: Pick<CoreLogger, 'error' | 'warn' | 'info'>;
123+
}
124+
125+
export function registerFilesIpc(opts: RegisterFilesIpcOptions): void {
126+
const { db, logger } = opts;
127+
128+
ipcMain.handle(
129+
'files:list:v1',
130+
async (_e: unknown, raw: unknown): Promise<{ files: FilesIpcEntry[] }> => {
131+
if (typeof raw !== 'object' || raw === null) {
132+
throw new CodesignError('files:list:v1 expects an object payload', 'IPC_BAD_INPUT');
133+
}
134+
const r = raw as Record<string, unknown>;
135+
if (typeof r['designId'] !== 'string' || r['designId'].trim().length === 0) {
136+
throw new CodesignError('designId must be a non-empty string', 'IPC_BAD_INPUT');
137+
}
138+
const designId = r['designId'] as string;
139+
140+
let design: ReturnType<typeof getDesign>;
141+
try {
142+
design = getDesign(db, designId);
143+
} catch (err) {
144+
logger.error('files.list.db.fail', {
145+
designId,
146+
message: err instanceof Error ? err.message : String(err),
147+
});
148+
throw new CodesignError('files lookup failed', 'IPC_DB_ERROR', {
149+
cause: err instanceof Error ? err : undefined,
150+
});
151+
}
152+
153+
if (design === null) {
154+
throw new CodesignError('Design not found', 'IPC_NOT_FOUND');
155+
}
156+
if (design.workspacePath === null) {
157+
// No workspace bound -- renderer falls back to virtual index.html.
158+
return { files: [] };
159+
}
160+
161+
try {
162+
const files = await walkWorkspaceFiles(design.workspacePath);
163+
return { files };
164+
} catch (err) {
165+
logger.error('files.list.walk.fail', {
166+
designId,
167+
workspacePath: design.workspacePath,
168+
message: err instanceof Error ? err.message : String(err),
169+
});
170+
throw new CodesignError('Failed to scan workspace folder', 'IPC_DB_ERROR', {
171+
cause: err instanceof Error ? err : undefined,
172+
});
173+
}
174+
},
175+
);
176+
}

0 commit comments

Comments
 (0)