Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/workspace-files-agent-visible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@open-codesign/desktop": patch
"@open-codesign/ui": patch
---

Expose bound workspace files to the agent and preview before the first generation, including workspace asset loading through `workspace://`, with bounded async workspace seeding.
27 changes: 15 additions & 12 deletions apps/desktop/src/main/design-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,19 +133,22 @@ describe('bindWorkspace', () => {
expect(await stat(path.join(workspace, 'tracked.txt'))).toEqual(destinationBefore);
});

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

await expect(bindWorkspace(db, design.id, conflictPath, false)).rejects.toThrow(
'Workspace path is already bound to another design',
);
const bound = await bindWorkspace(db, design.id, sharedPath, false);

expect(bound.workspacePath).toBe(sharedPath);
expect(db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(design.id)).toEqual({
workspace_path: null,
workspace_path: sharedPath,
});
expect(
db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(otherDesign.id),
).toEqual({ workspace_path: sharedPath });
});

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

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

await expect(bindWorkspace(db, design.id, '/users/roy/workspace', false)).rejects.toThrow(
'Workspace path is already bound to another design',
);
const bound = await bindWorkspace(db, design.id, '/users/roy/workspace', false);
expect(bound.workspacePath).toBe(normalizeWorkspacePath('/users/roy/workspace'));
});
});

Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/main/design-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,14 @@ export async function bindWorkspace(
logger.info('workspace.bind.noop', { designId, workspacePath: normalizedPath });
return current;
}
// Upstream rejected binding the same folder to two designs. Their own v0.2
// doc explicitly says multiple sessions can share a workspace, so the
// conflict guard is over-zealous: it forces the user to either duplicate
// the folder or shuffle bindings just to spin up a second design view of
// the same project. We log the overlap (so the issue is auditable) but
// let the bind proceed.
if (checkWorkspaceConflict(db, designId, normalizedPath)) {
throw new Error('Workspace path is already bound to another design');
logger.info('workspace.bind.shared', { designId, workspacePath: normalizedPath });
}

