Skip to content

Commit 137a6cf

Browse files
committed
fix: stabilize local workspace comments
1 parent f54cc8b commit 137a6cf

27 files changed

Lines changed: 1330 additions & 124 deletions

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,25 @@ describe('files-watcher subscribe / unsubscribe', () => {
142142
expect(watchMock).toHaveBeenCalledTimes(1);
143143
});
144144

145+
it('does not reject when the bound workspace folder is missing', () => {
146+
reset();
147+
const err = Object.assign(new Error('no such file or directory'), { code: 'ENOENT' });
148+
watchMock.mockImplementation(() => {
149+
throw err;
150+
});
151+
getDesignMock.mockReturnValue({
152+
id: 'd1',
153+
workspacePath: tempWorkspace('codesign-watch-missing'),
154+
});
155+
registerFilesWatcherIpc({} as never, () => null);
156+
const sub = getHandler('codesign:files:v1:subscribe');
157+
158+
expect(sub(null, { schemaVersion: 1, designId: 'd1' })).toEqual({ ok: true });
159+
160+
expect(watchMock).toHaveBeenCalledTimes(1);
161+
expect(__test.watchers.has('d1')).toBe(false);
162+
});
163+
145164
it('falls back to polling when native watch is denied by permissions', () => {
146165
reset();
147166
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ interface ActiveWatcher {
5050
pollBusy: boolean;
5151
}
5252

53+
type WatcherStartResult =
54+
| { ok: true; entry: ActiveWatcher }
55+
| { ok: false; reason: 'workspace-unavailable' | 'watch-failed' };
56+
5357
const watchers = new Map<string, ActiveWatcher>();
5458

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

73+
function isWorkspaceUnavailableWatchError(err: unknown): boolean {
74+
const code = (err as NodeJS.ErrnoException).code;
75+
return code === 'ENOENT' || code === 'ENOTDIR';
76+
}
77+
6978
async function pollWorkspaceSignature(root: string): Promise<string> {
7079
const rows: string[] = [];
7180

@@ -151,7 +160,7 @@ function startWatcher(
151160
designId: string,
152161
workspacePath: string,
153162
getWin: () => BrowserWindow | null,
154-
): ActiveWatcher | null {
163+
): WatcherStartResult {
155164
const entry: ActiveWatcher = {
156165
watcher: null,
157166
workspacePath,
@@ -177,9 +186,12 @@ function startWatcher(
177186
if (isPermissionWatchError(err)) {
178187
watchers.set(designId, entry);
179188
startPolling(designId, entry, getWin);
180-
return entry;
189+
return { ok: true, entry };
181190
}
182-
return null;
191+
if (isWorkspaceUnavailableWatchError(err)) {
192+
return { ok: false, reason: 'workspace-unavailable' };
193+
}
194+
return { ok: false, reason: 'watch-failed' };
183195
}
184196
entry.watcher = watcher;
185197
watcher.on('error', (err) => {
@@ -196,7 +208,7 @@ function startWatcher(
196208
startPolling(designId, active, getWin);
197209
});
198210
watchers.set(designId, entry);
199-
return entry;
211+
return { ok: true, entry };
200212
}
201213

202214
function stopWatcher(designId: string): void {
@@ -242,10 +254,14 @@ export function registerFilesWatcherIpc(db: Database, getWin: () => BrowserWindo
242254
return { ok: true };
243255
}
244256
}
245-
const entry = startWatcher(designId, workspacePath, getWin);
246-
if (!entry) {
257+
const result = startWatcher(designId, workspacePath, getWin);
258+
if (!result.ok) {
259+
if (result.reason === 'workspace-unavailable') {
260+
return { ok: true };
261+
}
247262
throw new CodesignError('Failed to watch workspace files', 'IPC_DB_ERROR');
248263
}
264+
const { entry } = result;
249265
entry.refCount = 1;
250266
return { ok: true };
251267
});

apps/desktop/src/renderer/src/components/FilesTabView.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CommentRow } from '@open-codesign/shared';
12
import { describe, expect, it, vi } from 'vitest';
23
import { openFileTab } from '../store/slices/tabs';
34
import {
@@ -8,12 +9,14 @@ import {
89
detectedPreviewTarget,
910
effectivePreviewModeForDesign,
1011
externalAppManagedFallbackPath,
12+
findReusableWorkspaceFileCommentForSelector,
1113
htmlRequiresWorkspaceDevServer,
1214
isMarkdownPreviewFile,
1315
isPreviewSourceUsableForSelectedPath,
1416
isRenderableDesignFileKind,
1517
previewKindForFile,
1618
resolveReferencedWorkspacePreviewPath,
19+
shouldEnableWorkspaceFilePreviewInteractions,
1720
shouldShowTweakPanelForFile,
1821
shouldUseDesignPreviewResolverForFile,
1922
splitMarkdownFrontmatter,
@@ -23,6 +26,24 @@ import {
2326
} from './FilesTabView';
2427

2528
describe('FilesTabView preview helpers', () => {
29+
const commentRow = (overrides: Partial<CommentRow> = {}): CommentRow => ({
30+
schemaVersion: 1,
31+
id: overrides.id ?? 'comment-1',
32+
designId: overrides.designId ?? 'design-1',
33+
snapshotId: overrides.snapshotId ?? 'snapshot-1',
34+
kind: overrides.kind ?? 'edit',
35+
selector: overrides.selector ?? '#hero',
36+
tag: overrides.tag ?? 'section',
37+
outerHTML: overrides.outerHTML ?? '<section id="hero">Hello</section>',
38+
rect: overrides.rect ?? { top: 10, left: 20, width: 100, height: 50 },
39+
text: overrides.text ?? 'Saved note',
40+
status: overrides.status ?? 'pending',
41+
createdAt: overrides.createdAt ?? '2026-05-13T00:00:00.000Z',
42+
appliedInSnapshotId: overrides.appliedInSnapshotId ?? null,
43+
...(overrides.scope ? { scope: overrides.scope } : {}),
44+
...(overrides.parentOuterHTML ? { parentOuterHTML: overrides.parentOuterHTML } : {}),
45+
});
46+
2647
it('clamps the file browser splitter width to usable bounds', () => {
2748
expect(clampFileBrowserWidth(120, 1280)).toBe(260);
2849
expect(clampFileBrowserWidth(480.4, 1280)).toBe(480);
@@ -145,6 +166,52 @@ describe('FilesTabView preview helpers', () => {
145166
});
146167
});
147168

169+
it('prefills the existing pending comment when reselecting the same file preview element', () => {
170+
const existing = commentRow({ id: 'comment-existing', text: 'Keep the saved note visible' });
171+
const selectCanvasElement = vi.fn();
172+
const openCommentBubble = vi.fn();
173+
const applyLiveRects = vi.fn();
174+
const pushIframeError = vi.fn();
175+
const handlers = createWorkspaceFilePreviewMessageHandlers({
176+
previewZoom: 100,
177+
comments: [existing],
178+
currentSnapshotId: existing.snapshotId,
179+
selectCanvasElement,
180+
openCommentBubble,
181+
applyLiveRects,
182+
pushIframeError,
183+
});
184+
185+
handlers.onElementSelected({
186+
__codesign: true,
187+
type: 'ELEMENT_SELECTED',
188+
selector: existing.selector,
189+
tag: existing.tag,
190+
outerHTML: existing.outerHTML,
191+
rect: existing.rect,
192+
});
193+
194+
expect(openCommentBubble).toHaveBeenCalledWith(
195+
expect.objectContaining({
196+
selector: existing.selector,
197+
existingCommentId: existing.id,
198+
initialText: existing.text,
199+
}),
200+
);
201+
});
202+
203+
it('falls back to a pending comment for the same selector when the file preview has no current snapshot', () => {
204+
const existing = commentRow({ id: 'comment-existing', snapshotId: 'snapshot-stale' });
205+
206+
expect(
207+
findReusableWorkspaceFileCommentForSelector({
208+
comments: [existing],
209+
currentSnapshotId: null,
210+
selector: existing.selector,
211+
}),
212+
).toBe(existing);
213+
});
214+
148215
it('marks html/jsx/tsx files as renderable', () => {
149216
expect(isRenderableDesignFileKind('html')).toBe(true);
150217
expect(isRenderableDesignFileKind('jsx')).toBe(true);
@@ -219,6 +286,12 @@ describe('FilesTabView preview helpers', () => {
219286
).toBe(false);
220287
});
221288

289+
it('keeps local workspace runtime previews interactive outside dedicated file tabs', () => {
290+
expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: 'runtime' })).toBe(true);
291+
expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: 'markdown' })).toBe(false);
292+
expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: null })).toBe(false);
293+
});
294+
222295
it('uses the design-level resolver only for generated preview fallbacks', () => {
223296
expect(
224297
shouldUseDesignPreviewResolverForFile({

apps/desktop/src/renderer/src/components/FilesTabView.tsx

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { useT } from '@open-codesign/i18n';
22
import { buildPreviewDocument, isRenderablePath } from '@open-codesign/runtime';
3-
import { DEFAULT_SOURCE_ENTRY, LEGACY_SOURCE_ENTRY, type PreviewMode } from '@open-codesign/shared';
3+
import {
4+
type CommentRow,
5+
DEFAULT_SOURCE_ENTRY,
6+
LEGACY_SOURCE_ENTRY,
7+
type PreviewMode,
8+
} from '@open-codesign/shared';
49
import {
510
ChevronRight,
611
ExternalLink,
@@ -41,7 +46,9 @@ import {
4146
handlePreviewMessage,
4247
isTrustedPreviewMessageSource,
4348
type PreviewMessageHandlers,
49+
postClearPinToPreviewWindow,
4450
postModeToPreviewWindow,
51+
postPinSelectorToPreviewWindow,
4552
scaleRectForZoom,
4653
stablePreviewSourceKey,
4754
} from '../preview/helpers';
@@ -783,6 +790,12 @@ export function shouldShowTweakPanelForFile(input: {
783790
);
784791
}
785792

793+
export function shouldEnableWorkspaceFilePreviewInteractions(input: {
794+
previewKind: FilePreviewKind | null;
795+
}): boolean {
796+
return input.previewKind === 'runtime';
797+
}
798+
786799
export function shouldUseDesignPreviewResolverForFile(input: {
787800
path: string;
788801
previewKind: FilePreviewKind;
@@ -924,14 +937,40 @@ interface WorkspaceFilePreviewProps {
924937

925938
interface WorkspaceFilePreviewMessageHandlerInput {
926939
previewZoom: number;
940+
comments?: CommentRow[] | undefined;
941+
currentSnapshotId?: string | null | undefined;
927942
selectCanvasElement: ReturnType<typeof useCodesignStore.getState>['selectCanvasElement'];
928943
openCommentBubble: ReturnType<typeof useCodesignStore.getState>['openCommentBubble'];
929944
applyLiveRects: ReturnType<typeof useCodesignStore.getState>['applyLiveRects'];
930945
pushIframeError: ReturnType<typeof useCodesignStore.getState>['pushIframeError'];
931946
}
932947

948+
export function findReusableWorkspaceFileCommentForSelector(input: {
949+
comments: CommentRow[];
950+
currentSnapshotId: string | null;
951+
selector: string;
952+
}): CommentRow | null {
953+
let fallback: CommentRow | null = null;
954+
for (let index = input.comments.length - 1; index >= 0; index--) {
955+
const comment = input.comments[index];
956+
if (
957+
comment?.kind === 'edit' &&
958+
comment.status === 'pending' &&
959+
comment.selector === input.selector
960+
) {
961+
if (input.currentSnapshotId !== null && comment.snapshotId === input.currentSnapshotId) {
962+
return comment;
963+
}
964+
fallback ??= comment;
965+
}
966+
}
967+
return fallback;
968+
}
969+
933970
export function createWorkspaceFilePreviewMessageHandlers({
934971
previewZoom,
972+
comments = [],
973+
currentSnapshotId = null,
935974
selectCanvasElement,
936975
openCommentBubble,
937976
applyLiveRects,
@@ -946,11 +985,19 @@ export function createWorkspaceFilePreviewMessageHandlers({
946985
outerHTML: msg.outerHTML,
947986
rect: scaled,
948987
});
988+
const existingComment = findReusableWorkspaceFileCommentForSelector({
989+
comments,
990+
currentSnapshotId,
991+
selector: msg.selector,
992+
});
949993
openCommentBubble({
950994
selector: msg.selector,
951995
tag: msg.tag,
952996
outerHTML: msg.outerHTML,
953997
rect: scaled,
998+
...(existingComment
999+
? { existingCommentId: existingComment.id, initialText: existingComment.text }
1000+
: {}),
9541001
...(typeof msg.parentOuterHTML === 'string' && msg.parentOuterHTML.length > 0
9551002
? { parentOuterHTML: msg.parentOuterHTML }
9561003
: {}),
@@ -1431,6 +1478,9 @@ export function WorkspaceFilePreview({
14311478
const selectCanvasElement = useCodesignStore((s) => s.selectCanvasElement);
14321479
const openCommentBubble = useCodesignStore((s) => s.openCommentBubble);
14331480
const applyLiveRects = useCodesignStore((s) => s.applyLiveRects);
1481+
const comments = useCodesignStore((s) => s.comments);
1482+
const currentSnapshotId = useCodesignStore((s) => s.currentSnapshotId);
1483+
const commentBubble = useCodesignStore((s) => s.commentBubble);
14341484
const { files: observedFiles } = useDesignFiles(files ? null : currentDesignId);
14351485
const workspaceFiles = files ?? observedFiles;
14361486
const currentDesign = designs.find((d) => d.id === currentDesignId);
@@ -1481,6 +1531,8 @@ export function WorkspaceFilePreview({
14811531
event.data,
14821532
createWorkspaceFilePreviewMessageHandlers({
14831533
previewZoom,
1534+
comments,
1535+
currentSnapshotId,
14841536
selectCanvasElement: (selection) => {
14851537
if (interactive) selectCanvasElement(selection);
14861538
},
@@ -1500,6 +1552,8 @@ export function WorkspaceFilePreview({
15001552
}, [
15011553
pushIframeError,
15021554
previewZoom,
1555+
comments,
1556+
currentSnapshotId,
15031557
selectCanvasElement,
15041558
openCommentBubble,
15051559
applyLiveRects,
@@ -1514,6 +1568,19 @@ export function WorkspaceFilePreview({
15141568
);
15151569
}, [interactionMode, pushIframeError, interactive]);
15161570

1571+
useEffect(() => {
1572+
if (!interactive) return;
1573+
if (commentBubble && interactionMode === 'comment') {
1574+
postPinSelectorToPreviewWindow(
1575+
iframeRef.current?.contentWindow,
1576+
commentBubble.selector,
1577+
pushIframeError,
1578+
);
1579+
return;
1580+
}
1581+
postClearPinToPreviewWindow(iframeRef.current?.contentWindow, pushIframeError);
1582+
}, [commentBubble, interactionMode, interactive, pushIframeError]);
1583+
15171584
useEffect(() => {
15181585
// Re-read when the file watcher reports changed metadata for either the
15191586
// selected file or an HTML placeholder's resolved JSX/TSX source.
@@ -1728,6 +1795,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null
17281795
const externalFallbackFile = externalFallbackPath
17291796
? (files.find((f) => f.path === externalFallbackPath) ?? null)
17301797
: null;
1798+
const externalFallbackPreviewKind = externalFallbackPath
1799+
? previewKindForFile(externalFallbackPath, externalFallbackFile?.kind)
1800+
: null;
17311801
const usesExternalPreview =
17321802
effectivePreviewMode === 'connected-url' || effectivePreviewMode === 'external-app';
17331803
const showPreviewHeaderAction =
@@ -1812,7 +1882,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null
18121882
path={externalFallbackPath}
18131883
file={externalFallbackFile}
18141884
files={files}
1815-
interactive={isDedicatedFileTab}
1885+
interactive={shouldEnableWorkspaceFilePreviewInteractions({
1886+
previewKind: externalFallbackPreviewKind,
1887+
})}
18161888
/>
18171889
);
18181890
}
@@ -1829,7 +1901,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null
18291901
path={selectedPath}
18301902
file={selectedFile}
18311903
files={files}
1832-
interactive={isDedicatedFileTab}
1904+
interactive={shouldEnableWorkspaceFilePreviewInteractions({
1905+
previewKind: previewKindForFile(selectedPath, selectedFile.kind),
1906+
})}
18331907
/>
18341908
);
18351909
}
@@ -1841,7 +1915,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null
18411915
path={selectedPath}
18421916
file={selectedFile}
18431917
files={files}
1844-
interactive={isDedicatedFileTab}
1918+
interactive={shouldEnableWorkspaceFilePreviewInteractions({
1919+
previewKind: previewKindForFile(selectedPath, selectedFile?.kind),
1920+
})}
18451921
/>
18461922
);
18471923
}

0 commit comments

Comments
 (0)