Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/desktop/src/main/workspace-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ describe('files-watcher subscribe / unsubscribe', () => {
expect(watchMock).toHaveBeenCalledTimes(1);
});

it('does not reject when the bound workspace folder is missing', () => {
reset();
const err = Object.assign(new Error('no such file or directory'), { code: 'ENOENT' });
watchMock.mockImplementation(() => {
throw err;
});
getDesignMock.mockReturnValue({
id: 'd1',
workspacePath: tempWorkspace('codesign-watch-missing'),
});
registerFilesWatcherIpc({} as never, () => null);
const sub = getHandler('codesign:files:v1:subscribe');

expect(sub(null, { schemaVersion: 1, designId: 'd1' })).toEqual({ ok: true });

expect(watchMock).toHaveBeenCalledTimes(1);
expect(__test.watchers.has('d1')).toBe(false);
});

it('falls back to polling when native watch is denied by permissions', () => {
reset();
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
Expand Down
28 changes: 22 additions & 6 deletions apps/desktop/src/main/workspace-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ interface ActiveWatcher {
pollBusy: boolean;
}

type WatcherStartResult =
| { ok: true; entry: ActiveWatcher }
| { ok: false; reason: 'workspace-unavailable' | 'watch-failed' };

const watchers = new Map<string, ActiveWatcher>();

function isIgnored(rel: string): boolean {
Expand All @@ -66,6 +70,11 @@ function isPermissionWatchError(err: unknown): boolean {
return code === 'EPERM' || code === 'EACCES';
}

function isWorkspaceUnavailableWatchError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException).code;
return code === 'ENOENT' || code === 'ENOTDIR';
}

async function pollWorkspaceSignature(root: string): Promise<string> {
const rows: string[] = [];

Expand Down Expand Up @@ -151,7 +160,7 @@ function startWatcher(
designId: string,
workspacePath: string,
getWin: () => BrowserWindow | null,
): ActiveWatcher | null {
): WatcherStartResult {
const entry: ActiveWatcher = {
watcher: null,
workspacePath,
Expand All @@ -177,9 +186,12 @@ function startWatcher(
if (isPermissionWatchError(err)) {
watchers.set(designId, entry);
startPolling(designId, entry, getWin);
return entry;
return { ok: true, entry };
}
return null;
if (isWorkspaceUnavailableWatchError(err)) {
return { ok: false, reason: 'workspace-unavailable' };
}
return { ok: false, reason: 'watch-failed' };
}
entry.watcher = watcher;
watcher.on('error', (err) => {
Expand All @@ -196,7 +208,7 @@ function startWatcher(
startPolling(designId, active, getWin);
});
watchers.set(designId, entry);
return entry;
return { ok: true, entry };
}

function stopWatcher(designId: string): void {
Expand Down Expand Up @@ -242,10 +254,14 @@ export function registerFilesWatcherIpc(db: Database, getWin: () => BrowserWindo
return { ok: true };
}
}
const entry = startWatcher(designId, workspacePath, getWin);
if (!entry) {
const result = startWatcher(designId, workspacePath, getWin);
if (!result.ok) {
if (result.reason === 'workspace-unavailable') {
return { ok: true };
}
throw new CodesignError('Failed to watch workspace files', 'IPC_DB_ERROR');
}
const { entry } = result;
entry.refCount = 1;
return { ok: true };
});
Expand Down
73 changes: 73 additions & 0 deletions apps/desktop/src/renderer/src/components/FilesTabView.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CommentRow } from '@open-codesign/shared';
import { describe, expect, it, vi } from 'vitest';
import { openFileTab } from '../store/slices/tabs';
import {
Expand All @@ -8,12 +9,14 @@ import {
detectedPreviewTarget,
effectivePreviewModeForDesign,
externalAppManagedFallbackPath,
findReusableWorkspaceFileCommentForSelector,
htmlRequiresWorkspaceDevServer,
isMarkdownPreviewFile,
isPreviewSourceUsableForSelectedPath,
isRenderableDesignFileKind,
previewKindForFile,
resolveReferencedWorkspacePreviewPath,
shouldEnableWorkspaceFilePreviewInteractions,
shouldShowTweakPanelForFile,
shouldUseDesignPreviewResolverForFile,
splitMarkdownFrontmatter,
Expand All @@ -23,6 +26,24 @@ import {
} from './FilesTabView';

describe('FilesTabView preview helpers', () => {
const commentRow = (overrides: Partial<CommentRow> = {}): CommentRow => ({
schemaVersion: 1,
id: overrides.id ?? 'comment-1',
designId: overrides.designId ?? 'design-1',
snapshotId: overrides.snapshotId ?? 'snapshot-1',
kind: overrides.kind ?? 'edit',
selector: overrides.selector ?? '#hero',
tag: overrides.tag ?? 'section',
outerHTML: overrides.outerHTML ?? '<section id="hero">Hello</section>',
rect: overrides.rect ?? { top: 10, left: 20, width: 100, height: 50 },
text: overrides.text ?? 'Saved note',
status: overrides.status ?? 'pending',
createdAt: overrides.createdAt ?? '2026-05-13T00:00:00.000Z',
appliedInSnapshotId: overrides.appliedInSnapshotId ?? null,
...(overrides.scope ? { scope: overrides.scope } : {}),
...(overrides.parentOuterHTML ? { parentOuterHTML: overrides.parentOuterHTML } : {}),
});

it('clamps the file browser splitter width to usable bounds', () => {
expect(clampFileBrowserWidth(120, 1280)).toBe(260);
expect(clampFileBrowserWidth(480.4, 1280)).toBe(480);
Expand Down Expand Up @@ -145,6 +166,52 @@ describe('FilesTabView preview helpers', () => {
});
});

it('prefills the existing pending comment when reselecting the same file preview element', () => {
const existing = commentRow({ id: 'comment-existing', text: 'Keep the saved note visible' });
const selectCanvasElement = vi.fn();
const openCommentBubble = vi.fn();
const applyLiveRects = vi.fn();
const pushIframeError = vi.fn();
const handlers = createWorkspaceFilePreviewMessageHandlers({
previewZoom: 100,
comments: [existing],
currentSnapshotId: existing.snapshotId,
selectCanvasElement,
openCommentBubble,
applyLiveRects,
pushIframeError,
});

handlers.onElementSelected({
__codesign: true,
type: 'ELEMENT_SELECTED',
selector: existing.selector,
tag: existing.tag,
outerHTML: existing.outerHTML,
rect: existing.rect,
});

expect(openCommentBubble).toHaveBeenCalledWith(
expect.objectContaining({
selector: existing.selector,
existingCommentId: existing.id,
initialText: existing.text,
}),
);
});

it('falls back to a pending comment for the same selector when the file preview has no current snapshot', () => {
const existing = commentRow({ id: 'comment-existing', snapshotId: 'snapshot-stale' });

expect(
findReusableWorkspaceFileCommentForSelector({
comments: [existing],
currentSnapshotId: null,
selector: existing.selector,
}),
).toBe(existing);
});

it('marks html/jsx/tsx files as renderable', () => {
expect(isRenderableDesignFileKind('html')).toBe(true);
expect(isRenderableDesignFileKind('jsx')).toBe(true);
Expand Down Expand Up @@ -219,6 +286,12 @@ describe('FilesTabView preview helpers', () => {
).toBe(false);
});

it('keeps local workspace runtime previews interactive outside dedicated file tabs', () => {
expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: 'runtime' })).toBe(true);
expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: 'markdown' })).toBe(false);
expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: null })).toBe(false);
});

