diff --git a/.changeset/local-workspace-preview.md b/.changeset/local-workspace-preview.md new file mode 100644 index 00000000..544b259d --- /dev/null +++ b/.changeset/local-workspace-preview.md @@ -0,0 +1,5 @@ +--- +"@open-codesign/desktop": minor +--- + +Add workspace-backed design creation, file browsing, project rebinding, and local preview modes, including a packaged preview runtime dependency fix so desktop builds include `ms` for `puppeteer-core`/`debug`. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d6e64537..18faff33 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "jszip": "^3.10.1", + "ms": "2.1.3", "puppeteer-core": "^24.42.0" }, "devDependencies": { diff --git a/apps/desktop/src/main/ask-ipc.test.ts b/apps/desktop/src/main/ask-ipc.test.ts index a04ae206..856ba55c 100644 --- a/apps/desktop/src/main/ask-ipc.test.ts +++ b/apps/desktop/src/main/ask-ipc.test.ts @@ -59,6 +59,27 @@ describe('ask-ipc', () => { await expect(second).resolves.toEqual({ status: 'cancelled', answers: [] }); }); + it('lists pending ask requests so the renderer can recover a missed event', async () => { + handlers.clear(); + registerAskIpc(); + const send = vi.fn(); + const fakeWindow = { + isDestroyed: () => false, + webContents: { send }, + } as unknown as Electron.BrowserWindow; + const inFlight = requestAsk('session-recover', sampleInput, () => fakeWindow); + const listPending = handlers.get('ask:list-pending'); + if (!listPending) throw new Error('ask:list-pending handler not registered'); + + const result = listPending(null, undefined); + + expect(result).toEqual([ + expect.objectContaining({ sessionId: 'session-recover', input: sampleInput }), + ]); + cancelPendingAskRequests('session-recover'); + await expect(inFlight).resolves.toEqual({ status: 'cancelled', answers: [] }); + }); + it('rejects malformed answers for a known request instead of leaving it pending', async () => { handlers.clear(); registerAskIpc(); diff --git a/apps/desktop/src/main/ask-ipc.ts b/apps/desktop/src/main/ask-ipc.ts index abd14dad..bcf0462c 100644 --- a/apps/desktop/src/main/ask-ipc.ts +++ b/apps/desktop/src/main/ask-ipc.ts @@ -20,6 +20,7 @@ interface PendingAsk { resolve: (result: AskResult) => void; reject: (reason?: unknown) => void; sessionId: string; + input: AskInput; } const pending = new Map(); @@ -31,6 +32,8 @@ export interface AskRequestPayload { } export function registerAskIpc(): void { + ipcMain.handle('ask:list-pending', () => listPendingAskRequests()); + ipcMain.handle('ask:resolve', (_event, raw: unknown) => { const requestId = readRequestId(raw, 'ask:resolve'); const entry = pending.get(requestId); @@ -70,7 +73,7 @@ export function requestAsk( ): Promise { const requestId = `ask-${randomUUID()}`; return new Promise((resolve, reject) => { - pending.set(requestId, { resolve, reject, sessionId }); + pending.set(requestId, { resolve, reject, sessionId, input }); const win = getMainWindow(); if (!win || win.isDestroyed()) { pending.delete(requestId); @@ -88,6 +91,14 @@ export function requestAsk( }); } +export function listPendingAskRequests(): AskRequestPayload[] { + return Array.from(pending, ([requestId, entry]) => ({ + requestId, + sessionId: entry.sessionId, + input: entry.input, + })); +} + export function cancelPendingAskRequests(sessionId: string): void { for (const [id, entry] of pending) { if (entry.sessionId !== sessionId) continue; diff --git a/apps/desktop/src/main/design-workspace.ts b/apps/desktop/src/main/design-workspace.ts index cd9c121b..5a665b95 100644 --- a/apps/desktop/src/main/design-workspace.ts +++ b/apps/desktop/src/main/design-workspace.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { copyFile, mkdir, stat } from 'node:fs/promises'; import path from 'node:path'; -import type { Design } from '@open-codesign/shared'; +import type { Design, WorkspaceMode } from '@open-codesign/shared'; import { type BrowserWindow, dialog, shell } from 'electron'; import { getLogger } from './logger'; import { @@ -145,6 +145,7 @@ export async function bindWorkspace( designId: string, workspacePath: string | null, migrateFiles: boolean, + workspaceMode?: WorkspaceMode, ): Promise { const current = requireDesign(db, designId); @@ -183,7 +184,7 @@ export async function bindWorkspace( await migrateWorkspaceFiles(db, designId, normalizedPath); } - const updated = updateDesignWorkspace(db, designId, normalizedPath); + const updated = updateDesignWorkspace(db, designId, normalizedPath, workspaceMode); if (updated === null) { throw new Error(`Design not found: ${designId}`); } diff --git a/apps/desktop/src/main/done-verify.test.ts b/apps/desktop/src/main/done-verify.test.ts index bbb77dff..7cd1936e 100644 --- a/apps/desktop/src/main/done-verify.test.ts +++ b/apps/desktop/src/main/done-verify.test.ts @@ -1,3 +1,6 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vitest'; import { formatRuntimeLoadError, @@ -34,23 +37,18 @@ describe('done runtime verifier error formatting', () => { }); it('allows only the verifier file for file:// requests', () => { + const verifyFilePath = join(tmpdir(), 'codesign-done', 'verify.html'); + expect(isDoneVerifierRequestAllowed(pathToFileURL(verifyFilePath).href, verifyFilePath)).toBe( + true, + ); expect( isDoneVerifierRequestAllowed( - 'file:///tmp/codesign-done/verify.html', - '/tmp/codesign-done/verify.html', - ), - ).toBe(true); - expect( - isDoneVerifierRequestAllowed( - 'file:///Users/me/private.txt', - '/tmp/codesign-done/verify.html', + pathToFileURL(join(tmpdir(), 'private.txt')).href, + verifyFilePath, ), ).toBe(false); - expect( - isDoneVerifierRequestAllowed( - 'https://fonts.googleapis.com/css2', - '/tmp/codesign-done/verify.html', - ), - ).toBe(true); + expect(isDoneVerifierRequestAllowed('https://fonts.googleapis.com/css2', verifyFilePath)).toBe( + true, + ); }); }); diff --git a/apps/desktop/src/main/exporter-ipc.test.ts b/apps/desktop/src/main/exporter-ipc.test.ts index 8eff8a64..e158182e 100644 --- a/apps/desktop/src/main/exporter-ipc.test.ts +++ b/apps/desktop/src/main/exporter-ipc.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { CodesignError } from '@open-codesign/shared'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { @@ -64,7 +64,7 @@ describe('parseRequest', () => { expect(result.sourcePath).toBe('screens/home/index.html'); expect(exportAssetOptions(result)).toMatchObject({ assetRootPath: '/workspace', - assetBasePath: '/workspace/screens/home', + assetBasePath: resolve('/workspace', 'screens/home'), sourcePath: 'screens/home/index.html', }); }); @@ -103,7 +103,7 @@ describe('parseRequest', () => { expect(result.sourcePath).toBe('screens/home/App.jsx'); expect(exportAssetOptions(result)).toMatchObject({ - assetBasePath: '/workspace/screens/home', + assetBasePath: resolve('/workspace', 'screens/home'), sourcePath: 'screens/home/App.jsx', }); }); @@ -131,7 +131,7 @@ describe('export path helpers', () => { now: new Date('2026-05-05T10:20:30.000Z'), }); - expect(out).toBe('/Users/roy/Downloads/Launch-Deck-Q2-Home-2026-05-05-102030.pptx'); + expect(out).toBe(join('/Users/roy/Downloads', 'Launch-Deck-Q2-Home-2026-05-05-102030.pptx')); }); it('falls back to an open-codesign name inside Downloads when no design name is available', () => { @@ -143,7 +143,7 @@ describe('export path helpers', () => { now: new Date('2026-05-05T10:20:30.000Z'), }); - expect(out).toBe('/Users/roy/Downloads/open-codesign-App-2026-05-05-102030.md'); + expect(out).toBe(join('/Users/roy/Downloads', 'open-codesign-App-2026-05-05-102030.md')); }); it('treats legacy defaultFilename as a Downloads filename, not a cwd-relative path', () => { @@ -154,7 +154,7 @@ describe('export path helpers', () => { now: new Date('2026-05-05T10:20:30.000Z'), }); - expect(out).toBe('/Users/roy/Downloads/codesign-2026-05-05T06-04-43.html'); + expect(out).toBe(join('/Users/roy/Downloads', 'codesign-2026-05-05T06-04-43.html')); }); it('keeps legacy defaultFilename on the requested format extension', () => { @@ -165,7 +165,7 @@ describe('export path helpers', () => { now: new Date('2026-05-05T10:20:30.000Z'), }); - expect(out).toBe('/Users/roy/Downloads/preview.html.pdf'); + expect(out).toBe(join('/Users/roy/Downloads', 'preview.html.pdf')); }); it('keeps export files on the selected format extension', () => { diff --git a/apps/desktop/src/main/generation-ipc.test.ts b/apps/desktop/src/main/generation-ipc.test.ts index bdb70d26..bcb044c7 100644 --- a/apps/desktop/src/main/generation-ipc.test.ts +++ b/apps/desktop/src/main/generation-ipc.test.ts @@ -1,6 +1,7 @@ import { CancelGenerationPayloadV1, CodesignError } from '@open-codesign/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + acquireInFlightWorkspaceGeneration, armGenerationTimeout, cancelGenerationRequest, extractGenerationTimeoutError, @@ -197,12 +198,50 @@ describe('withInFlightGenerationForDesign', () => { const controller = makeController(); const inFlight = new Map([['gen-1', controller]]); const inFlightByDesign = new Map([['design-1', { generationId: 'gen-1', startedAt: 1234 }]]); + const inFlightByWorkspace = new Map([ + ['/workspace', { generationId: 'gen-1', startedAt: 1234 }], + ]); const logIpc = { info: vi.fn() }; - cancelGenerationRequest('gen-1', inFlight, logIpc, inFlightByDesign); + cancelGenerationRequest('gen-1', inFlight, logIpc, inFlightByDesign, inFlightByWorkspace); expect(inFlight.has('gen-1')).toBe(false); expect(inFlightByDesign.has('design-1')).toBe(false); + expect(inFlightByWorkspace.has('/workspace')).toBe(false); + }); +}); + +describe('acquireInFlightWorkspaceGeneration', () => { + it('rejects a second generation for the same workspace while the first is running', () => { + const inFlightByWorkspace = new Map(); + const release = acquireInFlightWorkspaceGeneration('gen-1', '/workspace', inFlightByWorkspace); + + expect(() => + acquireInFlightWorkspaceGeneration('gen-2', '/workspace', inFlightByWorkspace), + ).toThrow(CodesignError); + + release(); + expect(inFlightByWorkspace.has('/workspace')).toBe(false); + }); + + it('allows different workspaces to run concurrently', () => { + const inFlightByWorkspace = new Map(); + const releaseOne = acquireInFlightWorkspaceGeneration( + 'gen-1', + '/workspace-a', + inFlightByWorkspace, + ); + const releaseTwo = acquireInFlightWorkspaceGeneration( + 'gen-2', + '/workspace-b', + inFlightByWorkspace, + ); + + expect([...inFlightByWorkspace.keys()].sort()).toEqual(['/workspace-a', '/workspace-b']); + + releaseOne(); + releaseTwo(); + expect(inFlightByWorkspace.size).toBe(0); }); }); diff --git a/apps/desktop/src/main/generation-ipc.ts b/apps/desktop/src/main/generation-ipc.ts index 87430cc5..dd379c92 100644 --- a/apps/desktop/src/main/generation-ipc.ts +++ b/apps/desktop/src/main/generation-ipc.ts @@ -14,6 +14,7 @@ export function cancelGenerationRequest( inFlight: Map, logIpc: CancellationLogger, inFlightByDesign?: Map, + inFlightByWorkspace?: Map, ): void { if (typeof raw !== 'string') { throw new CodesignError( @@ -32,6 +33,11 @@ export function cancelGenerationRequest( if (generation.generationId === raw) inFlightByDesign.delete(designId); } } + if (inFlightByWorkspace !== undefined) { + for (const [workspaceKey, generation] of inFlightByWorkspace) { + if (generation.generationId === raw) inFlightByWorkspace.delete(workspaceKey); + } + } logIpc.info('generate.cancelled', { id: raw }); } @@ -77,6 +83,27 @@ export async function withInFlightGenerationForDesign( } } +export function acquireInFlightWorkspaceGeneration( + id: string, + workspaceKey: string, + inFlightByWorkspace: Map, +): () => void { + const existing = inFlightByWorkspace.get(workspaceKey); + if (existing !== undefined && existing.generationId !== id) { + throw new CodesignError( + 'A generation is already running for this workspace. Wait for it to finish or stop it before continuing.', + 'GENERATION_ALREADY_RUNNING', + ); + } + const startedAt = existing?.startedAt ?? Date.now(); + inFlightByWorkspace.set(workspaceKey, { generationId: id, startedAt }); + return () => { + if (inFlightByWorkspace.get(workspaceKey)?.generationId === id) { + inFlightByWorkspace.delete(workspaceKey); + } + }; +} + export function listInFlightGenerations( inFlightByDesign: ReadonlyMap, ): Array<{ designId: string; generationId: string; startedAt: number }> { diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index 5d934ace..815de4f8 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -36,6 +36,7 @@ import { CHATGPT_CODEX_PROVIDER_ID, getCodexTokenStore } from '../codex-oauth-ip import { makeRuntimeVerifier } from '../done-verify'; import { app, ipcMain } from '../electron-runtime'; import { + acquireInFlightWorkspaceGeneration, armGenerationTimeout, cancelGenerationRequest, extractGenerationTimeoutError, @@ -673,6 +674,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe /** In-flight requests: generationId → AbortController */ const inFlight = new Map(); const inFlightByDesign = new Map(); + const inFlightByWorkspace = new Map(); const armTimeout = (id: string, controller: AbortController) => armGenerationTimeout( @@ -820,7 +822,13 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const t0 = Date.now(); let clearTimeoutGuard: () => void = () => {}; + let releaseWorkspaceGeneration: () => void = () => {}; try { + releaseWorkspaceGeneration = acquireInFlightWorkspaceGeneration( + id, + workspaceRoot, + inFlightByWorkspace, + ); clearTimeoutGuard = await armTimeout(id, controller); const isCodex = active.model.provider === CHATGPT_CODEX_PROVIDER_ID; let capturedMessages: DesignBriefConversationMessages | null = null; @@ -1251,6 +1259,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe throw rethrow; } finally { clearTimeoutGuard(); + releaseWorkspaceGeneration(); } }, ); @@ -1259,7 +1268,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe ipcMain.handle('codesign:v1:cancel-generation', (_e, raw: unknown) => { const { generationId } = CancelGenerationPayloadV1.parse(raw); - cancelGenerationRequest(generationId, inFlight, logIpc, inFlightByDesign); + cancelGenerationRequest(generationId, inFlight, logIpc, inFlightByDesign, inFlightByWorkspace); }); ipcMain.handle('codesign:v1:generation-status', () => ({ @@ -1334,7 +1343,13 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const t0 = Date.now(); let clearTimeoutGuard: () => void = () => {}; + let releaseWorkspaceGeneration: () => void = () => {}; try { + releaseWorkspaceGeneration = acquireInFlightWorkspaceGeneration( + id, + workspaceRoot, + inFlightByWorkspace, + ); clearTimeoutGuard = await armTimeout(id, controller); const isCodex = active.model.provider === CHATGPT_CODEX_PROVIDER_ID; const result = await runGenerate( @@ -1402,6 +1417,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe throw rethrow; } finally { clearTimeoutGuard(); + releaseWorkspaceGeneration(); } }, ); @@ -1468,5 +1484,6 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe } inFlight.clear(); inFlightByDesign.clear(); + inFlightByWorkspace.clear(); }; } diff --git a/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts b/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts index e9a61f0c..8d458ef6 100644 --- a/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts +++ b/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts @@ -1,4 +1,5 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import type { Design } from '@open-codesign/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -66,7 +67,7 @@ generateControl.reset(); vi.mock('../electron-runtime', () => ({ app: { - getPath: vi.fn(() => '/tmp/open-codesign-generate-rename-tests'), + getPath: vi.fn(() => path.join(os.tmpdir(), 'open-codesign-generate-rename-tests')), }, ipcMain: { handle: vi.fn((channel: string, handler: Handler) => { @@ -199,6 +200,7 @@ import { requestAsk } from '../ask-ipc'; import { appendSessionChatMessage } from '../session-chat'; import { createDesign, initInMemoryDb, updateDesignWorkspace } from '../snapshots-db'; import { registerSnapshotsIpc } from '../snapshots-ipc'; +import { normalizeWorkspacePath } from '../workspace-path'; import { registerGenerateIpc } from './generate'; function getHandler(channel: string): Handler { @@ -208,9 +210,17 @@ function getHandler(channel: string): Handler { } describe('generate IPC workspace rename coordination', () => { - const documentsRoot = '/tmp/open-codesign-generate-rename-tests'; + const documentsRoot = path.join(os.tmpdir(), 'open-codesign-generate-rename-tests'); const defaultWorkspaceRoot = path.join(documentsRoot, 'CoDesign'); + function initTestDb() { + return { + ...initInMemoryDb(), + dataDir: path.join(documentsRoot, 'data'), + sessionDir: path.join(documentsRoot, 'sessions'), + }; + } + beforeEach(async () => { vi.clearAllMocks(); handlers.clear(); @@ -223,12 +233,11 @@ describe('generate IPC workspace rename coordination', () => { afterEach(async () => { generateControl.release(); - await rm(':memory:', { recursive: true, force: true }); await rm(documentsRoot, { recursive: true, force: true }); }); it('allows set_title rename to settle while the agent generation is still running', async () => { - const db = initInMemoryDb(); + const db = initTestDb(); const design = createDesign(db, 'Untitled design 1'); const oldWorkspace = path.join(defaultWorkspaceRoot, 'Untitled-design-1'); await mkdir(oldWorkspace); @@ -275,7 +284,7 @@ describe('generate IPC workspace rename coordination', () => { const renamed = await renamePromise; expect(renamed.workspacePath).toBe( - path.join(defaultWorkspaceRoot, 'Hybrid-Workshop-Day-Agenda'), + normalizeWorkspacePath(path.join(defaultWorkspaceRoot, 'Hybrid-Workshop-Day-Agenda')), ); }); @@ -298,7 +307,7 @@ describe('generate IPC workspace rename coordination', () => { }, ], }); - const db = initInMemoryDb(); + const db = initTestDb(); const design = createDesign(db, 'Untitled design 1'); const workspace = path.join(defaultWorkspaceRoot, 'Untitled-design-1'); await mkdir(workspace); @@ -360,7 +369,7 @@ describe('generate IPC workspace rename coordination', () => { }, ], }); - const db = initInMemoryDb(); + const db = initTestDb(); const design = createDesign(db, 'Untitled design 1'); const workspace = path.join(defaultWorkspaceRoot, 'Untitled-design-1'); await mkdir(path.join(workspace, 'references'), { recursive: true }); @@ -418,7 +427,7 @@ describe('generate IPC workspace rename coordination', () => { ], }); vi.mocked(requestAsk).mockResolvedValueOnce({ status: 'cancelled', answers: [] }); - const db = initInMemoryDb(); + const db = initTestDb(); const design = createDesign(db, 'Untitled design 1'); const workspace = path.join(defaultWorkspaceRoot, 'Untitled-design-1'); await mkdir(workspace); @@ -464,7 +473,7 @@ describe('generate IPC workspace rename coordination', () => { }, ], }); - const db = initInMemoryDb(); + const db = initTestDb(); const design = createDesign(db, 'Untitled design 1'); const workspace = path.join(defaultWorkspaceRoot, 'Untitled-design-1'); await mkdir(workspace); diff --git a/apps/desktop/src/main/ipc/runtime-fs.ts b/apps/desktop/src/main/ipc/runtime-fs.ts index 06c1b0ff..3f794e0c 100644 --- a/apps/desktop/src/main/ipc/runtime-fs.ts +++ b/apps/desktop/src/main/ipc/runtime-fs.ts @@ -12,7 +12,11 @@ import { import { prepareWorkspaceWriteContent } from '../workspace-file-content'; import { normalizeWorkspacePath } from '../workspace-path'; import { withStableWorkspacePath } from '../workspace-path-lock'; -import { resolveSafeWorkspaceChildPath } from '../workspace-reader'; +import { + assertWorkspacePathVisible, + isIgnoredWorkspacePath, + resolveSafeWorkspaceChildPath, +} from '../workspace-reader'; function escapeRegExp(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -171,6 +175,7 @@ export function createRuntimeTextEditorFs({ async function persistMutation(filePath: string, content: string): Promise { const normalizedPath = normalizeDesignFilePath(filePath); + assertWorkspacePathVisible(normalizedPath); const writeContent = prepareWorkspaceWriteContent(normalizedPath, content); if (designId === null || db === null) return writeContent.storedContent; try { @@ -219,6 +224,7 @@ export function createRuntimeTextEditorFs({ absolutePath?: string, ): Promise<{ path: string; content: string }> { const normalizedPath = normalizeDesignFilePath(filePath); + assertWorkspacePathVisible(normalizedPath); const sourcePath = absolutePath; let content: string; if (!sourcePath) { @@ -282,6 +288,7 @@ export function createRuntimeTextEditorFs({ const prefix = dir.length === 0 || dir === '.' ? '' : `${dir.replace(/\/+$/, '')}/`; const entries: string[] = []; for (const p of fsMap.keys()) { + if (isIgnoredWorkspacePath(p)) continue; if (!p.startsWith(prefix)) continue; const rest = p.slice(prefix.length); if (rest.length > 0) entries.push(rest); diff --git a/apps/desktop/src/main/open-external.test.ts b/apps/desktop/src/main/open-external.test.ts index dbcda56c..e471f33b 100644 --- a/apps/desktop/src/main/open-external.test.ts +++ b/apps/desktop/src/main/open-external.test.ts @@ -16,6 +16,12 @@ describe('isAllowedExternalUrl', () => { ).toBe(true); }); + it('accepts loopback preview URLs', () => { + expect(isAllowedExternalUrl('http://localhost:5173/preview')).toBe(true); + expect(isAllowedExternalUrl('http://127.0.0.1:4173/')).toBe(true); + expect(isAllowedExternalUrl('http://[::1]:5173/')).toBe(true); + }); + it('rejects unrelated host', () => { expect( isAllowedExternalUrl('https://evil.example.com/OpenCoworkAI/open-codesign/issues/new'), diff --git a/apps/desktop/src/main/open-external.ts b/apps/desktop/src/main/open-external.ts index 4a2f15fe..83c2ad20 100644 --- a/apps/desktop/src/main/open-external.ts +++ b/apps/desktop/src/main/open-external.ts @@ -5,6 +5,10 @@ * - `/releases/...` — update banner → release notes * - `/issues/...` — Report flow → prefilled bug issue * + * Local preview controls may also open explicit loopback HTTP(S) URLs. Those + * URLs are configured or detected by the user and only opened from direct UI + * actions, so remote hosts and non-HTTP schemes still stay out of scope. + * * Anything else (different host, different repo, different path) is rejected * so a compromised renderer can't coerce the main process into opening an * attacker-controlled URL via `shell.openExternal`. @@ -17,6 +21,13 @@ const ALLOWED_PATHS = [ `/${GITHUB_OWNER}/${GITHUB_REPO}/releases`, `/${GITHUB_OWNER}/${GITHUB_REPO}/issues`, ]; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +function isAllowedLoopbackUrl(parsed: URL): boolean { + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const hostname = parsed.hostname.toLowerCase(); + return LOOPBACK_HOSTS.has(hostname) || hostname.endsWith('.localhost'); +} export function isAllowedExternalUrl(raw: string): boolean { let parsed: URL; @@ -25,6 +36,7 @@ export function isAllowedExternalUrl(raw: string): boolean { } catch { return false; } + if (isAllowedLoopbackUrl(parsed)) return true; if (parsed.protocol !== 'https:') return false; if (parsed.hostname !== ALLOWED_HOST) return false; return ALLOWED_PATHS.some((p) => parsed.pathname === p || parsed.pathname.startsWith(`${p}/`)); diff --git a/apps/desktop/src/main/preferences-ipc.test.ts b/apps/desktop/src/main/preferences-ipc.test.ts index 9378c694..c8a2a674 100644 --- a/apps/desktop/src/main/preferences-ipc.test.ts +++ b/apps/desktop/src/main/preferences-ipc.test.ts @@ -108,13 +108,16 @@ describe('readPersisted()', () => { }); }); - it('throws when persisted preferences contain unknown fields', async () => { - readFileMock.mockResolvedValueOnce(JSON.stringify({ schemaVersion: 5, accidental: true })); + it('ignores unknown fields in persisted preferences from stale local builds', async () => { + readFileMock.mockResolvedValueOnce( + JSON.stringify({ + schemaVersion: 8, + generationTimeoutSec: 900, + localWorkspaceDefaultMode: 'work-on-project', + }), + ); - await expect(readPersisted()).rejects.toMatchObject({ - code: ERROR_CODES.PREFERENCES_READ_FAIL, - message: expect.stringContaining('unsupported field'), - }); + await expect(readPersisted()).resolves.toMatchObject({ generationTimeoutSec: 900 }); }); it('migrates schemaVersion 1 with legacy 120s timeout to the 1200s default', async () => { @@ -229,7 +232,7 @@ describe('readPersisted()', () => { schemaVersion: number; diagnosticsLastReadTs: number; }; - expect(written.schemaVersion).toBe(7); + expect(written.schemaVersion).toBe(8); expect(written.diagnosticsLastReadTs).toBe(result.diagnosticsLastReadTs); expect(written.diagnosticsLastReadTs).toBeGreaterThanOrEqual(before); expect(written.diagnosticsLastReadTs).toBeLessThanOrEqual(after); @@ -349,7 +352,7 @@ describe('preferences memory schema fields', () => { workspaceMemoryAutoUpdate: boolean; userMemoryAutoUpdate: boolean; }; - expect(written.schemaVersion).toBe(7); + expect(written.schemaVersion).toBe(8); expect(written.memoryEnabled).toBe(false); expect(written.workspaceMemoryAutoUpdate).toBe(false); expect(written.userMemoryAutoUpdate).toBe(true); diff --git a/apps/desktop/src/main/preferences-ipc.ts b/apps/desktop/src/main/preferences-ipc.ts index f57f96dc..ac83534a 100644 --- a/apps/desktop/src/main/preferences-ipc.ts +++ b/apps/desktop/src/main/preferences-ipc.ts @@ -17,7 +17,7 @@ import { getLogger } from './logger'; const logger = getLogger('preferences-ipc'); -const SCHEMA_VERSION = 7; +const SCHEMA_VERSION = 8; // v1 → v2: raise the abandoned 120s timeout default (which aborted real // agentic runs mid-loop) to 600s. Values that happen to equal the old // default are treated as unmigrated defaults, not user intent. @@ -75,7 +75,6 @@ const PREFERENCE_UPDATE_FIELDS = [ 'workspaceMemoryAutoUpdate', 'userMemoryAutoUpdate', ] as const; -const PERSISTED_PREFERENCE_FIELDS = ['schemaVersion', ...PREFERENCE_UPDATE_FIELDS] as const; function assertKnownPreferenceFields(r: Record): void { for (const key of Object.keys(r)) { @@ -95,14 +94,6 @@ function failInvalidPersistedPreference(message: string): never { ); } -function assertKnownPersistedFields(r: Record): void { - for (const key of Object.keys(r)) { - if (!(PERSISTED_PREFERENCE_FIELDS as readonly string[]).includes(key)) { - failInvalidPersistedPreference(`unsupported field "${key}"`); - } - } -} - function readPersistedSchema(r: Record): number { const value = r['schemaVersion']; if (value === undefined) return 1; @@ -186,8 +177,11 @@ function parsePersistedFile(rawJson: unknown): Preferences { failInvalidPersistedPreference('preferences.json must contain an object'); } const parsed = rawJson as Record; - assertKnownPersistedFields(parsed); const persistedSchema = readPersistedSchema(parsed); + // Persisted preferences live on the user's machine and may contain keys + // from short-lived experimental builds. Once the schema version is known to + // be supported, ignore unknown keys so a stale local setting cannot brick + // startup after a clean rebuild. IPC update payloads remain strict. const rawTimeout = readPersistedTimeout(parsed); const migratedTimeout = persistedSchema < 2 && rawTimeout === V1_DEFAULT_TIMEOUT_SEC diff --git a/apps/desktop/src/main/session-chat.test.ts b/apps/desktop/src/main/session-chat.test.ts index 158124e2..5c654dad 100644 --- a/apps/desktop/src/main/session-chat.test.ts +++ b/apps/desktop/src/main/session-chat.test.ts @@ -89,6 +89,29 @@ describe('session design brief storage', () => { } }); + it('keeps shared-workspace conversations isolated by design id', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'codesign-session-shared-')); + try { + const db = initSnapshotsDb(path.join(root, 'design-store.json')); + const source = createDesign(db, 'Existing conversation'); + const fresh = createDesign(db, 'Fresh conversation'); + updateDesignWorkspace(db, source.id, root); + updateDesignWorkspace(db, fresh.id, root); + const opts = { db, sessionDir: db.sessionDir }; + + appendSessionChatMessage(opts, { + designId: source.id, + kind: 'user', + payload: { text: 'keep this in the original session' }, + }); + + expect(listSessionChatMessages(opts, source.id)).toHaveLength(1); + expect(listSessionChatMessages(opts, fresh.id)).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('does not make seeded legacy snapshot history look like fresh activity', async () => { const root = await mkdtemp(path.join(tmpdir(), 'codesign-session-seed-')); vi.useFakeTimers(); diff --git a/apps/desktop/src/main/snapshots-db.test.ts b/apps/desktop/src/main/snapshots-db.test.ts index fcf90646..5d37a7a2 100644 --- a/apps/desktop/src/main/snapshots-db.test.ts +++ b/apps/desktop/src/main/snapshots-db.test.ts @@ -13,6 +13,7 @@ import { listSnapshots, recordDiagnosticEvent, touchDesignActivity, + updateDesignPreview, updateDesignWorkspace, upsertDesignFile, } from './snapshots-db'; @@ -109,4 +110,16 @@ describe('json design store', () => { vi.useRealTimers(); } }); + + it('keeps the saved preview URL when switching back to managed preview', () => { + const db = initInMemoryDb(); + const design = createDesign(db, 'Preview settings'); + + updateDesignPreview(db, design.id, 'connected-url', 'http://localhost:5173/'); + const managed = updateDesignPreview(db, design.id, 'managed-file', null); + + expect(managed?.previewMode).toBe('managed-file'); + expect(managed?.previewUrl).toBe('http://localhost:5173/'); + expect(getDesign(db, design.id)?.previewUrl).toBe('http://localhost:5173/'); + }); }); diff --git a/apps/desktop/src/main/snapshots-db.ts b/apps/desktop/src/main/snapshots-db.ts index 7681b951..618c810e 100644 --- a/apps/desktop/src/main/snapshots-db.ts +++ b/apps/desktop/src/main/snapshots-db.ts @@ -15,7 +15,9 @@ import type { DiagnosticEventInput, DiagnosticEventRow, DiagnosticLevel, + PreviewMode, SnapshotCreateInput, + WorkspaceMode, } from '@open-codesign/shared'; import { assertWorkspacePath } from './workspace-path'; @@ -259,6 +261,7 @@ export function updateDesignWorkspace( db: Database, id: string, workspacePath: string, + workspaceMode?: WorkspaceMode, ): Design | null { const checkedWorkspacePath = assertWorkspacePath(workspacePath); return mutateStore(db, (data) => { @@ -269,6 +272,32 @@ export function updateDesignWorkspace( const updated: Design = { ...current, workspacePath: checkedWorkspacePath, + ...(workspaceMode !== undefined ? { workspaceMode } : {}), + updatedAt: nowIso(), + }; + data.designs[idx] = updated; + return updated; + }); +} + +export function updateDesignPreview( + db: Database, + id: string, + previewMode: PreviewMode, + previewUrl: string | null, +): Design | null { + return mutateStore(db, (data) => { + const idx = data.designs.findIndex((design) => design.id === id); + if (idx < 0) return null; + const current = data.designs[idx]; + if (current === undefined) return null; + const updated: Design = { + ...current, + previewMode, + previewUrl: + previewMode === 'connected-url' || previewMode === 'external-app' + ? previewUrl + : (current.previewUrl ?? null), updatedAt: nowIso(), }; data.designs[idx] = updated; diff --git a/apps/desktop/src/main/snapshots-ipc.ts b/apps/desktop/src/main/snapshots-ipc.ts index 626f2a87..302904ae 100644 --- a/apps/desktop/src/main/snapshots-ipc.ts +++ b/apps/desktop/src/main/snapshots-ipc.ts @@ -9,7 +9,7 @@ * initSnapshotsDb(). */ -import { copyFile, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { ChatAppendInput, @@ -19,6 +19,7 @@ import type { CommentUpdateInput, Design, DesignSnapshot, + PreviewMode, SnapshotCreateInput, } from '@open-codesign/shared'; import { ChatMessageKind, CodesignError, CommentKind, CommentRect } from '@open-codesign/shared'; @@ -67,6 +68,7 @@ import { setDesignThumbnail, softDeleteDesign, touchDesignActivity, + updateDesignPreview, updateDesignWorkspace, upsertDesignFile, } from './snapshots-db'; @@ -78,10 +80,13 @@ import { withStableWorkspacePath, } from './workspace-path-lock'; import { + assertWorkspacePathVisible, classifyWorkspaceFileKind, + listWorkspaceDirectoryAt, listWorkspaceFilesAt, readWorkspaceFileAt, resolveSafeWorkspaceChildPath, + type WorkspaceDirectoryEntry, type WorkspaceFileEntry, type WorkspaceFileReadResult, } from './workspace-reader'; @@ -236,6 +241,359 @@ function requireBoundWorkspacePath(design: Design, message: string): string { } } +function parsePreviewMode(value: unknown): PreviewMode { + if ( + value === 'managed-file' || + value === 'connected-url' || + value === 'external-app' || + value === 'none' + ) { + return value; + } + throw new CodesignError( + 'previewMode must be managed-file, connected-url, external-app, or none', + 'IPC_BAD_INPUT', + ); +} + +function normalizeConnectedPreviewUrl(value: unknown): string | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value !== 'string') { + throw new CodesignError('previewUrl must be a string or null', 'IPC_BAD_INPUT'); + } + let url: URL; + try { + url = new URL(value.trim()); + } catch (cause) { + throw new CodesignError('previewUrl must be a valid URL', 'IPC_BAD_INPUT', { cause }); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new CodesignError('previewUrl must start with http:// or https://', 'IPC_BAD_INPUT'); + } + return url.toString(); +} + +const APP_PROJECT_ROOT_FILES = [ + 'angular.json', + 'astro.config.cjs', + 'astro.config.js', + 'astro.config.mjs', + 'astro.config.ts', + 'next.config.cjs', + 'next.config.js', + 'next.config.mjs', + 'next.config.ts', + 'nuxt.config.js', + 'nuxt.config.mjs', + 'nuxt.config.ts', + 'parcel.config.js', + 'remix.config.js', + 'remix.config.ts', + 'svelte.config.js', + 'svelte.config.ts', + 'src-tauri/Cargo.toml', + 'src-tauri/Tauri.toml', + 'src-tauri/tauri.conf.json', + 'src-tauri/tauri.conf.json5', + 'src-tauri/tauri.conf.toml', + 'tauri.conf.json', + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.ts', + 'webpack.config.js', + 'webpack.config.ts', +] as const; + +const NATIVE_APP_PROJECT_FILES = [ + 'src-tauri/Cargo.toml', + 'src-tauri/Tauri.toml', + 'src-tauri/tauri.conf.json', + 'src-tauri/tauri.conf.json5', + 'src-tauri/tauri.conf.toml', + 'tauri.conf.json', + 'electron-builder.yml', + 'electron-builder.yaml', + 'electron.vite.config.js', + 'electron.vite.config.mjs', + 'electron.vite.config.ts', + 'capacitor.config.json', + 'capacitor.config.ts', +] as const; + +const COMMON_DEV_SERVER_PORTS = [ + 5173, 3000, 3001, 5174, 4173, 4200, 4321, 8080, 8000, 5000, 6006, 1234, 1420, +] as const; + +interface PreviewDetectCandidate { + url: string; + source: string; + status: 'matched' | 'native-runtime-required' | 'not-preview' | 'unreachable'; + httpStatus?: number; + contentType?: string; + title?: string; + error?: string; +} + +interface PreviewDetectResult { + schemaVersion: 1; + found: boolean; + url: string | null; + candidates: PreviewDetectCandidate[]; + message: string; +} + +interface PreviewCandidateSpec { + url: string; + source: string; +} + +async function readWorkspacePackageJson( + workspacePath: string, +): Promise | null> { + try { + const raw = await readFile(path.join(workspacePath, 'package.json'), 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function packageScripts(pkg: Record | null): string[] { + if (pkg === null || !isRecord(pkg['scripts'])) return []; + return Object.values(pkg['scripts']).filter( + (value): value is string => typeof value === 'string', + ); +} + +function packageNamesFromManifest(pkg: Record | null): Set { + const names = new Set(); + if (pkg === null) return names; + for (const key of ['dependencies', 'devDependencies', 'peerDependencies']) { + const entry = pkg[key]; + if (!isRecord(entry)) continue; + for (const name of Object.keys(entry)) names.add(name); + } + return names; +} + +function scriptsSuggestNativeRuntime(scripts: string[]): boolean { + return scripts.some((script) => /\b(?:tauri|electron|capacitor)\b/i.test(script)); +} + +function packageManifestLooksLikeApplicationProject(pkg: Record | null): boolean { + const names = packageNamesFromManifest(pkg); + if ( + [ + '@angular/core', + '@astrojs/react', + '@capacitor/core', + '@remix-run/react', + '@sveltejs/kit', + '@tauri-apps/api', + '@vitejs/plugin-react', + 'astro', + 'electron', + 'next', + 'nuxt', + 'parcel', + 'react-scripts', + 'svelte', + 'vite', + 'webpack', + ].some((name) => names.has(name)) + ) { + return true; + } + return packageScripts(pkg).some((script) => + /\b(?:astro|capacitor|electron|next|nuxt|parcel|react-scripts|remix|svelte-kit|tauri|vite|webpack)\b/i.test( + script, + ), + ); +} + +async function workspaceRequiresNativeRuntime(input: { + workspacePath: string; + packageNames: Set; + scripts: string[]; +}): Promise { + if ( + input.packageNames.has('@tauri-apps/api') || + input.packageNames.has('electron') || + input.packageNames.has('@capacitor/core') || + Array.from(input.packageNames).some( + (name) => name.startsWith('@tauri-apps/plugin-') || name.startsWith('tauri-plugin-'), + ) || + scriptsSuggestNativeRuntime(input.scripts) + ) { + return true; + } + return workspaceHasAnyFile(input.workspacePath, NATIVE_APP_PROJECT_FILES); +} + +function extractPortsFromScripts(scripts: string[]): number[] { + const ports = new Set(); + const pattern = + /(?:--port|--https-port|-p)\s+([0-9]{2,5})|(?:PORT|VITE_PORT|NUXT_PORT|ASTRO_PORT|STORYBOOK_PORT)\s*=\s*([0-9]{2,5})|(?:localhost|127\.0\.0\.1|\[::1\]):([0-9]{2,5})/gi; + for (const script of scripts) { + for (const match of script.matchAll(pattern)) { + const raw = match[1] ?? match[2] ?? match[3]; + const port = raw ? Number.parseInt(raw, 10) : Number.NaN; + if (Number.isInteger(port) && port > 0 && port <= 65535) ports.add(port); + } + } + return Array.from(ports); +} + +async function workspaceHasAnyFile( + workspacePath: string, + files: readonly string[], +): Promise { + for (const file of files) { + if (await pathExists(path.join(workspacePath, file))) return true; + } + return false; +} + +export async function workspaceLooksLikeApplicationProject( + workspacePath: string, +): Promise { + if (await workspaceHasAnyFile(workspacePath, APP_PROJECT_ROOT_FILES)) return true; + return packageManifestLooksLikeApplicationProject(await readWorkspacePackageJson(workspacePath)); +} + +function addPreviewCandidate( + candidates: Map, + url: string, + source: string, +): void { + let normalized: string | null; + try { + normalized = normalizeConnectedPreviewUrl(url); + } catch { + normalized = null; + } + if (normalized === null || candidates.has(normalized)) return; + candidates.set(normalized, { url: normalized, source }); +} + +async function localPreviewCandidatesForWorkspace(input: { + workspacePath: string; + currentUrl?: string | null; +}): Promise { + const candidates = new Map(); + if (input.currentUrl) addPreviewCandidate(candidates, input.currentUrl, 'saved preview URL'); + + const pkg = await readWorkspacePackageJson(input.workspacePath); + for (const port of extractPortsFromScripts(packageScripts(pkg))) { + addPreviewCandidate(candidates, `http://localhost:${port}/`, 'package.json script'); + } + for (const port of COMMON_DEV_SERVER_PORTS) { + addPreviewCandidate(candidates, `http://localhost:${port}/`, 'common local preview port'); + } + return Array.from(candidates.values()); +} + +function titleFromHtml(html: string): string | undefined { + const match = html.match(/]*>([^<]*)<\/title>/i); + const title = match?.[1]?.trim(); + return title && title.length > 0 ? title : undefined; +} + +function looksLikeHtmlPreview(contentType: string, body: string): boolean { + const lowerContentType = contentType.toLowerCase(); + return ( + lowerContentType.includes('text/html') || + /^\s*]/i.test(body) + ); +} + +function responseDisallowsEmbeddedPreview(headers: Headers): boolean { + const xFrameOptions = headers.get('x-frame-options')?.toLowerCase() ?? ''; + if (xFrameOptions.includes('deny') || xFrameOptions.includes('sameorigin')) return true; + + const csp = headers.get('content-security-policy')?.toLowerCase() ?? ''; + const frameAncestors = csp.match(/(?:^|;)\s*frame-ancestors\s+([^;]+)/u)?.[1] ?? ''; + return frameAncestors.includes("'none'") || frameAncestors.includes("'self'"); +} + +async function probePreviewCandidate( + candidate: PreviewCandidateSpec, + options: { nativeRuntimeRequired: boolean }, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 700); + try { + const response = await fetch(candidate.url, { signal: controller.signal, redirect: 'follow' }); + const contentType = response.headers.get('content-type') ?? ''; + const body = await response.text().catch(() => ''); + const matched = response.status < 500 && looksLikeHtmlPreview(contentType, body); + const needsExternalPreview = + matched && + (options.nativeRuntimeRequired || responseDisallowsEmbeddedPreview(response.headers)); + const title = titleFromHtml(body); + return { + url: candidate.url, + source: candidate.source, + status: matched + ? needsExternalPreview + ? 'native-runtime-required' + : 'matched' + : 'not-preview', + httpStatus: response.status, + ...(contentType.length > 0 ? { contentType } : {}), + ...(title ? { title } : {}), + }; + } catch (err) { + return { + url: candidate.url, + source: candidate.source, + status: 'unreachable', + error: + err instanceof Error && err.name === 'AbortError' + ? 'timeout' + : err instanceof Error + ? err.message + : String(err), + }; + } finally { + clearTimeout(timeout); + } +} + +export async function detectLocalPreviewServer(input: { + workspacePath: string; + currentUrl?: string | null; +}): Promise { + const pkg = await readWorkspacePackageJson(input.workspacePath); + const scripts = packageScripts(pkg); + const nativeRuntimeRequired = await workspaceRequiresNativeRuntime({ + workspacePath: input.workspacePath, + packageNames: packageNamesFromManifest(pkg), + scripts, + }); + const candidates = await localPreviewCandidatesForWorkspace(input); + const probed = await Promise.all( + candidates.map((candidate) => probePreviewCandidate(candidate, { nativeRuntimeRequired })), + ); + const match = probed.find((candidate) => candidate.status === 'matched') ?? null; + const nativeMatch = + probed.find((candidate) => candidate.status === 'native-runtime-required') ?? null; + return { + schemaVersion: 1, + found: match !== null, + url: match?.url ?? null, + candidates: probed, + message: + match !== null + ? `Found a local preview at ${match.url}` + : nativeMatch !== null + ? `Found a local native app at ${nativeMatch.url}. Use External app preview.` + : 'No running local preview server was found.', + }; +} + /** * Translate store errors into typed CodesignErrors so the renderer never sees * low-level persistence details. @@ -521,7 +879,7 @@ export async function renameAutoManagedWorkspaceForDesign(input: { await rename(currentPath, nextPath); return runDb('rename-design.workspace', () => - updateDesignWorkspace(input.db, input.designBeforeRename.id, nextPath), + updateDesignWorkspace(input.db, input.designBeforeRename.id, nextPath, 'blank-canvas'), ); } @@ -536,6 +894,15 @@ function requireSchemaV1(r: Record, channel: string): void { } } +function parseRenameWorkspaceOption(r: Record): boolean { + const value = r['renameWorkspace']; + if (value === undefined) return true; + if (typeof value !== 'boolean') { + throw new CodesignError('renameWorkspace must be a boolean when provided', 'IPC_BAD_INPUT'); + } + return value; +} + function parseSnapshotCreateInput(raw: unknown): SnapshotCreateInput { if (typeof raw !== 'object' || raw === null) { throw new CodesignError('snapshots:v1:create expects an object payload', 'IPC_BAD_INPUT'); @@ -909,7 +1276,13 @@ export function registerSnapshotsIpc(db: Database): void { if (requestedWorkspacePath === undefined) { autoWorkspacePath = workspacePath; } - return await bindWorkspace(db, design.id, workspacePath, false); + return await bindWorkspace( + db, + design.id, + workspacePath, + false, + requestedWorkspacePath === undefined ? 'blank-canvas' : 'work-on-project', + ); } catch (err) { if (autoWorkspacePath !== null) { await cleanupAutoAllocatedWorkspace(autoWorkspacePath, 'create-design'); @@ -953,6 +1326,7 @@ export function registerSnapshotsIpc(db: Database): void { } const designId = r['id'] as string; const name = r['name'] as string; + const renameWorkspace = parseRenameWorkspaceOption(r); return await runWithWorkspaceRenameQueue(designId, async () => { const before = runDb('rename-design.lookup', () => getDesign(db, designId)); if (before === null) { @@ -963,20 +1337,22 @@ export function registerSnapshotsIpc(db: Database): void { throw new CodesignError('Design not found', 'IPC_NOT_FOUND'); } let finalDesign = updated; - try { - finalDesign = - (await renameAutoManagedWorkspaceForDesign({ - db, - designBeforeRename: before, - newName: updated.name, - })) ?? updated; - } catch (err) { - logger.warn('design.workspace_rename.skipped', { - id: updated.id, - workspacePath: before.workspacePath, - targetName: updated.name, - error: err instanceof Error ? err.message : String(err), - }); + if (renameWorkspace) { + try { + finalDesign = + (await renameAutoManagedWorkspaceForDesign({ + db, + designBeforeRename: before, + newName: updated.name, + })) ?? updated; + } catch (err) { + logger.warn('design.workspace_rename.skipped', { + id: updated.id, + workspacePath: before.workspacePath, + targetName: updated.name, + error: err instanceof Error ? err.message : String(err), + }); + } } logger.info('design.renamed', { id: finalDesign.id, @@ -1059,7 +1435,7 @@ export function registerSnapshotsIpc(db: Database): void { const workspacePath = await allocateDefaultWorkspacePath(name); autoWorkspacePath = workspacePath; await copyTrackedWorkspaceFiles(db, sourceId, sourceWorkspacePath, workspacePath); - const bound = await bindWorkspace(db, cloned.id, workspacePath, false); + const bound = await bindWorkspace(db, cloned.id, workspacePath, false, 'blank-canvas'); logger.info('design.duplicated', { sourceId, newId: bound.id }); return bound; } catch (err) { @@ -1084,6 +1460,96 @@ export function registerSnapshotsIpc(db: Database): void { }, ); + ipcMain.handle( + 'snapshots:v1:preview:update', + async (_e: unknown, raw: unknown): Promise => { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError( + 'snapshots:v1:preview:update expects an object payload', + 'IPC_BAD_INPUT', + ); + } + const r = raw as Record; + requireSchemaV1(r, 'snapshots:v1:preview:update'); + 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; + const previewMode = parsePreviewMode(r['previewMode']); + const previewUrl = normalizeConnectedPreviewUrl(r['previewUrl']); + if (previewMode === 'connected-url' && previewUrl === null) { + throw new CodesignError( + 'previewUrl is required when previewMode is connected-url', + 'IPC_BAD_INPUT', + ); + } + const current = await getDesignAfterPendingWorkspaceRename(db, 'preview:update', designId); + if (current === null) { + throw new CodesignError('Design not found', 'IPC_NOT_FOUND'); + } + if ( + previewMode === 'managed-file' && + current.workspacePath !== null && + (await workspaceLooksLikeApplicationProject( + requireBoundWorkspacePath(current, 'No workspace bound to this design'), + )) + ) { + throw new CodesignError( + 'Integrated preview is not available for app workspaces. Use Local URL or Off.', + 'IPC_BAD_INPUT', + ); + } + + const updated = runDb('preview:update', () => + updateDesignPreview(db, designId, previewMode, previewUrl), + ); + if (updated === null) { + throw new CodesignError('Design not found', 'IPC_NOT_FOUND'); + } + logger.info('design.preview_updated', { + id: updated.id, + previewMode: updated.previewMode, + previewUrl: updated.previewUrl, + }); + return updated; + }, + ); + + ipcMain.handle( + 'snapshots:v1:preview:detect', + async (_e: unknown, raw: unknown): Promise => { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError( + 'snapshots:v1:preview:detect expects an object payload', + 'IPC_BAD_INPUT', + ); + } + const r = raw as Record; + requireSchemaV1(r, 'snapshots:v1:preview:detect'); + 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; + const design = await getDesignAfterPendingWorkspaceRename(db, 'preview:detect', designId); + if (design === null) { + throw new CodesignError('Design not found', 'IPC_NOT_FOUND'); + } + const workspacePath = requireBoundWorkspacePath(design, 'No workspace bound to this design'); + const result = await detectLocalPreviewServer({ + workspacePath, + currentUrl: design.previewUrl ?? null, + }); + logger.info('design.preview_detected', { + id: design.id, + found: result.found, + url: result.url, + candidateCount: result.candidates.length, + }); + return result; + }, + ); + ipcMain.handle('chat:v1:list', (_e: unknown, raw: unknown): ChatMessageRow[] => { const designId = parseDesignIdPayload(raw, 'chat:v1:list'); return runDb('chat:list', () => listSessionChatMessages(chatStoreOptions(db), designId)); @@ -1226,6 +1692,7 @@ export function registerWorkspaceIpc(db: Database, getWin: () => BrowserWindow | r['designId'] as string, workspacePath, r['migrateFiles'] as boolean, + 'work-on-project', ); if (design === null) { throw new CodesignError('Design not found', 'IPC_NOT_FOUND'); @@ -1353,6 +1820,54 @@ export function registerWorkspaceIpc(db: Database, getWin: () => BrowserWindow | }, ); + ipcMain.handle( + 'codesign:files:v1:list-dir', + async (_e: unknown, raw: unknown): Promise => { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError( + 'codesign:files:v1:list-dir expects { designId, path }', + 'IPC_BAD_INPUT', + ); + } + const r = raw as Record; + requireSchemaV1(r, 'codesign:files:v1:list-dir'); + if (typeof r['designId'] !== 'string' || r['designId'].trim().length === 0) { + throw new CodesignError('designId must be a non-empty string', 'IPC_BAD_INPUT'); + } + const dirPath = r['path'] === undefined ? '.' : r['path']; + if (typeof dirPath !== 'string') { + throw new CodesignError('path must be a string', 'IPC_BAD_INPUT'); + } + const designId = r['designId'] as string; + return withStableWorkspacePath(designId, async () => { + const design = await getDesignAfterPendingWorkspaceRename(db, 'files:list-dir', designId); + if (design === null) { + throw new CodesignError('Design not found', 'IPC_NOT_FOUND'); + } + if (design.workspacePath === null) { + logger.warn('files.listDir.workspace_missing', { designId: design.id }); + return []; + } + const workspacePath = requireBoundWorkspacePath( + design, + 'Design is not bound to a workspace', + ); + if (!(await checkWorkspaceFolderExists(workspacePath))) { + logger.warn('files.listDir.workspace_unavailable', { + designId: design.id, + workspacePath, + }); + return []; + } + try { + return await listWorkspaceDirectoryAt(workspacePath, dirPath); + } catch (cause) { + throw new CodesignError('Failed to list workspace directory', 'IPC_DB_ERROR', { cause }); + } + }); + }, + ); + ipcMain.handle( 'codesign:files:v1:read', async (_e: unknown, raw: unknown): Promise => { @@ -1428,6 +1943,7 @@ export function registerWorkspaceIpc(db: Database, getWin: () => BrowserWindow | let normalizedPath: string; try { normalizedPath = normalizeDesignFilePath(r['path'] as string); + assertWorkspacePathVisible(normalizedPath); } catch (cause) { throw new CodesignError('Invalid workspace file path', 'IPC_BAD_INPUT', { cause }); } @@ -1479,6 +1995,7 @@ export function registerWorkspaceIpc(db: Database, getWin: () => BrowserWindow | let normalizedPath: string; try { normalizedPath = normalizeDesignFilePath(r['path'] as string); + assertWorkspacePathVisible(normalizedPath); } catch (cause) { throw new CodesignError('Invalid workspace file path', 'IPC_BAD_INPUT', { cause }); } @@ -1521,6 +2038,7 @@ export function registerWorkspaceIpc(db: Database, getWin: () => BrowserWindow | let normalizedPath: string; try { normalizedPath = normalizeDesignFilePath(r['path'] as string); + assertWorkspacePathVisible(normalizedPath); } catch (cause) { throw new CodesignError('Invalid workspace file path', 'IPC_BAD_INPUT', { cause }); } @@ -1721,7 +2239,10 @@ export const SNAPSHOTS_CHANNELS_V1 = [ 'snapshots:v1:workspace:update', 'snapshots:v1:workspace:open', 'snapshots:v1:workspace:check', + 'snapshots:v1:preview:update', + 'snapshots:v1:preview:detect', 'codesign:files:v1:list', + 'codesign:files:v1:list-dir', 'codesign:files:v1:read', 'codesign:files:v1:preview', 'codesign:files:v1:thumbnail', diff --git a/apps/desktop/src/main/snapshots-ipc.workspace-files.test.ts b/apps/desktop/src/main/snapshots-ipc.workspace-files.test.ts index 3aca870c..78af3f52 100644 --- a/apps/desktop/src/main/snapshots-ipc.workspace-files.test.ts +++ b/apps/desktop/src/main/snapshots-ipc.workspace-files.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -52,7 +52,9 @@ describe('workspace files IPC legacy workspace fallback', () => { it('returns an empty file list when the bound workspace folder is missing', async () => { const db = initInMemoryDb(); const design = createDesign(db, 'Missing workspace folder'); - updateDesignWorkspace(db, design.id, '/tmp/open-codesign-missing-workspace-for-list'); + const missingWorkspace = path.join(tmpdir(), 'open-codesign-missing-workspace-for-list'); + await rm(missingWorkspace, { recursive: true, force: true }); + updateDesignWorkspace(db, design.id, missingWorkspace); registerWorkspaceIpc(db, () => null); const list = getHandler('codesign:files:v1:list'); diff --git a/apps/desktop/src/main/snapshots-ipc.workspace-naming.test.ts b/apps/desktop/src/main/snapshots-ipc.workspace-naming.test.ts index 724d7f12..a3e81a9f 100644 --- a/apps/desktop/src/main/snapshots-ipc.workspace-naming.test.ts +++ b/apps/desktop/src/main/snapshots-ipc.workspace-naming.test.ts @@ -8,6 +8,7 @@ import { isAutoManagedWorkspacePath, renameAutoManagedWorkspaceForDesign, } from './snapshots-ipc'; +import { normalizeWorkspacePath } from './workspace-path'; vi.mock('./electron-runtime', () => ({ app: { @@ -79,7 +80,7 @@ describe('auto-managed workspace naming', () => { defaultRoot: root, }); - const expected = path.join(root, 'Studio-Loop-Welcome-Email'); + const expected = normalizeWorkspacePath(path.join(root, 'Studio-Loop-Welcome-Email')); expect(updated?.workspacePath).toBe(expected); expect(getDesign(db, design.id)?.workspacePath).toBe(expected); await expect(exists(oldWorkspace)).resolves.toBe(false); @@ -101,7 +102,9 @@ describe('auto-managed workspace naming', () => { defaultRoot: root, }); - expect(updated?.workspacePath).toBe(path.join(root, 'Studio-Loop-Welcome-Email-1')); + expect(updated?.workspacePath).toBe( + normalizeWorkspacePath(path.join(root, 'Studio-Loop-Welcome-Email-1')), + ); }); it('leaves user-chosen workspaces alone', async () => { @@ -119,7 +122,7 @@ describe('auto-managed workspace naming', () => { }); expect(updated).toBeNull(); - expect(getDesign(db, design.id)?.workspacePath).toBe(userWorkspace); + expect(getDesign(db, design.id)?.workspacePath).toBe(normalizeWorkspacePath(userWorkspace)); await expect(exists(userWorkspace)).resolves.toBe(true); } finally { await rm(userWorkspace, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/snapshots-ipc.workspace-rename-race.test.ts b/apps/desktop/src/main/snapshots-ipc.workspace-rename-race.test.ts index 97ed6b38..760d82b4 100644 --- a/apps/desktop/src/main/snapshots-ipc.workspace-rename-race.test.ts +++ b/apps/desktop/src/main/snapshots-ipc.workspace-rename-race.test.ts @@ -2,14 +2,29 @@ import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { Design } from '@open-codesign/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createDesign, initInMemoryDb, updateDesignWorkspace } from './snapshots-db'; -import { registerSnapshotsIpc, registerWorkspaceIpc } from './snapshots-ipc'; +import { createDesign, getDesign, initInMemoryDb, updateDesignWorkspace } from './snapshots-db'; +import { + detectLocalPreviewServer, + registerSnapshotsIpc, + registerWorkspaceIpc, +} from './snapshots-ipc'; +import { normalizeWorkspacePath } from './workspace-path'; import { withStableWorkspacePath } from './workspace-path-lock'; import type { WorkspaceFileEntry } from './workspace-reader'; type Handler = (event: unknown, raw: unknown) => unknown; const handlers = vi.hoisted(() => new Map()); +const testRoots = vi.hoisted(() => { + const base = ( + process.env['RUNNER_TEMP'] ?? + process.env['TMPDIR'] ?? + process.env['TEMP'] ?? + process.env['TMP'] ?? + (process.platform === 'win32' ? 'C:/Temp' : '/tmp') + ).replaceAll('\\', '/'); + return { documentsRoot: `${base}/open-codesign-rename-tests` }; +}); const renameControl = vi.hoisted(() => { let markStarted: (() => void) | null = null; let release: (() => void) | null = null; @@ -58,7 +73,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { vi.mock('./electron-runtime', () => ({ app: { - getPath: vi.fn(() => '/tmp/open-codesign-tests'), + getPath: vi.fn(() => testRoots.documentsRoot), }, dialog: { showOpenDialog: vi.fn(), @@ -91,7 +106,7 @@ async function exists(p: string): Promise { } describe('workspace files IPC during auto-managed workspace renames', () => { - const documentsRoot = '/tmp/open-codesign-tests'; + const documentsRoot = testRoots.documentsRoot; const defaultWorkspaceRoot = path.join(documentsRoot, 'CoDesign'); let root: string; @@ -142,10 +157,136 @@ describe('workspace files IPC during auto-managed workspace renames', () => { renameControl.release(); const [updated, files] = await Promise.all([renamePromise, listPromise]); - expect(updated.workspacePath).toBe(path.join(root, 'General-Agent-Benchmark-Deck')); + expect(updated.workspacePath).toBe( + normalizeWorkspacePath(path.join(root, 'General-Agent-Benchmark-Deck')), + ); expect(files.map((file) => file.path)).toContain('App.jsx'); }); + it('marks reachable Tauri dev servers as external app previews', async () => { + const workspace = path.join(root, 'MadWhisp'); + await mkdir(path.join(workspace, 'src-tauri'), { recursive: true }); + await writeFile(path.join(workspace, 'src-tauri', 'tauri.conf.json'), '{}', 'utf8'); + + const fetchMock = vi.fn(async () => { + return new Response('MadWhisp', { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + try { + const result = await detectLocalPreviewServer({ + workspacePath: workspace, + currentUrl: 'http://localhost:1420/', + }); + + expect(result.found).toBe(false); + expect(result.url).toBeNull(); + expect( + result.candidates.find((candidate) => candidate.url === 'http://localhost:1420/'), + ).toMatchObject({ + status: 'native-runtime-required', + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('can rename design metadata without moving an auto-managed workspace folder', async () => { + const db = initInMemoryDb(); + const design = createDesign(db, 'Untitled design 1'); + const oldWorkspace = path.join(root, 'Untitled-design-1'); + const newWorkspace = path.join(root, 'Studio-Loop-Welcome-Email'); + await mkdir(oldWorkspace); + await writeFile(path.join(oldWorkspace, 'App.jsx'), 'function App() { return null; }', 'utf8'); + updateDesignWorkspace(db, design.id, oldWorkspace); + registerSnapshotsIpc(db); + + const renameDesign = getHandler('snapshots:v1:rename-design'); + const updated = (await renameDesign(null, { + schemaVersion: 1, + id: design.id, + name: 'Studio Loop Welcome Email', + renameWorkspace: false, + })) as Design; + + expect(updated.name).toBe('Studio Loop Welcome Email'); + expect(updated.workspacePath).toBe(normalizeWorkspacePath(oldWorkspace)); + await expect(exists(path.join(oldWorkspace, 'App.jsx'))).resolves.toBe(true); + await expect(exists(newWorkspace)).resolves.toBe(false); + }); + + it('stores a connected preview URL without moving the workspace folder', async () => { + const db = initInMemoryDb(); + const design = createDesign(db, 'Local app'); + const workspace = path.join(root, 'local-app'); + await mkdir(workspace); + updateDesignWorkspace(db, design.id, workspace, 'work-on-project'); + registerSnapshotsIpc(db); + + const updatePreview = getHandler('snapshots:v1:preview:update'); + const updated = (await updatePreview(null, { + schemaVersion: 1, + designId: design.id, + previewMode: 'connected-url', + previewUrl: 'http://localhost:5173', + })) as Design; + + expect(updated.previewMode).toBe('connected-url'); + expect(updated.previewUrl).toBe('http://localhost:5173/'); + expect(updated.workspacePath).toBe(normalizeWorkspacePath(workspace)); + expect(getDesign(db, design.id)?.previewUrl).toBe('http://localhost:5173/'); + await expect(exists(workspace)).resolves.toBe(true); + }); + + it('rejects integrated file preview for an app-shaped workspace', async () => { + const db = initInMemoryDb(); + const design = createDesign(db, 'App workspace'); + const workspace = path.join(root, 'app-workspace'); + await mkdir(workspace); + await writeFile(path.join(workspace, 'package.json'), '{"scripts":{"dev":"vite"}}', 'utf8'); + updateDesignWorkspace(db, design.id, workspace, 'work-on-project'); + registerSnapshotsIpc(db); + + const updatePreview = getHandler('snapshots:v1:preview:update'); + + await expect( + updatePreview(null, { + schemaVersion: 1, + designId: design.id, + previewMode: 'managed-file', + previewUrl: null, + }), + ).rejects.toMatchObject({ + name: 'CodesignError', + code: 'IPC_BAD_INPUT', + }); + }); + + it('allows integrated file preview for a simple HTML workspace with package metadata', async () => { + const db = initInMemoryDb(); + const design = createDesign(db, 'Simple HTML workspace'); + const workspace = path.join(root, 'simple-html-workspace'); + await mkdir(workspace); + await writeFile(path.join(workspace, 'index.html'), '
Hello
', 'utf8'); + await writeFile(path.join(workspace, 'package.json'), '{"name":"static-page"}', 'utf8'); + updateDesignWorkspace(db, design.id, workspace, 'work-on-project'); + registerSnapshotsIpc(db); + + const updatePreview = getHandler('snapshots:v1:preview:update'); + + const updated = (await updatePreview(null, { + schemaVersion: 1, + designId: design.id, + previewMode: 'managed-file', + previewUrl: null, + })) as Design; + + expect(updated.previewMode).toBe('managed-file'); + }); + it('defers auto-managed workspace folder renames while generation owns a stable workspace path', async () => { const db = initInMemoryDb(); const design = createDesign(db, 'Untitled design 1'); @@ -188,7 +329,7 @@ describe('workspace files IPC during auto-managed workspace renames', () => { const updated = await renamePromise; await generationLease; - expect(updated.workspacePath).toBe(newWorkspace); + expect(updated.workspacePath).toBe(normalizeWorkspacePath(newWorkspace)); await expect(exists(oldWorkspace)).resolves.toBe(false); await expect(exists(path.join(newWorkspace, 'App.jsx'))).resolves.toBe(true); }); diff --git a/apps/desktop/src/main/workspace-reader.test.ts b/apps/desktop/src/main/workspace-reader.test.ts index 71202c6d..a9ccc421 100644 --- a/apps/desktop/src/main/workspace-reader.test.ts +++ b/apps/desktop/src/main/workspace-reader.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { classifyWorkspaceFileKind, + listWorkspaceDirectoryAt, listWorkspaceFilesAt, readWorkspaceFileAt, readWorkspaceFilesAt, @@ -102,12 +103,14 @@ describe('readWorkspaceFilesAt', () => { expect(result.map((f) => f.file)).toEqual(['index.html']); }); - it('throws when a matched source file cannot be read as UTF-8 text', async () => { + it('skips matched source files that are not UTF-8 text during bulk scans', async () => { await writeFile(join(root, 'ok.html'), '

ok

'); - // A stray NUL byte is our binary sniff. Writing .html keeps it on the - // default pattern so we prove the binary filter (not the glob) fails it. + await writeFile(join(root, 'invalid.txt'), Buffer.from([0xff, 0xfe, 0xfd])); await writeFile(join(root, 'binary.html'), Buffer.from([0x00, 0x01, 0x02, 0x03])); - await expect(readWorkspaceFilesAt(root)).rejects.toThrow(/Failed to read workspace file/); + + const result = await readWorkspaceFilesAt(root); + + expect(result.map((file) => file.file)).toEqual(['ok.html']); }); it('throws when the workspace root cannot be scanned', async () => { @@ -166,6 +169,37 @@ describe('workspace file metadata/read helpers', () => { ]); }); + it('lists only immediate visible children for lazy file tree loading', async () => { + await mkdir(join(root, 'src', 'components'), { recursive: true }); + await writeFile(join(root, 'src', 'App.tsx'), 'export function App() { return null; }'); + await writeFile(join(root, 'src', 'components', 'Button.tsx'), 'export function Button() {}'); + await writeFile(join(root, 'DESIGN.md'), '# Design'); + + const rootEntries = await listWorkspaceDirectoryAt(root, '.'); + expect(rootEntries.map((entry) => entry.path)).toEqual(['src', 'DESIGN.md']); + + const srcEntries = await listWorkspaceDirectoryAt(root, 'src'); + expect(srcEntries.map((entry) => entry.path)).toEqual(['src/components', 'src/App.tsx']); + }); + + it('hides agent worktrees, hidden files, and build outputs from workspace listings', async () => { + await mkdir(join(root, '.claude', 'worktrees', 'demo', 'src'), { recursive: true }); + await mkdir(join(root, 'target', 'debug'), { recursive: true }); + await mkdir(join(root, 'src'), { recursive: true }); + await writeFile(join(root, '.claude', 'worktrees', 'demo', 'src', 'App.tsx'), 'wrong app'); + await writeFile(join(root, 'target', 'debug', 'app.exe'), 'binary'); + await writeFile(join(root, 'build-out.txt'), Buffer.from([0xff, 0xfe, 0xfd])); + await writeFile(join(root, '.gitignore'), 'node_modules'); + await writeFile(join(root, 'CLAUDE.md'), '# stale model instructions'); + await writeFile(join(root, 'src', 'App.tsx'), 'right app'); + + const rootEntries = await listWorkspaceDirectoryAt(root, '.'); + expect(rootEntries.map((entry) => entry.path)).toEqual(['src']); + + const recursiveEntries = await listWorkspaceFilesAt(root); + expect(recursiveEntries.map((entry) => entry.path)).toEqual(['src/App.tsx']); + }); + it('skips operating system metadata files from workspace listings', async () => { await writeFile(join(root, '.DS_Store'), 'finder metadata'); await mkdir(join(root, 'assets'), { recursive: true }); @@ -187,6 +221,21 @@ describe('workspace file metadata/read helpers', () => { await expect(readWorkspaceFileAt(root, '../outside.jsx')).rejects.toThrow(/escapes/); }); + it('rejects hidden and ignored single-file reads', async () => { + await mkdir(join(root, '.claude'), { recursive: true }); + await writeFile(join(root, '.claude', 'secret.jsx'), 'export const secret = true;'); + await writeFile(join(root, 'CLAUDE.md'), '# stale model instructions'); + + await expect(readWorkspaceFileAt(root, '.claude/secret.jsx')).rejects.toMatchObject({ + name: 'CodesignError', + code: 'IPC_BAD_INPUT', + }); + await expect(readWorkspaceFileAt(root, 'CLAUDE.md')).rejects.toMatchObject({ + name: 'CodesignError', + code: 'IPC_BAD_INPUT', + }); + }); + it('rejects single-file reads through symlinked workspace path segments', async () => { const outside = await makeTmp(); await writeFile(join(outside, 'secret.jsx'), 'export const secret = true;'); diff --git a/apps/desktop/src/main/workspace-reader.ts b/apps/desktop/src/main/workspace-reader.ts index 06569832..0a7d893d 100644 --- a/apps/desktop/src/main/workspace-reader.ts +++ b/apps/desktop/src/main/workspace-reader.ts @@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs'; import { lstat, readdir, readFile, stat } from 'node:fs/promises'; import { basename, extname, join, relative, resolve, sep } from 'node:path'; import { TextDecoder } from 'node:util'; +import { CodesignError } from '@open-codesign/shared'; export const DEFAULT_WORKSPACE_PATTERNS = [ '**/*.html', @@ -28,17 +29,22 @@ export const DEFAULT_WORKSPACE_PATTERNS = [ export const WORKSPACE_IGNORED_DIRS = new Set([ 'node_modules', '.git', + '.claude', '.codesign', 'dist', 'build', 'out', + 'target', '.next', '.turbo', '.vite', '.cache', + '.ruff_cache', '.pnpm-store', '__pycache__', 'coverage', + 'playwright-report', + 'test-results', ]); /** Hard caps: stop after 200 files or 2 MB total bytes, whichever first. Main- @@ -55,16 +61,19 @@ export interface WorkspaceFile { /** * Scan `root` for files whose workspace-relative path matches any of - * `patterns` (default: HTML/JSX/CSS/JS). Returns UTF-8 contents. Matching - * files must be readable text; otherwise the caller gets a visible source-scan - * error instead of a silently partial tweak scan. Results are truncated to - * `MAX_FILES` / `MAX_BYTES`. + * `patterns` (default: HTML/JSX/CSS/JS). Returns UTF-8 contents. The bulk + * context scan is best-effort: matched files that are too large, binary, or + * not valid UTF-8 are skipped so one generated build log cannot block a turn. + * Results are truncated to `MAX_FILES` / `MAX_BYTES`. */ export async function readWorkspaceFilesAt( root: string, patterns?: string[], ): Promise { const active = patterns && patterns.length > 0 ? patterns : [...DEFAULT_WORKSPACE_PATTERNS]; + const exactMatches = new Set( + active.filter((pattern) => !pattern.includes('*') && !pattern.includes('?')), + ); const matchers = active.map(globToRegExp); const out: WorkspaceFile[] = []; @@ -86,14 +95,14 @@ export async function readWorkspaceFilesAt( if (out.length >= MAX_FILES || totalBytes >= MAX_BYTES) return; const abs = join(dir, entry.name); if (entry.isDirectory()) { - if (WORKSPACE_IGNORED_DIRS.has(entry.name)) continue; + if (isIgnoredWorkspaceDirectoryName(entry.name)) continue; await walk(abs); continue; } if (!entry.isFile()) continue; if (isIgnoredWorkspaceFileName(entry.name)) continue; const rel = normalizeSlashes(relative(root, abs)); - if (!matchers.some((re) => re.test(rel))) continue; + if (!exactMatches.has(rel) && !matchers.some((re) => re.test(rel))) continue; let size = 0; try { size = (await stat(abs)).size; @@ -106,10 +115,8 @@ export async function readWorkspaceFilesAt( let contents: string; try { contents = await readUtf8TextFile(abs); - } catch (err) { - throw new Error( - `Failed to read workspace file ${rel}: ${err instanceof Error ? err.message : String(err)}`, - ); + } catch { + continue; } out.push({ file: rel, contents }); totalBytes += Buffer.byteLength(contents, 'utf8'); @@ -156,8 +163,31 @@ export interface WorkspaceFileReadResult extends WorkspaceFileEntry { content: string; } +export interface WorkspaceDirectoryEntry { + /** Workspace-relative POSIX path. Directories never end with `/`. */ + path: string; + /** Basename for display. */ + name: string; + type: 'file' | 'directory'; + kind?: WorkspaceFileKind; + size?: number; + updatedAt?: string; +} + +export interface ListWorkspaceFilesOptions { + maxFiles?: number; +} + const LIST_IGNORED_DIRS = WORKSPACE_IGNORED_DIRS; -const WORKSPACE_IGNORED_FILE_NAMES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']); +const WORKSPACE_IGNORED_FILE_NAMES = new Set([ + '.ds_store', + 'thumbs.db', + 'desktop.ini', + 'build-out.txt', + 'build-output.txt', + 'claude.md', + 'memory.md', +]); const LIST_MAX_FILES = 2_000; const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }); @@ -216,7 +246,27 @@ export function isWorkspaceTextReadablePath(path: string): boolean { } function isIgnoredWorkspaceFileName(name: string): boolean { - return WORKSPACE_IGNORED_FILE_NAMES.has(name); + return name.startsWith('.') || WORKSPACE_IGNORED_FILE_NAMES.has(name.toLowerCase()); +} + +function isIgnoredWorkspaceDirectoryName(name: string): boolean { + return name.startsWith('.') || LIST_IGNORED_DIRS.has(name.toLowerCase()); +} + +export function isIgnoredWorkspacePath(path: string): boolean { + return normalizeSlashes(path) + .split('/') + .filter((part) => part.length > 0 && part !== '.') + .some((part, index, parts) => { + const isLast = index === parts.length - 1; + return isIgnoredWorkspaceDirectoryName(part) || (isLast && isIgnoredWorkspaceFileName(part)); + }); +} + +export function assertWorkspacePathVisible(path: string): void { + if (isIgnoredWorkspacePath(path)) { + throw new CodesignError(`hidden workspace path is not accessible: ${path}`, 'IPC_BAD_INPUT'); + } } export function classifyWorkspaceFileKind(path: string): WorkspaceFileKind { @@ -305,11 +355,15 @@ async function readUtf8TextFile(abs: string): Promise { * Returns entries sorted by path (POSIX-style separators). A bound workspace * that cannot be scanned is an invalid runtime state, so scan failures throw. */ -export async function listWorkspaceFilesAt(root: string): Promise { +export async function listWorkspaceFilesAt( + root: string, + opts: ListWorkspaceFilesOptions = {}, +): Promise { const out: WorkspaceFileEntry[] = []; + const maxFiles = opts.maxFiles ?? LIST_MAX_FILES; async function walk(dir: string): Promise { - if (out.length >= LIST_MAX_FILES) return; + if (out.length >= maxFiles) return; let entries: Dirent[] = []; try { entries = await readdir(dir, { withFileTypes: true }); @@ -321,10 +375,10 @@ export async function listWorkspaceFilesAt(root: string): Promise= LIST_MAX_FILES) return; + if (out.length >= maxFiles) return; const abs = join(dir, entry.name); if (entry.isDirectory()) { - if (LIST_IGNORED_DIRS.has(entry.name)) continue; + if (isIgnoredWorkspaceDirectoryName(entry.name)) continue; await walk(abs); continue; } @@ -357,6 +411,60 @@ export async function listWorkspaceFilesAt(root: string): Promise { + const absRoot = resolve(root); + const absDir = await resolveSafeWorkspaceChildPath(absRoot, dirPath); + const relDir = normalizeSlashes(relative(absRoot, absDir)); + assertWorkspacePathVisible(relDir); + const dirStat = await stat(absDir); + if (!dirStat.isDirectory()) { + throw new Error(`not a directory: ${dirPath}`); + } + + let entries: Dirent[] = []; + try { + entries = await readdir(absDir, { withFileTypes: true }); + } catch (err) { + throw new Error( + `Failed to scan workspace directory ${relDir || '.'}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + const out: WorkspaceDirectoryEntry[] = []; + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (isIgnoredWorkspaceDirectoryName(entry.name)) continue; + const relPath = normalizeSlashes(relative(absRoot, join(absDir, entry.name))); + out.push({ path: relPath, name: entry.name, type: 'directory' }); + continue; + } + if (!entry.isFile()) continue; + if (isIgnoredWorkspaceFileName(entry.name)) continue; + const abs = join(absDir, entry.name); + const fileStat = await stat(abs); + const relPath = normalizeSlashes(relative(absRoot, abs)); + out.push({ + path: relPath, + name: entry.name, + type: 'file', + kind: classifyWorkspaceFileKind(relPath), + size: fileStat.size, + updatedAt: fileStat.mtime.toISOString(), + }); + } + + return out.sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + }); +} + /** Tiny glob → regex. Supports `**` (any including slashes), `*` (no slash), * `?` (single non-slash char), and character classes `[...]`. Good enough for * extension filters like `**\/*.html` and `*.md`. */ @@ -420,6 +528,7 @@ export async function readWorkspaceFileAt( ): Promise { const abs = await resolveSafeWorkspaceChildPath(root, relPath); const rel = normalizeSlashes(relative(resolve(root), abs)); + assertWorkspacePathVisible(rel); let size = 0; let mtime = new Date(); try { diff --git a/apps/desktop/src/main/workspace-watcher.ts b/apps/desktop/src/main/workspace-watcher.ts index 461cbd4d..4c773987 100644 --- a/apps/desktop/src/main/workspace-watcher.ts +++ b/apps/desktop/src/main/workspace-watcher.ts @@ -7,7 +7,7 @@ import { ipcMain } from './electron-runtime'; import { getLogger } from './logger'; import { type Database, getDesign } from './snapshots-db'; import { normalizeWorkspacePath } from './workspace-path'; -import { WORKSPACE_IGNORED_DIRS } from './workspace-reader'; +import { isIgnoredWorkspacePath } from './workspace-reader'; /** * Files watcher (T2.3 follow-up). Without this, edits made in Finder / a @@ -54,11 +54,7 @@ const watchers = new Map(); function isIgnored(rel: string): boolean { if (!rel) return true; - if (rel.endsWith('.DS_Store')) return true; - for (const seg of rel.split(/[\\/]/)) { - if (WORKSPACE_IGNORED_DIRS.has(seg)) return true; - } - return false; + return isIgnoredWorkspacePath(rel); } function toForwardSlashes(path: string): string { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 419f7df5..cf7eca5d 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -16,6 +16,7 @@ import type { LocalInputFile, ModelRef, OnboardingState, + PreviewMode, ReasoningLevel, ReportEventInput, ReportEventResult, @@ -43,6 +44,7 @@ export type { ExternalConfigsDetection, ImageGenerationSettingsView, ModelsListResponse, + PreviewMode, TestEndpointResponse, }; @@ -78,6 +80,14 @@ export interface WorkspaceFileEntry { size: number; updatedAt: string; } +export interface WorkspaceDirectoryEntry { + path: string; + name: string; + type: 'file' | 'directory'; + kind?: WorkspaceFileKind; + size?: number; + updatedAt?: string; +} export interface WorkspaceFileReadResult extends WorkspaceFileEntry { content: string; } @@ -115,6 +125,25 @@ export interface WorkspaceDocumentThumbnailResult { path: string; thumbnailDataUrl: string | null; } + +export interface PreviewDetectCandidate { + url: string; + source: string; + status: 'matched' | 'native-runtime-required' | 'not-preview' | 'unreachable'; + httpStatus?: number; + contentType?: string; + title?: string; + error?: string; +} + +export interface PreviewDetectResult { + schemaVersion: 1; + found: boolean; + url: string | null; + candidates: PreviewDetectCandidate[]; + message: string; +} + export type WorkspaceImportSource = 'composer' | 'workspace' | 'canvas' | 'clipboard'; export type WorkspaceImportKind = 'reference' | 'asset'; export interface WorkspaceImportFileInput { @@ -137,6 +166,10 @@ export interface WorkspaceImportResult { source: WorkspaceImportSource; } +export interface RenameDesignOptions { + renameWorkspace?: boolean; +} + export interface ExportInvokeResponse { status: 'saved' | 'cancelled'; path?: string; @@ -594,6 +627,12 @@ const api = { schemaVersion: 1, designId, }) as Promise, + listDir: (designId: string, path = '.') => + ipcRenderer.invoke('codesign:files:v1:list-dir', { + schemaVersion: 1, + designId, + path, + }) as Promise, read: (designId: string, path: string) => ipcRenderer.invoke('codesign:files:v1:read', { schemaVersion: 1, @@ -660,11 +699,14 @@ const api = { schemaVersion: 1, id, }) as Promise, - renameDesign: (id: string, name: string) => + renameDesign: (id: string, name: string, options?: RenameDesignOptions) => ipcRenderer.invoke('snapshots:v1:rename-design', { schemaVersion: 1, id, name, + ...(options?.renameWorkspace !== undefined + ? { renameWorkspace: options.renameWorkspace } + : {}), }) as Promise, setThumbnail: (id: string, thumbnailText: string | null) => ipcRenderer.invoke('snapshots:v1:set-thumbnail', { @@ -720,6 +762,18 @@ const api = { schemaVersion: 1, designId, }) as Promise<{ exists: boolean }>, + updatePreview: (designId: string, previewMode: PreviewMode, previewUrl?: string | null) => + ipcRenderer.invoke('snapshots:v1:preview:update', { + schemaVersion: 1, + designId, + previewMode, + previewUrl: previewUrl ?? null, + }) as Promise, + detectPreview: (designId: string) => + ipcRenderer.invoke('snapshots:v1:preview:detect', { + schemaVersion: 1, + designId, + }) as Promise, }, chat: { list: (designId: string) => @@ -838,6 +892,7 @@ const api = { openExternal: (url: string) => ipcRenderer.invoke('codesign:v1:open-external', url) as Promise, ask: { + pending: () => ipcRenderer.invoke('ask:list-pending') as Promise, onRequest: (cb: (req: AskRequest) => void) => { const listener = (_e: unknown, req: AskRequest) => cb(req); ipcRenderer.on('ask:request', listener); diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 4d1b7710..586a46e7 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -36,10 +36,6 @@ export function App() { const loadDesigns = useCodesignStore((s) => s.loadDesigns); const syncGenerationStatus = useCodesignStore((s) => s.syncGenerationStatus); const switchDesign = useCodesignStore((s) => s.switchDesign); - const sendPrompt = useCodesignStore((s) => s.sendPrompt); - const isGenerating = useCodesignStore( - (s) => s.isGenerating && s.generatingDesignId === s.currentDesignId, - ); const setView = useCodesignStore((s) => s.setView); const view = useCodesignStore((s) => s.view); const previousView = useCodesignStore((s) => s.previousView); @@ -56,7 +52,7 @@ export function App() { const activeReportLocalId = useCodesignStore((s) => s.activeReportLocalId); const closeReportDialog = useCodesignStore((s) => s.closeReportDialog); - const [prompt, setPrompt] = useState(''); + const [prefillPrompt, setPrefillPrompt] = useState<{ id: number; text: string } | null>(null); const [sidebarWidth, setSidebarWidth] = useState(() => Math.max(320, Math.round(window.innerWidth * 0.25)), ); @@ -133,27 +129,13 @@ export function App() { void bootstrap(); }, [loadConfig, loadDesigns, switchDesign, syncGenerationStatus]); - function submit(): void { - const trimmed = prompt.trim(); - if (!trimmed || isGenerating) return; - void sendPrompt({ prompt: trimmed }); - setPrompt(''); - } - const ready = configLoaded && config?.hasKey; + const prefillComposer = useCallback((text: string) => { + setPrefillPrompt((prev) => ({ id: (prev?.id ?? 0) + 1, text })); + }, []); const bindings = useMemo( () => [ - { - combo: 'mod+enter', - handler: () => { - if (!ready) return; - const trimmed = prompt.trim(); - if (!trimmed || isGenerating) return; - void sendPrompt({ prompt: trimmed }); - setPrompt(''); - }, - }, { combo: 'mod+,', handler: () => { @@ -195,10 +177,7 @@ export function App() { }, ], [ - prompt, - isGenerating, ready, - sendPrompt, view, previousView, designsViewOpen, @@ -245,7 +224,7 @@ export function App() { // doesn't quietly land in the current design's input box. const created = await createNewDesign(); if (!created) return; - setPrompt(p); + prefillComposer(p); setView('workspace'); }} /> @@ -259,7 +238,7 @@ export function App() {
{isResizing &&
}
- +
- setPrompt(p)} /> +
diff --git a/apps/desktop/src/renderer/src/components/AskModal.test.ts b/apps/desktop/src/renderer/src/components/AskModal.test.ts index 9409a4f2..df614537 100644 --- a/apps/desktop/src/renderer/src/components/AskModal.test.ts +++ b/apps/desktop/src/renderer/src/components/AskModal.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'; import type { AskRequest } from '../../../preload/index'; -import { advanceAskQueue, enqueueAskRequest, sanitizeInlineSvg } from './AskModal'; +import { + advanceAskQueue, + answerValueForImportedFiles, + enqueueAskRequest, + enqueueAskRequests, + sanitizeInlineSvg, +} from './AskModal'; const request = (requestId: string): AskRequest => ({ requestId, @@ -52,6 +58,16 @@ describe('AskModal queue helpers', () => { expect(state.queue.map((item) => item.requestId)).toEqual(['ask-2']); }); + it('dedupes replayed pending requests', () => { + const first = request('ask-1'); + const second = request('ask-2'); + + const state = enqueueAskRequests({ active: first, queue: [second] }, [first, second]); + + expect(state.active?.requestId).toBe('ask-1'); + expect(state.queue.map((item) => item.requestId)).toEqual(['ask-2']); + }); + it('advances to the next request after the active request resolves', () => { const state = { active: request('ask-1'), @@ -64,3 +80,33 @@ describe('AskModal queue helpers', () => { expect(next.queue.map((item) => item.requestId)).toEqual(['ask-3']); }); }); + +describe('file answer helpers', () => { + it('prefers imported workspace paths over browser file names', () => { + expect( + answerValueForImportedFiles({ + importedPaths: ['references/logo.png'], + selectedNames: ['logo.png'], + }), + ).toBe('references/logo.png'); + }); + + it('keeps multiple imported paths for multi-file answers', () => { + expect( + answerValueForImportedFiles({ + importedPaths: ['references/logo.png', 'references/screenshot.png'], + selectedNames: ['logo.png', 'screenshot.png'], + multiple: true, + }), + ).toEqual(['references/logo.png', 'references/screenshot.png']); + }); + + it('falls back to selected names when import is unavailable', () => { + expect( + answerValueForImportedFiles({ + importedPaths: [], + selectedNames: ['logo.png'], + }), + ).toBe('logo.png'); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/AskModal.tsx b/apps/desktop/src/renderer/src/components/AskModal.tsx index 52764104..7bd2719a 100644 --- a/apps/desktop/src/renderer/src/components/AskModal.tsx +++ b/apps/desktop/src/renderer/src/components/AskModal.tsx @@ -12,6 +12,8 @@ import type { AskSvgOptionsQuestion, AskTextOptionsQuestion, } from '../../../preload/index'; +import { fileListToWorkspaceImport } from '../lib/file-ingest'; +import { useCodesignStore } from '../store'; /** * Inline questionnaire rendered whenever main pushes `ask:request` over IPC. @@ -21,7 +23,8 @@ import type { * interruption. */ -type AnswerValue = string | number | string[] | null; +export type AnswerValue = string | number | string[] | null; +type FileImportHandler = (files: readonly File[]) => Promise; const SVG_ALLOWED_TAGS = new Set([ 'svg', @@ -143,27 +146,55 @@ export interface AskQueueState { } export function enqueueAskRequest(state: AskQueueState, request: AskRequest): AskQueueState { + if (state.active?.requestId === request.requestId) return state; + if (state.queue.some((item) => item.requestId === request.requestId)) return state; if (state.active === null) return { active: request, queue: state.queue }; return { active: state.active, queue: [...state.queue, request] }; } +export function enqueueAskRequests(state: AskQueueState, requests: AskRequest[]): AskQueueState { + return requests.reduce((next, request) => enqueueAskRequest(next, request), state); +} + export function advanceAskQueue(state: AskQueueState): AskQueueState { const [next, ...rest] = state.queue; return { active: next ?? null, queue: rest }; } +export function answerValueForImportedFiles(input: { + importedPaths: readonly string[]; + selectedNames: readonly string[]; + multiple?: boolean | undefined; +}): AnswerValue { + const values = input.importedPaths.length > 0 ? input.importedPaths : input.selectedNames; + if (values.length === 0) return null; + return input.multiple ? [...values] : (values[0] ?? null); +} + export function AskModal() { const t = useT(); + const importFilesToWorkspace = useCodesignStore((s) => s.importFilesToWorkspace); const [askQueue, setAskQueue] = useState({ active: null, queue: [] }); const [answers, setAnswers] = useState>({}); const panelRef = useRef(null); const pending = askQueue.active; useEffect(() => { + let disposed = false; const off = window.codesign?.ask?.onRequest?.((req) => { setAskQueue((prev) => enqueueAskRequest(prev, req)); }); + void window.codesign?.ask + ?.pending?.() + .then((requests) => { + if (disposed) return; + setAskQueue((prev) => enqueueAskRequests(prev, requests)); + }) + .catch(() => { + // The live IPC event remains the primary path; pending replay is recovery-only. + }); return () => { + disposed = true; off?.(); }; }, []); @@ -196,6 +227,15 @@ export function AskModal() { return () => window.removeEventListener('keydown', onKey); }, [pending, cancel]); + const importQuestionFiles = useCallback( + async (files) => { + const input = await fileListToWorkspaceImport(files); + const imported = await importFilesToWorkspace({ source: 'composer', ...input }); + return imported.map((file) => file.path); + }, + [importFilesToWorkspace], + ); + if (!pending) return null; function submit() { @@ -243,6 +283,7 @@ export function AskModal() { question={q} value={answers[q.id] ?? null} onChange={(v) => setValue(q.id, v)} + onImportFiles={importQuestionFiles} /> ))}
@@ -284,9 +325,10 @@ interface FieldProps { question: AskQuestion; value: AnswerValue; onChange: (v: AnswerValue) => void; + onImportFiles: FileImportHandler; } -function QuestionField({ question, value, onChange }: FieldProps) { +function QuestionField({ question, value, onChange, onImportFiles }: FieldProps) { return (
- {renderControl(question, value, onChange)} + {renderControl(question, value, onChange, onImportFiles)}
); } @@ -304,6 +346,7 @@ function renderControl( q: AskQuestion, value: AnswerValue, onChange: (v: AnswerValue) => void, + onImportFiles: FileImportHandler, ): ReactElement { switch (q.type) { case 'text-options': @@ -313,7 +356,7 @@ function renderControl( case 'slider': return ; case 'file': - return ; + return ; case 'freeform': return ; } @@ -451,24 +494,68 @@ function SliderField({ ); } -function FileField({ q, onChange }: { q: AskFileQuestion; onChange: (v: AnswerValue) => void }) { +function FileField({ + q, + onChange, + onImportFiles, +}: { + q: AskFileQuestion; + onChange: (v: AnswerValue) => void; + onImportFiles: FileImportHandler; +}) { + const t = useT(); + const [importing, setImporting] = useState(false); + const [error, setError] = useState(null); + + async function handleFiles(fileList: FileList | null) { + const files = Array.from(fileList ?? []); + if (files.length === 0) { + onChange(null); + return; + } + const selectedNames = files.map((file) => file.name); + setImporting(true); + setError(null); + try { + const importedPaths = await onImportFiles(files); + onChange(answerValueForImportedFiles({ importedPaths, selectedNames, multiple: q.multiple })); + } catch (err) { + setError(err instanceof Error ? err.message : 'File import failed'); + onChange( + answerValueForImportedFiles({ importedPaths: [], selectedNames, multiple: q.multiple }), + ); + } finally { + setImporting(false); + } + } + return ( - { - const files = Array.from(e.target.files ?? []); - if (files.length === 0) { - onChange(null); - return; - } - if (q.multiple) onChange(files.map((f) => f.name)); - else onChange(files[0]?.name ?? null); - }} - className="max-w-full text-[12.5px] text-[var(--color-text-primary)]" - /> +
+ { + void handleFiles(e.currentTarget.files); + }} + className="max-w-full text-[12.5px] text-[var(--color-text-primary)]" + /> + {importing ? ( +

+ {t('common.loading', { defaultValue: 'Loading...' })} +

+ ) : null} + {error ? ( +

+ {t('ask.fileImportFallback', { + defaultValue: 'Could not import the file automatically; sending the file name instead.', + })} +

+ ) : null} +
); } diff --git a/apps/desktop/src/renderer/src/components/CanvasTabBar.tsx b/apps/desktop/src/renderer/src/components/CanvasTabBar.tsx index 06e69054..b2844796 100644 --- a/apps/desktop/src/renderer/src/components/CanvasTabBar.tsx +++ b/apps/desktop/src/renderer/src/components/CanvasTabBar.tsx @@ -1,6 +1,6 @@ import { useT } from '@open-codesign/i18n'; import { Eye, FolderOpen, X } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { Fragment, type ReactNode } from 'react'; import { useCodesignStore } from '../store'; function fileTabLabel(path: string): string { @@ -59,6 +59,7 @@ export function CanvasTabBar() { const active = useCodesignStore((s) => s.activeCanvasTab); const setActive = useCodesignStore((s) => s.setActiveCanvasTab); const close = useCodesignStore((s) => s.closeCanvasTab); + const hasFileTabs = tabs.some((tab) => tab.kind === 'file'); if (tabs.length === 0) return null; @@ -70,50 +71,65 @@ export function CanvasTabBar() { > {tabs.map((tab, index) => { const isActive = index === active; + const isFilesTab = tab.kind === 'files'; + const isFileTab = tab.kind === 'file'; const item = tabMeta(tab, t); return ( -
- - {item.closable ? ( - ) : null} - {isActive ? ( - close(index)} + aria-label={t('canvas.closeTab', { name: item.label })} + className="p-[2px] text-[var(--color-text-muted)] opacity-50 transition-opacity hover:text-[var(--color-text-primary)] hover:opacity-100" + > + + + ) : null} + {isActive ? ( + + ) : null} +
+ {isFilesTab && hasFileTabs ? ( +
) : null} -
+ ); })}
diff --git a/apps/desktop/src/renderer/src/components/FilesPanel.test.tsx b/apps/desktop/src/renderer/src/components/FilesPanel.test.tsx index 1cd93fbb..aabe1896 100644 --- a/apps/desktop/src/renderer/src/components/FilesPanel.test.tsx +++ b/apps/desktop/src/renderer/src/components/FilesPanel.test.tsx @@ -26,7 +26,7 @@ vi.mock('react', async (importOriginal) => { }); import type { CodesignApi } from '../../../preload'; -import { useDesignFiles } from '../hooks/useDesignFiles'; +import { useLazyDesignFileTree } from '../hooks/useDesignFiles'; import { useCodesignStore } from '../store'; import { FilesPanel } from './FilesPanel'; @@ -41,9 +41,13 @@ vi.mock('../store', async (importOriginal) => { useCodesignStore: mockStoreHook, }; }); -vi.mock('../hooks/useDesignFiles', () => ({ - useDesignFiles: vi.fn(), -})); +vi.mock('../hooks/useDesignFiles', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useLazyDesignFileTree: vi.fn(), + }; +}); declare global { interface Window { @@ -350,10 +354,12 @@ describe('FilesPanel workspace integration', () => { describe('FilesPanel rendering UI', () => { beforeEach(() => { - vi.mocked(useDesignFiles).mockReturnValue({ + vi.mocked(useLazyDesignFileTree).mockReturnValue({ files: [], + tree: [], loading: false, backend: 'snapshots', + loadDirectory: vi.fn(), }); useCodesignStore.setState({ currentDesignId: 'design-1', diff --git a/apps/desktop/src/renderer/src/components/FilesPanel.tsx b/apps/desktop/src/renderer/src/components/FilesPanel.tsx index 6e6c816f..83d7580a 100644 --- a/apps/desktop/src/renderer/src/components/FilesPanel.tsx +++ b/apps/desktop/src/renderer/src/components/FilesPanel.tsx @@ -1,14 +1,21 @@ import { useT } from '@open-codesign/i18n'; -import { FileCode2, Folder, FolderOpen, Plus } from 'lucide-react'; -import { type DragEvent, useEffect, useState } from 'react'; -import { formatAbsoluteTime, formatRelativeTime, useDesignFiles } from '../hooks/useDesignFiles'; +import { ChevronRight, FileCode2, Folder, FolderOpen, Plus } from 'lucide-react'; +import { type DragEvent, type ReactNode, useEffect, useRef, useState } from 'react'; +import { + formatAbsoluteTime, + formatRelativeTime, + useLazyDesignFileTree, +} from '../hooks/useDesignFiles'; import { clipboardFilesToWorkspaceBlobs, dataTransferFilesToWorkspaceFiles, } from '../lib/file-ingest'; +import type { FileTreeNode } from '../lib/file-tree'; import { workspacePathComparisonKey } from '../lib/workspace-path'; import { useCodesignStore } from '../store'; +export { buildFileTree } from '../lib/file-tree'; + function formatBytes(n: number | undefined): string { if (n === undefined) return ''; if (n < 1024) return `${n} B`; @@ -33,14 +40,22 @@ export function FilesPanel() { const importFilesToWorkspace = useCodesignStore((s) => s.importFilesToWorkspace); const addImportedFileToPrompt = useCodesignStore((s) => s.useImportedFileInPrompt); const requestWorkspaceRebind = useCodesignStore((s) => s.requestWorkspaceRebind); - const { files, loading } = useDesignFiles(currentDesignId); + const { files, tree: fileTree, loading, loadDirectory } = useLazyDesignFileTree(currentDesignId); const [workspaceLoading, setWorkspaceLoading] = useState(false); const [folderExists, setFolderExists] = useState(null); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const expandedDesignRef = useRef(currentDesignId); const currentDesign = designs.find((d) => d.id === currentDesignId); const workspacePath = currentDesign?.workspacePath ?? null; const isCurrentDesignGenerating = isGenerating && generatingDesignId === currentDesignId; + useEffect(() => { + if (expandedDesignRef.current === currentDesignId) return; + expandedDesignRef.current = currentDesignId; + setExpandedDirs(new Set()); + }, [currentDesignId]); + useEffect(() => { if (!workspacePath || !currentDesignId) { setFolderExists(null); @@ -128,6 +143,107 @@ export function FilesPanel() { await importFilesToWorkspace(input); } + function toggleDirectory(node: FileTreeNode) { + if (node.type !== 'directory') return; + setExpandedDirs((prev) => { + const next = new Set(prev); + if (next.has(node.path)) { + next.delete(node.path); + } else { + next.add(node.path); + if (!node.loaded && !node.loading) void loadDirectory(node.path); + } + return next; + }); + } + + function renderTreeNode(node: FileTreeNode, depth: number): ReactNode { + if (node.type === 'directory') { + const isExpanded = expandedDirs.has(node.path); + return ( +
  • + + {isExpanded && node.loading && ( +
    + {t('common.loading')} +
    + )} + {isExpanded && node.children.length > 0 && ( +
      + {node.children.map((child) => renderTreeNode(child, depth + 1))} +
    + )} +
  • + ); + } + + const f = node.file; + return ( +
  • +
    + + +
    +
  • + ); + } + if (!currentDesignId) { return (
    @@ -225,7 +341,7 @@ export function FilesPanel() { - {files.length === 0 ? ( + {files.length === 0 && fileTree.length === 0 ? (
    + ) : fileTree.length > 0 ? ( +
      + {fileTree.map((node) => renderTreeNode(node, 0))} +
    ) : (
      {files.map((f) => ( @@ -260,7 +380,7 @@ export function FilesPanel() { title={formatAbsoluteTime(f.updatedAt)} style={{ fontFamily: 'var(--font-mono)', fontFeatureSettings: "'tnum'" }} > - {formatBytes(f.size)} · {formatRelativeTime(f.updatedAt)} + {formatBytes(f.size)} - {formatRelativeTime(f.updatedAt)}
    diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.test.ts b/apps/desktop/src/renderer/src/components/FilesTabView.test.ts index 9a4e19e0..30eeb460 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.test.ts +++ b/apps/desktop/src/renderer/src/components/FilesTabView.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it, vi } from 'vitest'; import { openFileTab } from '../store/slices/tabs'; import { chooseWorkspacePreviewSourceMode, + clampFileBrowserWidth, createWorkspaceFilePreviewMessageHandlers, defaultWorkspacePreviewPath, + detectedPreviewTarget, + effectivePreviewModeForDesign, + externalAppManagedFallbackPath, + htmlRequiresWorkspaceDevServer, isMarkdownPreviewFile, isPreviewSourceUsableForSelectedPath, isRenderableDesignFileKind, @@ -18,6 +23,90 @@ import { } from './FilesTabView'; describe('FilesTabView preview helpers', () => { + it('clamps the file browser splitter width to usable bounds', () => { + expect(clampFileBrowserWidth(120, 1280)).toBe(260); + expect(clampFileBrowserWidth(480.4, 1280)).toBe(480); + expect(clampFileBrowserWidth(900, 1280)).toBe(704); + expect(clampFileBrowserWidth(900, 900)).toBe(495); + }); + + it('keeps native app detections on external app preview', () => { + expect( + detectedPreviewTarget({ + schemaVersion: 1, + found: false, + url: null, + message: 'Use External app preview.', + candidates: [ + { + url: 'http://localhost:1420/', + source: 'common local preview port', + status: 'native-runtime-required', + httpStatus: 200, + }, + ], + }), + ).toEqual({ mode: 'external-app', url: 'http://localhost:1420/' }); + }); + + it('connects ordinary web previews after detection', () => { + expect( + detectedPreviewTarget({ + schemaVersion: 1, + found: true, + url: 'http://localhost:5173/', + message: 'Found a local preview.', + candidates: [ + { + url: 'http://localhost:5173/', + source: 'package.json script', + status: 'matched', + httpStatus: 200, + }, + ], + }), + ).toEqual({ mode: 'connected-url', url: 'http://localhost:5173/' }); + }); + + it('keeps simple HTML workspaces on integrated preview even with package.json present', () => { + expect( + effectivePreviewModeForDesign({ + files: [ + { + path: 'index.html', + kind: 'html', + updatedAt: '2026-05-10T00:00:00.000Z', + size: 120, + }, + { + path: 'package.json', + kind: 'text', + updatedAt: '2026-05-10T00:00:00.000Z', + size: 80, + }, + ], + }), + ).toBe('managed-file'); + }); + + it('detects Vite-style app entry HTML that needs a dev server', () => { + expect( + htmlRequiresWorkspaceDevServer( + '
    ', + ), + ).toBe(true); + expect( + htmlRequiresWorkspaceDevServer( + '
    ', + ), + ).toBe(true); + expect( + htmlRequiresWorkspaceDevServer( + '
    Hello
    ', + ), + ).toBe(false); + }); + it('forwards element selection messages from file preview iframes into comment state', () => { const selectCanvasElement = vi.fn(); const openCommentBubble = vi.fn(); @@ -130,15 +219,23 @@ describe('FilesTabView preview helpers', () => { ).toBe(false); }); - it('uses the design-level resolver for main design runtime files only', () => { - expect(shouldUseDesignPreviewResolverForFile({ path: 'App.jsx', previewKind: 'runtime' })).toBe( - true, - ); + it('uses the design-level resolver only for generated preview fallbacks', () => { expect( - shouldUseDesignPreviewResolverForFile({ path: 'index.html', previewKind: 'runtime' }), + shouldUseDesignPreviewResolverForFile({ + path: 'App.jsx', + previewKind: 'runtime', + source: 'preview-html', + }), ).toBe(true); + expect(shouldUseDesignPreviewResolverForFile({ path: 'App.jsx', previewKind: 'runtime' })).toBe( + false, + ); expect( - shouldUseDesignPreviewResolverForFile({ path: 'screens/App.jsx', previewKind: 'runtime' }), + shouldUseDesignPreviewResolverForFile({ + path: 'index.html', + previewKind: 'runtime', + source: 'workspace', + }), ).toBe(false); expect( shouldUseDesignPreviewResolverForFile({ path: 'DESIGN.md', previewKind: 'markdown' }), @@ -290,6 +387,37 @@ describe('FilesTabView preview helpers', () => { ).toBe('brief.pdf'); }); + it('keeps a managed preview fallback available for external-app workspaces', () => { + expect( + externalAppManagedFallbackPath({ + selectedPath: 'App.jsx', + defaultPath: 'index.html', + hasPersistedPreview: true, + }), + ).toBe('App.jsx'); + expect( + externalAppManagedFallbackPath({ + selectedPath: null, + defaultPath: 'index.html', + hasPersistedPreview: true, + }), + ).toBe('index.html'); + expect( + externalAppManagedFallbackPath({ + selectedPath: null, + defaultPath: null, + hasPersistedPreview: true, + }), + ).toBe('App.jsx'); + expect( + externalAppManagedFallbackPath({ + selectedPath: null, + defaultPath: null, + hasPersistedPreview: false, + }), + ).toBeNull(); + }); + it('prefers actual workspace reads over previewSource when the files API is available', () => { expect( chooseWorkspacePreviewSourceMode({ diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.tsx b/apps/desktop/src/renderer/src/components/FilesTabView.tsx index a5e2b653..316bb134 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.tsx +++ b/apps/desktop/src/renderer/src/components/FilesTabView.tsx @@ -1,12 +1,40 @@ import { useT } from '@open-codesign/i18n'; import { buildPreviewDocument, isRenderablePath } from '@open-codesign/runtime'; -import { DEFAULT_SOURCE_ENTRY, LEGACY_SOURCE_ENTRY } from '@open-codesign/shared'; -import { FileCode2, FileText, Folder, FolderOpen } from 'lucide-react'; -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { DEFAULT_SOURCE_ENTRY, LEGACY_SOURCE_ENTRY, type PreviewMode } from '@open-codesign/shared'; +import { + ChevronRight, + ExternalLink, + FileCode2, + FileText, + Folder, + FolderOpen, + Globe2, + RefreshCw, +} from 'lucide-react'; +import { + type ChangeEvent, + type KeyboardEvent, + lazy, + type MouseEvent as ReactMouseEvent, + type ReactNode, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import type { WorkspaceDocumentPreviewResult } from '../../../preload'; -import { type DesignFileEntry, type DesignFileKind, useDesignFiles } from '../hooks/useDesignFiles'; +import type { PreviewDetectResult, WorkspaceDocumentPreviewResult } from '../../../preload'; +import { + type DesignFileEntry, + type DesignFileKind, + type DesignFileSource, + useDesignFiles, + useLazyDesignFileTree, +} from '../hooks/useDesignFiles'; +import type { FileTreeNode } from '../lib/file-tree'; import { workspacePathComparisonKey } from '../lib/workspace-path'; import { formatIframeError, @@ -27,6 +55,33 @@ export { resolveReferencedWorkspacePreviewPath } from '../preview/workspace-sour const TweakPanel = lazy(() => import('./TweakPanel').then((m) => ({ default: m.TweakPanel }))); +const FILE_BROWSER_WIDTH_STORAGE_KEY = 'open-codesign:file-browser-width'; +const FILE_BROWSER_DEFAULT_WIDTH = 360; +const FILE_BROWSER_MIN_WIDTH = 260; +const FILE_BROWSER_MAX_WIDTH = 720; + +export function clampFileBrowserWidth(width: number, viewportWidth = 1280): number { + const maxWidth = Math.max( + FILE_BROWSER_MIN_WIDTH, + Math.min(FILE_BROWSER_MAX_WIDTH, Math.round(viewportWidth * 0.55)), + ); + return Math.min(Math.max(Math.round(width), FILE_BROWSER_MIN_WIDTH), maxWidth); +} + +function initialFileBrowserWidth(): number { + if (typeof window === 'undefined') return FILE_BROWSER_DEFAULT_WIDTH; + try { + const raw = window.localStorage.getItem(FILE_BROWSER_WIDTH_STORAGE_KEY); + const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10); + return clampFileBrowserWidth( + Number.isFinite(parsed) ? parsed : FILE_BROWSER_DEFAULT_WIDTH, + window.innerWidth, + ); + } catch { + return clampFileBrowserWidth(FILE_BROWSER_DEFAULT_WIDTH, window.innerWidth); + } +} + function truncatePath(path: string, maxLength = 40): string { if (path.length <= maxLength) return path; const start = path.substring(0, maxLength / 2 - 2); @@ -34,6 +89,124 @@ function truncatePath(path: string, maxLength = 40): string { return `${start}…${end}`; } +const APP_WORKSPACE_ROOT_FILES = new Set([ + 'angular.json', + 'astro.config.cjs', + 'astro.config.js', + 'astro.config.mjs', + 'astro.config.ts', + 'next.config.cjs', + 'next.config.js', + 'next.config.mjs', + 'next.config.ts', + 'nuxt.config.js', + 'nuxt.config.mjs', + 'nuxt.config.ts', + 'parcel.config.js', + 'remix.config.js', + 'remix.config.ts', + 'svelte.config.js', + 'svelte.config.ts', + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.ts', + 'webpack.config.js', + 'webpack.config.ts', +]); + +export function normalizeConnectedPreviewUrlInput(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`; + try { + const url = new URL(withScheme); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + return url.toString(); + } catch { + return null; + } +} + +export function looksLikeApplicationWorkspace(files: DesignFileEntry[]): boolean { + return files.some((file) => { + const normalized = file.path.replaceAll('\\', '/').toLowerCase(); + if (normalized.includes('/')) return false; + return APP_WORKSPACE_ROOT_FILES.has(normalized); + }); +} + +export function effectivePreviewModeForDesign(input: { + previewMode?: PreviewMode | null | undefined; + previewUrl?: string | null | undefined; + files: DesignFileEntry[]; +}): PreviewMode { + const integratedBlocked = looksLikeApplicationWorkspace(input.files); + if (integratedBlocked && input.previewMode === 'managed-file') return 'none'; + if (input.previewMode) return input.previewMode; + if (input.previewUrl && input.previewUrl.trim().length > 0) return 'connected-url'; + if (integratedBlocked) return 'none'; + return 'managed-file'; +} + +const WORKSPACE_MODULE_SCRIPT_RE = /]*)>/gi; +const MODULE_SCRIPT_TYPE_RE = /\btype\s*=\s*["']module["']/i; +const SCRIPT_SRC_RE = /\bsrc\s*=\s*["']([^"']+)["']/i; + +export function htmlRequiresWorkspaceDevServer(source: string): boolean { + for (const match of source.matchAll(WORKSPACE_MODULE_SCRIPT_RE)) { + const attributes = match[1] ?? ''; + if (!MODULE_SCRIPT_TYPE_RE.test(attributes)) continue; + const src = SCRIPT_SRC_RE.exec(attributes)?.[1]?.trim(); + if (!src) continue; + const normalized = src.replaceAll('\\', '/').replace(/^\.\//, '').toLowerCase(); + if (normalized.startsWith('/src/') || normalized.startsWith('src/')) return true; + } + return false; +} + +function previewModeLabelKey(mode: PreviewMode): string { + if (mode === 'connected-url') return 'canvas.workspace.preview.mode.connectedUrlShort'; + if (mode === 'external-app') return 'canvas.workspace.preview.mode.externalAppShort'; + if (mode === 'none') return 'canvas.workspace.preview.mode.offShort'; + return 'canvas.workspace.preview.mode.integratedShort'; +} + +export function previewModeSummary(input: { + mode: PreviewMode; + previewUrl?: string | null | undefined; + integratedPreviewBlocked?: boolean; +}): { key: string; options?: Record } { + if (input.mode === 'connected-url') { + const url = input.previewUrl?.trim(); + return url + ? { key: 'canvas.workspace.preview.summary.connected', options: { url } } + : { key: 'canvas.workspace.preview.summary.waitingUrl' }; + } + if (input.mode === 'external-app') { + const url = input.previewUrl?.trim(); + return url + ? { key: 'canvas.workspace.preview.summary.externalWithUrl', options: { url } } + : { key: 'canvas.workspace.preview.summary.external' }; + } + if (input.mode === 'none') { + return input.integratedPreviewBlocked + ? { key: 'canvas.workspace.preview.summary.integratedBlocked' } + : { key: 'canvas.workspace.preview.summary.off' }; + } + return { key: 'canvas.workspace.preview.summary.integrated' }; +} + +export function detectedPreviewTarget( + result: PreviewDetectResult, +): { mode: 'connected-url' | 'external-app'; url: string } | null { + const nativeCandidate = result.candidates.find( + (candidate) => candidate.status === 'native-runtime-required', + ); + if (nativeCandidate) return { mode: 'external-app', url: nativeCandidate.url }; + if (result.found && result.url) return { mode: 'connected-url', url: result.url }; + return null; +} + function escapeHtmlText(value: string): string { return value .replace(/&/g, '&') @@ -43,7 +216,7 @@ function escapeHtmlText(value: string): string { .replace(/'/g, '''); } -function WorkspaceSection() { +function WorkspaceSection({ files }: { files: DesignFileEntry[] }) { const t = useT(); const currentDesignId = useCodesignStore((s) => s.currentDesignId); const designs = useCodesignStore((s) => s.designs); @@ -52,11 +225,38 @@ function WorkspaceSection() { const requestWorkspaceRebind = useCodesignStore((s) => s.requestWorkspaceRebind); const [picking, setPicking] = useState(false); const [folderExists, setFolderExists] = useState(null); + const [previewModeInput, setPreviewModeInput] = useState('managed-file'); + const [previewUrlInput, setPreviewUrlInput] = useState(''); + const [savingPreview, setSavingPreview] = useState(false); + const [detectingPreview, setDetectingPreview] = useState(false); + const [detectResult, setDetectResult] = useState(null); + const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false); + const autoDetectDesignRef = useRef(null); const currentDesign = designs.find((d) => d.id === currentDesignId); const workspacePath = currentDesign?.workspacePath ?? null; const isCurrentDesignGenerating = isGenerating && generatingDesignId === currentDesignId; const disabled = picking || isCurrentDesignGenerating; + const integratedPreviewBlocked = looksLikeApplicationWorkspace(files); + const savedPreviewMode = currentDesign?.previewMode ?? null; + const savedPreviewUrl = currentDesign?.previewUrl ?? ''; + const effectivePreviewMode = effectivePreviewModeForDesign({ + previewMode: savedPreviewMode, + previewUrl: savedPreviewUrl, + files, + }); + const previewSummary = previewModeSummary({ + mode: effectivePreviewMode, + previewUrl: savedPreviewUrl, + integratedPreviewBlocked, + }); + const previewSummaryText = t(previewSummary.key, previewSummary.options); + const previewConfigured = Boolean(savedPreviewMode || savedPreviewUrl); + const previewNeedsUrl = + previewModeInput === 'connected-url' || previewModeInput === 'external-app'; + const normalizedPreviewUrl = normalizeConnectedPreviewUrlInput(previewUrlInput); + const previewUrlInvalid = + previewNeedsUrl && previewUrlInput.trim().length > 0 && !normalizedPreviewUrl; useEffect(() => { if (!workspacePath || !currentDesignId) { @@ -76,6 +276,135 @@ function WorkspaceSection() { }); }, [currentDesignId, workspacePath, t]); + useEffect(() => { + setPreviewModeInput(effectivePreviewMode); + setPreviewUrlInput(savedPreviewUrl); + setDetectResult(null); + }, [effectivePreviewMode, savedPreviewUrl]); + + const refreshDesigns = useCallback(async () => { + const updated = await window.codesign?.snapshots.listDesigns?.(); + if (updated) useCodesignStore.setState({ designs: updated }); + }, []); + + const savePreviewMode = useCallback( + async (mode: PreviewMode, rawUrl = previewUrlInput, options: { quiet?: boolean } = {}) => { + if (!currentDesignId || !window.codesign?.snapshots.updatePreview) return; + const trimmedUrl = rawUrl.trim(); + const normalizedUrl = + mode === 'connected-url' || mode === 'external-app' + ? normalizeConnectedPreviewUrlInput(trimmedUrl) + : null; + if (mode === 'connected-url' && normalizedUrl === null) { + if (!options.quiet) { + useCodesignStore.getState().pushToast({ + variant: 'error', + title: t('canvas.workspace.preview.saveFailed'), + description: t('canvas.workspace.preview.urlRequired'), + }); + } + return; + } + if (mode === 'external-app' && trimmedUrl.length > 0 && normalizedUrl === null) { + if (!options.quiet) { + useCodesignStore.getState().pushToast({ + variant: 'error', + title: t('canvas.workspace.preview.saveFailed'), + description: t('canvas.workspace.preview.urlInvalid'), + }); + } + return; + } + try { + setSavingPreview(true); + await window.codesign.snapshots.updatePreview(currentDesignId, mode, normalizedUrl); + await refreshDesigns(); + } catch (err) { + if (!options.quiet) { + useCodesignStore.getState().pushToast({ + variant: 'error', + title: t('canvas.workspace.preview.saveFailed'), + description: err instanceof Error ? err.message : t('errors.unknown'), + }); + } + } finally { + setSavingPreview(false); + } + }, + [currentDesignId, previewUrlInput, refreshDesigns, t], + ); + + async function handlePreviewModeChange(event: ChangeEvent) { + const nextMode = event.currentTarget.value as PreviewMode; + const safeMode = nextMode === 'managed-file' && integratedPreviewBlocked ? 'none' : nextMode; + setPreviewModeInput(safeMode); + setDetectResult(null); + if (safeMode === 'connected-url' && !normalizeConnectedPreviewUrlInput(previewUrlInput)) return; + await savePreviewMode(safeMode, previewUrlInput); + } + + async function handlePreviewUrlApply() { + await savePreviewMode(previewModeInput, previewUrlInput); + } + + async function handlePreviewUrlKeyDown(event: KeyboardEvent) { + if (event.key !== 'Enter') return; + event.currentTarget.blur(); + await handlePreviewUrlApply(); + } + + const handleDetectPreview = useCallback( + async (options: { quiet?: boolean } = {}) => { + if (!currentDesignId || !window.codesign?.snapshots.detectPreview) return; + try { + setDetectingPreview(true); + if (!options.quiet) setDetectResult(null); + const result = await window.codesign.snapshots.detectPreview(currentDesignId); + if (!options.quiet) setDetectResult(result); + const target = detectedPreviewTarget(result); + if (target) { + setPreviewModeInput(target.mode); + setPreviewUrlInput(target.url); + await savePreviewMode(target.mode, target.url, { quiet: options.quiet === true }); + return; + } + if (!options.quiet) { + useCodesignStore.getState().pushToast({ + variant: 'info', + title: t('canvas.workspace.preview.detectNone'), + description: result.message, + }); + } + } catch (err) { + if (!options.quiet) { + useCodesignStore.getState().pushToast({ + variant: 'error', + title: t('canvas.workspace.preview.detectFailed'), + description: err instanceof Error ? err.message : t('errors.unknown'), + }); + } + } finally { + setDetectingPreview(false); + } + }, + [currentDesignId, savePreviewMode, t], + ); + + useEffect(() => { + if (!currentDesignId) return; + if (!integratedPreviewBlocked) return; + if (savedPreviewMode || savedPreviewUrl) return; + if (autoDetectDesignRef.current === currentDesignId) return; + autoDetectDesignRef.current = currentDesignId; + void handleDetectPreview({ quiet: true }); + }, [ + currentDesignId, + handleDetectPreview, + integratedPreviewBlocked, + savedPreviewMode, + savedPreviewUrl, + ]); + async function handlePickWorkspace() { if (!window.codesign?.snapshots.pickWorkspaceFolder) return; if (isCurrentDesignGenerating) { @@ -127,52 +456,177 @@ function WorkspaceSection() { } return ( -
    - - {t('canvas.workspace.sectionTitle')} - - - {workspacePath ? ( - <> - {truncatePath(workspacePath)} - {folderExists === false && ( - - ! - - )} - - ) : ( - - {t('canvas.workspace.default')} - - )} - -
    - - {workspacePath && ( + {workspacePath ? ( + <> + {truncatePath(workspacePath)} + {folderExists === false && ( + + ! + + )} + + ) : ( + + {t('canvas.workspace.default')} + + )} + +
    - )} + {workspacePath && ( + + )} +
    +
    + +
    +
    + + + +
    +
    + + {t('canvas.workspace.preview.label')} + + + {previewConfigured + ? t('canvas.workspace.preview.status.saved') + : t('canvas.workspace.preview.status.auto')} + : {t(previewModeLabelKey(effectivePreviewMode))} + +
    +
    + + +
    + {previewOptionsOpen ? ( +
    +
    +

    + {detectingPreview + ? t('canvas.workspace.preview.summary.detecting') + : (detectResult?.message ?? previewSummaryText)} +

    + + {previewNeedsUrl ? ( + + ) : null} +

    + {integratedPreviewBlocked + ? t('canvas.workspace.preview.hint.appWorkspace') + : t('canvas.workspace.preview.hint.simpleWorkspace')} +

    +
    +
    + ) : null}
    ); @@ -332,8 +786,13 @@ export function shouldShowTweakPanelForFile(input: { export function shouldUseDesignPreviewResolverForFile(input: { path: string; previewKind: FilePreviewKind; + source?: DesignFileSource | undefined; }): boolean { - return input.previewKind === 'runtime' && isMainDesignSourcePath(input.path); + return ( + input.previewKind === 'runtime' && + isMainDesignSourcePath(input.path) && + input.source === 'preview-html' + ); } export function defaultWorkspacePreviewPath(files: DesignFileEntry[]): string | null { @@ -355,6 +814,18 @@ export function defaultWorkspacePreviewPath(files: DesignFileEntry[]): string | ); } +export function externalAppManagedFallbackPath(input: { + selectedPath: string | null; + defaultPath: string | null; + hasPersistedPreview: boolean; +}): string | null { + return ( + input.selectedPath ?? + input.defaultPath ?? + (input.hasPersistedPreview ? DEFAULT_SOURCE_ENTRY : null) + ); +} + export function workspaceBaseHrefForFile(input: { designId: string | null | undefined; workspacePath: string | null | undefined; @@ -448,6 +919,7 @@ interface WorkspaceFilePreviewProps { path: string; file?: DesignFileEntry | null | undefined; files?: DesignFileEntry[] | null | undefined; + interactive?: boolean | undefined; } interface WorkspaceFilePreviewMessageHandlerInput { @@ -836,7 +1308,119 @@ function NativeFilePreview({ ); } -export function WorkspaceFilePreview({ path, file, files }: WorkspaceFilePreviewProps) { +function ConnectedUrlPreview({ url }: { url: string }) { + const t = useT(); + const [reloadKey, setReloadKey] = useState(0); + + const handleOpenExternal = useCallback(async () => { + try { + await window.codesign?.openExternal(url); + } catch (err) { + useCodesignStore.getState().pushToast({ + variant: 'error', + title: t('canvas.workspace.preview.openFailed'), + description: err instanceof Error ? err.message : t('errors.unknown'), + }); + } + }, [t, url]); + + return ( +
    +