Skip to content

Commit 8ea4bff

Browse files
authored
fix(desktop): stabilize local workspace comments
Stabilize local workspace comment mode, queue saved comments for chat, restore pins for saved comments, and make workspace watching more resilient.
1 parent 693e731 commit 8ea4bff

28 files changed

Lines changed: 1346 additions & 131 deletions

.changeset/comment-mode-copy.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@open-codesign/desktop": patch
3+
"@open-codesign/i18n": patch
4+
---
5+
6+
Update comment mode copy for saving comments and adding saved comments to chat.

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

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

145-
it('falls back to polling when native watch is denied by permissions', () => {
145+
it('does not reject when the bound workspace folder is missing', () => {
146146
reset();
147-
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
147+
const err = Object.assign(new Error('no such file or directory'), { code: 'ENOENT' });
148148
watchMock.mockImplementation(() => {
149149
throw err;
150150
});
151151
getDesignMock.mockReturnValue({
152152
id: 'd1',
153-
workspacePath: tempWorkspace('codesign-watch-eperm'),
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+
164+
it.each([
165+
['permission denial', 'EPERM'],
166+
['unsupported directory watch', 'EISDIR'],
167+
])('falls back to polling when native watch fails from %s', (_reason, code) => {
168+
reset();
169+
const err = Object.assign(new Error('watch unavailable'), { code });
170+
watchMock.mockImplementation(() => {
171+
throw err;
172+
});
173+
getDesignMock.mockReturnValue({
174+
id: 'd1',
175+
workspacePath: tempWorkspace(`codesign-watch-${code.toLowerCase()}`),
154176
});
155177
registerFilesWatcherIpc({} as never, () => null);
156178
const sub = getHandler('codesign:files:v1:subscribe');

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

Lines changed: 26 additions & 10 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 {
@@ -61,9 +65,14 @@ function toForwardSlashes(path: string): string {
6165
return sep === '/' ? path : path.split(sep).join('/');
6266
}
6367

64-
function isPermissionWatchError(err: unknown): boolean {
68+
function shouldFallbackToPolling(err: unknown): boolean {
69+
const code = (err as NodeJS.ErrnoException).code;
70+
return code === 'EPERM' || code === 'EACCES' || code === 'EISDIR';
71+
}
72+
73+
function isWorkspaceUnavailableWatchError(err: unknown): boolean {
6574
const code = (err as NodeJS.ErrnoException).code;
66-
return code === 'EPERM' || code === 'EACCES';
75+
return code === 'ENOENT' || code === 'ENOTDIR';
6776
}
6877

6978
async function pollWorkspaceSignature(root: string): Promise<string> {
@@ -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,
@@ -174,17 +183,20 @@ function startWatcher(
174183
workspacePath,
175184
error: err instanceof Error ? err.message : String(err),
176185
});
177-
if (isPermissionWatchError(err)) {
186+
if (shouldFallbackToPolling(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) => {
186198
log.warn('files.watch.error', { designId, error: String(err) });
187-
if (!isPermissionWatchError(err)) return;
199+
if (!shouldFallbackToPolling(err)) return;
188200
const active = watchers.get(designId);
189201
if (!active || active.watcher !== watcher) return;
190202
try {
@@ -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({

0 commit comments

Comments
 (0)