logger.info('workspace.bind.start', {
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/done-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function makeRuntimeVerifier(): DoneRuntimeVerifier {
const onConsole = (...args: unknown[]) => {
// Electron 35+ emits a single Event-like object; older majors emit
// positional (event, level, message, line, sourceId). Detect by
// arity — a single object argument means the new shape.
// arity: a single object argument means the new shape.
let level: ConsoleMessageEvent['level'];
let message: string;
let line: number | undefined;
Expand Down Expand Up @@ -99,7 +99,7 @@ export function makeRuntimeVerifier(): DoneRuntimeVerifier {
};

try {
// Cast through `any` for the event-name overloads — Electron's WebContents
// Cast through `any` for the event-name overloads: Electron's WebContents
// event union doesn't include 'did-fail-load' / 'preload-error' in the
// overload set this TS lib resolves, even though the events fire at runtime.
const wc = win.webContents as unknown as {
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/electron-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ const require = createRequire(import.meta.url);

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

export const { app, BrowserWindow, clipboard, dialog, ipcMain, safeStorage, shell } = electron;
export const { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, safeStorage, shell } =
electron;
110 changes: 110 additions & 0 deletions apps/desktop/src/main/files-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { beforeEach, describe, expect, it } from 'vitest';
import { walkWorkspaceFiles } from './files-ipc';

describe('walkWorkspaceFiles', () => {
let workspaceDir: string;

beforeEach(async () => {
workspaceDir = await mkdtemp(path.join(os.tmpdir(), 'oc-files-ipc-'));
});

it('returns empty array for an empty workspace', async () => {
const files = await walkWorkspaceFiles(workspaceDir);
expect(files).toEqual([]);
});

it('lists html and asset files at the root', async () => {
await writeFile(path.join(workspaceDir, 'index.html'), '<html></html>');
await writeFile(path.join(workspaceDir, 'styles.css'), 'body{}');
await writeFile(path.join(workspaceDir, 'app.js'), '');
const files = await walkWorkspaceFiles(workspaceDir);
const paths = files.map((f) => f.path);
expect(paths).toContain('index.html');
expect(paths).toContain('styles.css');
expect(paths).toContain('app.js');
});

it('marks .html and .htm as kind=html', async () => {
await writeFile(path.join(workspaceDir, 'a.html'), '');
await writeFile(path.join(workspaceDir, 'b.htm'), '');
await writeFile(path.join(workspaceDir, 'c.css'), '');
const files = await walkWorkspaceFiles(workspaceDir);
const byPath = Object.fromEntries(files.map((f) => [f.path, f.kind]));
expect(byPath['a.html']).toBe('html');
expect(byPath['b.htm']).toBe('html');
expect(byPath['c.css']).toBe('asset');
});

it('sorts html files first, then assets, both alphabetical', async () => {
await writeFile(path.join(workspaceDir, 'zzz.html'), '');
await writeFile(path.join(workspaceDir, 'aaa.css'), '');
await writeFile(path.join(workspaceDir, 'bbb.html'), '');
const files = await walkWorkspaceFiles(workspaceDir);
expect(files.map((f) => f.path)).toEqual(['bbb.html', 'zzz.html', 'aaa.css']);
});

it('walks nested directories', async () => {
await mkdir(path.join(workspaceDir, 'sub'));
await writeFile(path.join(workspaceDir, 'sub', 'page.html'), '');
await writeFile(path.join(workspaceDir, 'sub', 'logo.svg'), '');
const files = await walkWorkspaceFiles(workspaceDir);
const paths = files.map((f) => f.path);
expect(paths).toContain('sub/page.html');
expect(paths).toContain('sub/logo.svg');
});

it('skips standard heavy/build directories', async () => {
for (const dir of ['node_modules', '.git', 'dist', 'build', '.next']) {
await mkdir(path.join(workspaceDir, dir));
await writeFile(path.join(workspaceDir, dir, 'inside.html'), '');
}
await writeFile(path.join(workspaceDir, 'visible.html'), '');
const files = await walkWorkspaceFiles(workspaceDir);
const paths = files.map((f) => f.path);
expect(paths).toEqual(['visible.html']);
});

it('skips hidden (dotfile) entries', async () => {
await writeFile(path.join(workspaceDir, '.env'), 'SECRET=1');
await writeFile(path.join(workspaceDir, '.hidden.html'), '');
await writeFile(path.join(workspaceDir, 'visible.html'), '');
const files = await walkWorkspaceFiles(workspaceDir);
expect(files.map((f) => f.path)).toEqual(['visible.html']);
});

it('skips files without an allowed extension', async () => {
await writeFile(path.join(workspaceDir, 'binary.exe'), '');
await writeFile(path.join(workspaceDir, 'Makefile'), '');
await writeFile(path.join(workspaceDir, 'page.html'), '');
const files = await walkWorkspaceFiles(workspaceDir);
expect(files.map((f) => f.path)).toEqual(['page.html']);
});

it('returns size and ISO mtime per entry', async () => {
const content = 'hello world';
await writeFile(path.join(workspaceDir, 'page.html'), content);
const files = await walkWorkspaceFiles(workspaceDir);
expect(files).toHaveLength(1);
expect(files[0]?.size).toBe(content.length);
expect(files[0]?.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});

it('respects the max-files cap', async () => {
for (let i = 0; i < 10; i++) {
await writeFile(path.join(workspaceDir, `file-${i}.html`), '');
}
const files = await walkWorkspaceFiles(workspaceDir, 3);
expect(files).toHaveLength(3);
});

it('handles unreadable directories gracefully', async () => {
// Walking a non-existent path returns [] rather than throwing -- the
// workspace folder might have been deleted between the bind and the
// list call and the panel should just go empty.
const files = await walkWorkspaceFiles(path.join(workspaceDir, 'does-not-exist'));
expect(files).toEqual([]);
});
});
176 changes: 176 additions & 0 deletions apps/desktop/src/main/files-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type { Dirent } from 'node:fs';
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import type { CoreLogger } from '@open-codesign/core';
import { CodesignError } from '@open-codesign/shared';
import type Database from 'better-sqlite3';
import { ipcMain } from './electron-runtime';
import { getDesign } from './snapshots-db';
import {
WORKSPACE_WALK_MAX_FILES,
shouldSkipDirEntry,
shouldSkipFileEntry,
} from './workspace-walk';

const HTML_EXTS = new Set(['.html', '.htm']);
// Anything renderable in an iframe directly (or useful to surface in the
// Files panel) gets an entry. Non-listed extensions are skipped to keep
// the panel uncluttered -- if the agent or user needs a niche file type
// they can edit the list here.
const ASSET_EXTS = new Set([
'.css',
'.js',
'.mjs',
'.cjs',
'.jsx',
'.ts',
'.tsx',
'.json',
'.svg',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.avif',
'.ico',
'.bmp',
'.woff',
'.woff2',
'.ttf',
'.otf',
'.md',
'.txt',
'.xml',
'.yaml',
'.yml',
'.toml',
]);

export type FilesIpcEntryKind = 'html' | 'asset';

export interface FilesIpcEntry {
path: string;
kind: FilesIpcEntryKind;
size: number;
updatedAt: string;
}

export async function walkWorkspaceFiles(
workspacePath: string,
max: number = WORKSPACE_WALK_MAX_FILES,
): Promise<FilesIpcEntry[]> {
const out: FilesIpcEntry[] = [];

async function walk(absDir: string, relDir: string): Promise<void> {
if (out.length >= max) return;
let entries: Dirent[];
try {
entries = await readdir(absDir, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));

for (const entry of entries) {
if (out.length >= max) return;
const absPath = path.join(absDir, entry.name);
const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`;

if (entry.isDirectory()) {
if (shouldSkipDirEntry(entry.name)) continue;
await walk(absPath, relPath);
continue;
}
if (!entry.isFile() || shouldSkipFileEntry(entry.name)) continue;

const ext = path.extname(entry.name).toLowerCase();
const kind: FilesIpcEntryKind | null = HTML_EXTS.has(ext)
? 'html'
: ASSET_EXTS.has(ext)
? 'asset'
: null;
if (kind === null) continue;

try {
const st = await stat(absPath);
out.push({
path: relPath,
kind,
size: st.size,
updatedAt: st.mtime.toISOString(),
});
} catch {
// unreadable entries are skipped silently; the Files panel doesn't
// surface them and the user already has read access via the picker.
}
}
}

await walk(workspacePath, '');
// HTML files first (the user-visible focus), then everything else.
// Stable secondary sort by path keeps the order deterministic.
out.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'html' ? -1 : 1;
return a.path.localeCompare(b.path);
});
return out;
}

export interface RegisterFilesIpcOptions {
db: Database.Database;
logger: Pick<CoreLogger, 'error' | 'warn' | 'info'>;
}

export function registerFilesIpc(opts: RegisterFilesIpcOptions): void {
const { db, logger } = opts;

ipcMain.handle(
'files:list:v1',
async (_e: unknown, raw: unknown): Promise<{ files: FilesIpcEntry[] }> => {
if (typeof raw !== 'object' || raw === null) {
throw new CodesignError('files:list:v1 expects an object payload', 'IPC_BAD_INPUT');
}
const r = raw as Record<string, unknown>;
if (typeof r['designId'] !== 'string' || r['designId'].trim().length === 0) {
throw new CodesignError('designId must be a non-empty string', 'IPC_BAD_INPUT');
}
const designId = r['designId'] as string;

let design: ReturnType<typeof getDesign>;
try {
design = getDesign(db, designId);
} catch (err) {
logger.error('files.list.db.fail', {
designId,
message: err instanceof Error ? err.message : String(err),
});
throw new CodesignError('files lookup failed', 'IPC_DB_ERROR', {
cause: err instanceof Error ? err : undefined,
});
}

if (design === null) {
throw new CodesignError('Design not found', 'IPC_NOT_FOUND');
}
if (design.workspacePath === null) {
// No workspace bound -- renderer falls back to virtual index.html.
return { files: [] };
}

try {
const files = await walkWorkspaceFiles(design.workspacePath);
return { files };
} catch (err) {
logger.error('files.list.walk.fail', {
designId,
workspacePath: design.workspacePath,
message: err instanceof Error ? err.message : String(err),
});
throw new CodesignError('Failed to scan workspace folder', 'IPC_DB_ERROR', {
cause: err instanceof Error ? err : undefined,
});
}
},
);
}
Loading
Loading