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

Filter by extension

Filter by extension

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

Update comment mode copy for saving comments and adding saved comments to chat.
28 changes: 25 additions & 3 deletions apps/desktop/src/main/workspace-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,37 @@ describe('files-watcher subscribe / unsubscribe', () => {
expect(watchMock).toHaveBeenCalledTimes(1);
});

it('falls back to polling when native watch is denied by permissions', () => {
it('does not reject when the bound workspace folder is missing', () => {
reset();
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
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-eperm'),
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.each([
['permission denial', 'EPERM'],
['unsupported directory watch', 'EISDIR'],
])('falls back to polling when native watch fails from %s', (_reason, code) => {
reset();
const err = Object.assign(new Error('watch unavailable'), { code });
watchMock.mockImplementation(() => {
throw err;
});
getDesignMock.mockReturnValue({
id: 'd1',
workspacePath: tempWorkspace(`codesign-watch-${code.toLowerCase()}`),
});
registerFilesWatcherIpc({} as never, () => null);
const sub = getHandler('codesign:files:v1:subscribe');
Expand Down
36 changes: 26 additions & 10 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 @@ -61,9 +65,14 @@ function toForwardSlashes(path: string): string {
return sep === '/' ? path : path.split(sep).join('/');
}

function isPermissionWatchError(err: unknown): boolean {
function shouldFallbackToPolling(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException).code;
return code === 'EPERM' || code === 'EACCES' || code === 'EISDIR';
}

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

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