it('uses the design-level resolver only for generated preview fallbacks', () => {
expect(
shouldUseDesignPreviewResolverForFile({
Expand Down
84 changes: 80 additions & 4 deletions apps/desktop/src/renderer/src/components/FilesTabView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useT } from '@open-codesign/i18n';
import { buildPreviewDocument, isRenderablePath } from '@open-codesign/runtime';
import { DEFAULT_SOURCE_ENTRY, LEGACY_SOURCE_ENTRY, type PreviewMode } from '@open-codesign/shared';
import {
type CommentRow,
DEFAULT_SOURCE_ENTRY,
LEGACY_SOURCE_ENTRY,
type PreviewMode,
} from '@open-codesign/shared';
import {
ChevronRight,
ExternalLink,
Expand Down Expand Up @@ -41,7 +46,9 @@ import {
handlePreviewMessage,
isTrustedPreviewMessageSource,
type PreviewMessageHandlers,
postClearPinToPreviewWindow,
postModeToPreviewWindow,
postPinSelectorToPreviewWindow,
scaleRectForZoom,
stablePreviewSourceKey,
} from '../preview/helpers';
Expand Down Expand Up @@ -783,6 +790,12 @@ export function shouldShowTweakPanelForFile(input: {
);
}

export function shouldEnableWorkspaceFilePreviewInteractions(input: {
previewKind: FilePreviewKind | null;
}): boolean {
return input.previewKind === 'runtime';
}

export function shouldUseDesignPreviewResolverForFile(input: {
path: string;
previewKind: FilePreviewKind;
Expand Down Expand Up @@ -924,14 +937,40 @@ interface WorkspaceFilePreviewProps {

interface WorkspaceFilePreviewMessageHandlerInput {
previewZoom: number;
comments?: CommentRow[] | undefined;
currentSnapshotId?: string | null | undefined;
selectCanvasElement: ReturnType<typeof useCodesignStore.getState>['selectCanvasElement'];
openCommentBubble: ReturnType<typeof useCodesignStore.getState>['openCommentBubble'];
applyLiveRects: ReturnType<typeof useCodesignStore.getState>['applyLiveRects'];
pushIframeError: ReturnType<typeof useCodesignStore.getState>['pushIframeError'];
}

export function findReusableWorkspaceFileCommentForSelector(input: {
comments: CommentRow[];
currentSnapshotId: string | null;
selector: string;
}): CommentRow | null {
let fallback: CommentRow | null = null;
for (let index = input.comments.length - 1; index >= 0; index--) {
const comment = input.comments[index];
if (
comment?.kind === 'edit' &&
comment.status === 'pending' &&
comment.selector === input.selector
) {
if (input.currentSnapshotId !== null && comment.snapshotId === input.currentSnapshotId) {
return comment;
}
fallback ??= comment;
}
}
return fallback;
}

export function createWorkspaceFilePreviewMessageHandlers({
previewZoom,
comments = [],
currentSnapshotId = null,
selectCanvasElement,
openCommentBubble,
applyLiveRects,
Expand All @@ -946,11 +985,19 @@ export function createWorkspaceFilePreviewMessageHandlers({
outerHTML: msg.outerHTML,
rect: scaled,
});
const existingComment = findReusableWorkspaceFileCommentForSelector({
comments,
currentSnapshotId,
selector: msg.selector,
});
openCommentBubble({
selector: msg.selector,
tag: msg.tag,
outerHTML: msg.outerHTML,
rect: scaled,
...(existingComment
? { existingCommentId: existingComment.id, initialText: existingComment.text }
: {}),
...(typeof msg.parentOuterHTML === 'string' && msg.parentOuterHTML.length > 0
? { parentOuterHTML: msg.parentOuterHTML }
: {}),
Expand Down Expand Up @@ -1431,6 +1478,9 @@ export function WorkspaceFilePreview({
const selectCanvasElement = useCodesignStore((s) => s.selectCanvasElement);
const openCommentBubble = useCodesignStore((s) => s.openCommentBubble);
const applyLiveRects = useCodesignStore((s) => s.applyLiveRects);
const comments = useCodesignStore((s) => s.comments);
const currentSnapshotId = useCodesignStore((s) => s.currentSnapshotId);
const commentBubble = useCodesignStore((s) => s.commentBubble);
const { files: observedFiles } = useDesignFiles(files ? null : currentDesignId);
const workspaceFiles = files ?? observedFiles;
const currentDesign = designs.find((d) => d.id === currentDesignId);
Expand Down Expand Up @@ -1481,6 +1531,8 @@ export function WorkspaceFilePreview({
event.data,
createWorkspaceFilePreviewMessageHandlers({
previewZoom,
comments,
currentSnapshotId,
selectCanvasElement: (selection) => {
if (interactive) selectCanvasElement(selection);
},
Expand All @@ -1500,6 +1552,8 @@ export function WorkspaceFilePreview({
}, [
pushIframeError,
previewZoom,
comments,
currentSnapshotId,
selectCanvasElement,
openCommentBubble,
applyLiveRects,
Expand All @@ -1514,6 +1568,19 @@ export function WorkspaceFilePreview({
);
}, [interactionMode, pushIframeError, interactive]);

useEffect(() => {
if (!interactive) return;
if (commentBubble && interactionMode === 'comment') {
postPinSelectorToPreviewWindow(
iframeRef.current?.contentWindow,
commentBubble.selector,
pushIframeError,
);
return;
}
postClearPinToPreviewWindow(iframeRef.current?.contentWindow, pushIframeError);
}, [commentBubble, interactionMode, interactive, pushIframeError]);

useEffect(() => {
// Re-read when the file watcher reports changed metadata for either the
// selected file or an HTML placeholder's resolved JSX/TSX source.
Expand Down Expand Up @@ -1728,6 +1795,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null
const externalFallbackFile = externalFallbackPath
? (files.find((f) => f.path === externalFallbackPath) ?? null)
: null;
const externalFallbackPreviewKind = externalFallbackPath
? previewKindForFile(externalFallbackPath, externalFallbackFile?.kind)
: null;
const usesExternalPreview =
effectivePreviewMode === 'connected-url' || effectivePreviewMode === 'external-app';
const showPreviewHeaderAction =
Expand Down Expand Up @@ -1812,7 +1882,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null
path={externalFallbackPath}
file={externalFallbackFile}
files={files}
interactive={isDedicatedFileTab}
interactive={shouldEnableWorkspaceFilePreviewInteractions({
previewKind: externalFallbackPreviewKind,
})}
/>
);
}
Expand All @@ -1829,7 +1901,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null
path={selectedPath}
file={selectedFile}
files={files}
interactive={isDedicatedFileTab}
interactive={shouldEnableWorkspaceFilePreviewInteractions({
previewKind: previewKindForFile(selectedPath, selectedFile.kind),
})}
/>
);
}
Expand All @@ -1841,7 +1915,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null
path={selectedPath}
file={selectedFile}
files={files}
interactive={isDedicatedFileTab}
interactive={shouldEnableWorkspaceFilePreviewInteractions({
previewKind: previewKindForFile(selectedPath, selectedFile?.kind),
})}
/>
);
}
Expand Down
Loading
Loading