diff --git a/packages/code-link-cli/src/controller.rename.test.ts b/packages/code-link-cli/src/controller.rename.test.ts new file mode 100644 index 000000000..e01ab3f90 --- /dev/null +++ b/packages/code-link-cli/src/controller.rename.test.ts @@ -0,0 +1,353 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { WebSocket } from "ws" +import { executeEffect } from "./controller.ts" +import type { Config } from "./types.ts" +import { createHashTracker } from "./utils/hash-tracker.ts" +import { hashFileContent } from "./utils/state-persistence.ts" + +const { sendMessage } = vi.hoisted(() => ({ + sendMessage: vi.fn(), +})) + +vi.mock("./helpers/connection.ts", () => ({ + initConnection: vi.fn(), + sendMessage, +})) + +const mockSocket = {} as WebSocket + +describe("rename confirmation bookkeeping", () => { + beforeEach(() => { + sendMessage.mockReset() + }) + + it("skips echoed remote renames when write and delete collapse into one watcher rename", async () => { + const content = "export const New = () => null" + const hashTracker = createHashTracker() + hashTracker.remember("New.tsx", content) + hashTracker.markDelete("Old.tsx") + + const pendingRenameConfirmations = new Map() + + await executeEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "Old.tsx", + newFileName: "New.tsx", + content, + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: null, + filesDir: null, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker, + installer: null, + fileMetadataCache: { + recordDelete: vi.fn(), + } as never, + pendingRenameConfirmations, + userActions: {} as never, + syncState: { + mode: "watching", + socket: mockSocket, + pendingRemoteChanges: [], + }, + } + ) + + expect(sendMessage).not.toHaveBeenCalled() + expect(hashTracker.shouldSkipDelete("Old.tsx")).toBe(false) + expect(hashTracker.shouldSkip("New.tsx", content)).toBe(false) + expect(pendingRenameConfirmations.size).toBe(0) + }) + + it("waits for file-synced before deleting old tracking", async () => { + sendMessage.mockResolvedValue(true) + + const hashTracker = { + remember: vi.fn(), + shouldSkip: vi.fn(), + forget: vi.fn(), + clear: vi.fn(), + markDelete: vi.fn(), + shouldSkipDelete: vi.fn(), + clearDelete: vi.fn(), + } + const fileMetadataCache = { + recordDelete: vi.fn(), + } + const pendingRenameConfirmations = new Map() + + await executeEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "Old.tsx", + newFileName: "New.tsx", + content: "export const New = () => null", + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: null, + filesDir: null, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker: hashTracker as never, + installer: null, + fileMetadataCache: fileMetadataCache as never, + pendingRenameConfirmations, + userActions: {} as never, + syncState: { + mode: "watching", + socket: {} as never, + pendingRemoteChanges: [], + }, + } + ) + + expect(sendMessage).toHaveBeenCalledWith( + expect.anything(), + { + type: "file-rename", + oldFileName: "Old.tsx", + newFileName: "New.tsx", + content: "export const New = () => null", + } + ) + expect(hashTracker.forget).not.toHaveBeenCalled() + expect(hashTracker.remember).not.toHaveBeenCalled() + expect(fileMetadataCache.recordDelete).not.toHaveBeenCalled() + expect(pendingRenameConfirmations.get("New.tsx")).toEqual({ + oldFileName: "Old.tsx", + content: "export const New = () => null", + }) + }) + + it("normalizes extensionless rename targets for later confirmation lookup", async () => { + sendMessage.mockResolvedValue(true) + + const hashTracker = { + remember: vi.fn(), + shouldSkip: vi.fn(), + forget: vi.fn(), + clear: vi.fn(), + markDelete: vi.fn(), + shouldSkipDelete: vi.fn(), + clearDelete: vi.fn(), + } + const pendingRenameConfirmations = new Map() + + await executeEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "Old.tsx", + newFileName: "New", + content: "export const New = () => null", + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: null, + filesDir: null, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker: hashTracker as never, + installer: null, + fileMetadataCache: { + recordDelete: vi.fn(), + } as never, + pendingRenameConfirmations, + userActions: {} as never, + syncState: { + mode: "watching", + socket: {} as never, + pendingRemoteChanges: [], + }, + } + ) + + expect(sendMessage).toHaveBeenCalledWith( + expect.anything(), + { + type: "file-rename", + oldFileName: "Old.tsx", + newFileName: "New.tsx", + content: "export const New = () => null", + } + ) + expect(pendingRenameConfirmations.get("New.tsx")).toEqual({ + oldFileName: "Old.tsx", + content: "export const New = () => null", + }) + expect(pendingRenameConfirmations.has("New")).toBe(false) + }) + + it("applies old-file cleanup after file-synced arrives", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "New.tsx"), "export const New = () => null", "utf-8") + + const hashTracker = { + remember: vi.fn(), + shouldSkip: vi.fn(), + forget: vi.fn(), + clear: vi.fn(), + markDelete: vi.fn(), + shouldSkipDelete: vi.fn(), + clearDelete: vi.fn(), + } + const fileMetadataCache = { + recordSyncedSnapshot: vi.fn(), + recordDelete: vi.fn(), + } + const pendingRenameConfirmations = new Map([ + ["New.tsx", { oldFileName: "Old.tsx", content: "export const New = () => null" }], + ]) + + await executeEffect( + { + type: "UPDATE_FILE_METADATA", + fileName: "New.tsx", + remoteModifiedAt: 1234, + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: tmpDir, + filesDir, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker: hashTracker as never, + installer: null, + fileMetadataCache: fileMetadataCache as never, + pendingRenameConfirmations, + userActions: {} as never, + syncState: { + mode: "watching", + socket: mockSocket, + pendingRemoteChanges: [], + }, + } + ) + + expect(fileMetadataCache.recordSyncedSnapshot).toHaveBeenCalledWith( + "New.tsx", + hashFileContent("export const New = () => null"), + 1234 + ) + expect(hashTracker.forget).toHaveBeenCalledWith("Old.tsx") + expect(fileMetadataCache.recordDelete).toHaveBeenCalledWith("Old.tsx") + expect(hashTracker.remember).toHaveBeenCalledWith("New.tsx", "export const New = () => null") + expect(pendingRenameConfirmations.has("New.tsx")).toBe(false) + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("applies old-file cleanup when file-synced uses a normalized rename target", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-normalized-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + + const hashTracker = { + remember: vi.fn(), + shouldSkip: vi.fn(), + forget: vi.fn(), + clear: vi.fn(), + markDelete: vi.fn(), + shouldSkipDelete: vi.fn(), + clearDelete: vi.fn(), + } + const fileMetadataCache = { + recordSyncedSnapshot: vi.fn(), + recordDelete: vi.fn(), + } + const pendingRenameConfirmations = new Map() + + sendMessage.mockResolvedValue(true) + + await executeEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "Old.tsx", + newFileName: "New", + content: "export const New = () => null", + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: tmpDir, + filesDir, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker: hashTracker as never, + installer: null, + fileMetadataCache: fileMetadataCache as never, + pendingRenameConfirmations, + userActions: {} as never, + syncState: { + mode: "watching", + socket: {} as never, + pendingRemoteChanges: [], + }, + } + ) + + await executeEffect( + { + type: "UPDATE_FILE_METADATA", + fileName: "New.tsx", + remoteModifiedAt: 1234, + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: tmpDir, + filesDir, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker: hashTracker as never, + installer: null, + fileMetadataCache: fileMetadataCache as never, + pendingRenameConfirmations, + userActions: {} as never, + syncState: { + mode: "watching", + socket: mockSocket, + pendingRemoteChanges: [], + }, + } + ) + + expect(fileMetadataCache.recordSyncedSnapshot).toHaveBeenCalledWith( + "New.tsx", + hashFileContent("export const New = () => null"), + 1234 + ) + expect(hashTracker.forget).toHaveBeenCalledWith("Old.tsx") + expect(fileMetadataCache.recordDelete).toHaveBeenCalledWith("Old.tsx") + expect(hashTracker.remember).toHaveBeenCalledWith("New.tsx", "export const New = () => null") + expect(pendingRenameConfirmations.has("New.tsx")).toBe(false) + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + +}) diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index 21f0e5939..76d604fde 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -6,7 +6,7 @@ */ import type { CliToPluginMessage, PluginToCliMessage } from "@code-link/shared" -import { pluralize, shortProjectHash } from "@code-link/shared" +import { normalizeCodeFileName, pluralize, shortProjectHash } from "@code-link/shared" import fs from "fs/promises" import path from "path" import type { WebSocket } from "ws" @@ -164,6 +164,12 @@ type Effect = type: "LOCAL_INITIATED_FILE_DELETE" fileNames: string[] } + | { + type: "SEND_FILE_RENAME" + oldFileName: string + newFileName: string + content: string + } | { type: "PERSIST_STATE" } | { type: "SYNC_COMPLETE" @@ -177,6 +183,11 @@ type Effect = message: string } +interface PendingRenameConfirmation { + oldFileName: string + content: string +} + /** Log helper */ function log(level: "info" | "debug" | "warn" | "success", message: string): Effect { return { type: "LOG", level, message } @@ -556,6 +567,23 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff }) break } + + case "rename": { + if (content === undefined || !event.event.oldRelativePath) { + effects.push(log("warn", `Rename event missing data: ${relativePath}`)) + return { state, effects } + } + effects.push( + log("debug", `Local rename detected: ${event.event.oldRelativePath} → ${relativePath}`), + { + type: "SEND_FILE_RENAME", + oldFileName: event.event.oldRelativePath, + newFileName: relativePath, + content, + } + ) + break + } } return { state, effects } @@ -675,11 +703,20 @@ async function executeEffect( hashTracker: ReturnType installer: Installer | null fileMetadataCache: FileMetadataCache + pendingRenameConfirmations: Map userActions: PluginUserPromptCoordinator syncState: SyncState } ): Promise { - const { config, hashTracker, installer, fileMetadataCache, userActions, syncState } = context + const { + config, + hashTracker, + installer, + fileMetadataCache, + pendingRenameConfirmations, + userActions, + syncState, + } = context switch (effect.type) { case "INIT_WORKSPACE": { @@ -852,12 +889,22 @@ async function executeEffect( // Read current file content to compute hash const currentContent = await readFileSafe(effect.fileName, config.filesDir) + // Rename cleanup waits for the plugin's file-synced acknowledgment. + const pendingRenameConfirmation = pendingRenameConfirmations.get(normalizeCodeFileName(effect.fileName)) + const syncedContent = currentContent ?? pendingRenameConfirmation?.content ?? null - if (currentContent !== null) { - const contentHash = hashFileContent(currentContent) + if (syncedContent !== null) { + const contentHash = hashFileContent(syncedContent) fileMetadataCache.recordSyncedSnapshot(effect.fileName, contentHash, effect.remoteModifiedAt) } + if (pendingRenameConfirmation) { + hashTracker.forget(pendingRenameConfirmation.oldFileName) + fileMetadataCache.recordDelete(pendingRenameConfirmation.oldFileName) + hashTracker.remember(effect.fileName, pendingRenameConfirmation.content) + pendingRenameConfirmations.delete(normalizeCodeFileName(effect.fileName)) + } + return [] } @@ -903,6 +950,47 @@ async function executeEffect( return [] } + case "SEND_FILE_RENAME": { + const normalizedNewFileName = normalizeCodeFileName(effect.newFileName) + const isEchoedRename = + hashTracker.shouldSkip(normalizedNewFileName, effect.content) && + hashTracker.shouldSkipDelete(effect.oldFileName) + + if (isEchoedRename) { + hashTracker.forget(normalizedNewFileName) + hashTracker.clearDelete(effect.oldFileName) + debug(`Skipping echoed rename ${effect.oldFileName} -> ${effect.newFileName}`) + return [] + } + + try { + if (!syncState.socket) { + warn(`No socket available to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + return [] + } + + const sent = await sendMessage(syncState.socket, { + type: "file-rename", + oldFileName: effect.oldFileName, + newFileName: normalizedNewFileName, + content: effect.content, + }) + if (!sent) { + warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + return [] + } + + pendingRenameConfirmations.set(normalizeCodeFileName(effect.newFileName), { + oldFileName: effect.oldFileName, + content: effect.content, + }) + } catch (err) { + warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + } + + return [] + } + case "LOCAL_INITIATED_FILE_DELETE": { // Echo prevention: filter out remote-initiated deletes const filesToDelete = effect.fileNames.filter(fileName => { @@ -1013,6 +1101,7 @@ export async function start(config: Config): Promise { const hashTracker = createHashTracker() const fileMetadataCache = new FileMetadataCache() + const pendingRenameConfirmations = new Map() let installer: Installer | null = null // State machine state @@ -1052,6 +1141,7 @@ export async function start(config: Config): Promise { hashTracker, installer, fileMetadataCache, + pendingRenameConfirmations, userActions, syncState, }) @@ -1088,6 +1178,7 @@ export async function start(config: Config): Promise { return } debug(`New handshake received in ${syncState.mode} mode, resetting sync state`) + pendingRenameConfirmations.clear() await processEvent({ type: "DISCONNECT" }) } @@ -1208,6 +1299,13 @@ export async function start(config: Config): Promise { } break + case "error": + if (message.fileName) { + pendingRenameConfirmations.delete(normalizeCodeFileName(message.fileName)) + } + warn(message.message) + return + case "conflicts-resolved": event = { type: "CONFLICTS_RESOLVED", @@ -1250,6 +1348,7 @@ export async function start(config: Config): Promise { status("Disconnected, waiting to reconnect...") }) void (async () => { + pendingRenameConfirmations.clear() await processEvent({ type: "DISCONNECT" }) userActions.cleanup() })() @@ -1287,4 +1386,4 @@ export async function start(config: Config): Promise { } // Export for testing -export { transition } +export { executeEffect, transition } diff --git a/packages/code-link-cli/src/helpers/files.test.ts b/packages/code-link-cli/src/helpers/files.test.ts index 169c08c36..9c3c6258d 100644 --- a/packages/code-link-cli/src/helpers/files.test.ts +++ b/packages/code-link-cli/src/helpers/files.test.ts @@ -9,11 +9,11 @@ import { autoResolveConflicts, DEFAULT_REMOTE_DRIFT_MS, detectConflicts } from " function makeConflict(overrides: Partial = {}): Conflict { return { fileName: overrides.fileName ?? "Test.tsx", - localContent: "localContent" in overrides ? overrides.localContent : "local", - remoteContent: "remoteContent" in overrides ? overrides.remoteContent : "remote", + localContent: Object.hasOwn(overrides, "localContent") ? overrides.localContent ?? null : "local", + remoteContent: Object.hasOwn(overrides, "remoteContent") ? overrides.remoteContent ?? null : "remote", localModifiedAt: overrides.localModifiedAt ?? Date.now(), remoteModifiedAt: overrides.remoteModifiedAt ?? Date.now(), - lastSyncedAt: "lastSyncedAt" in overrides ? overrides.lastSyncedAt : Date.now(), + lastSyncedAt: Object.hasOwn(overrides, "lastSyncedAt") ? overrides.lastSyncedAt : Date.now(), localClean: overrides.localClean, } } diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts index f577188e2..2d67f8fee 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -41,7 +41,6 @@ const REACT_DOM_TYPES_VERSION = "18.3.1" const CORE_LIBRARIES = ["framer-motion", "framer"] const JSON_EXTENSION_REGEX = /\.json$/i - /** * Packages that are officially supported for type acquisition. * Use --unsupported-npm flag to allow other packages. @@ -228,7 +227,8 @@ export class Installer { try { await this.ata(filteredContent) } catch (err) { - warn(`ATA failed for ${fileName}`, err as Error) + warn(`Type fetching failed for ${fileName}`) + debug(`ATA error for ${fileName}:`, err) } } @@ -588,12 +588,13 @@ async function fetchWithRetry( if (attempt < retries && isRetryable) { const delay = attempt * 1_000 - warn(`Fetch failed (${error.cause?.code ?? error.message}) for ${urlString}, retrying in ${delay}ms...`) + debug(`Fetch failed for ${urlString}, retrying...`, error) await new Promise(resolve => setTimeout(resolve, delay)) continue } - warn(`Fetch failed for ${urlString}`, error) + warn(`Fetch failed for ${urlString}`) + debug(`Fetch error details:`, error) throw error } } diff --git a/packages/code-link-cli/src/helpers/watcher.test.ts b/packages/code-link-cli/src/helpers/watcher.test.ts index 98ad342e4..64f5bd053 100644 --- a/packages/code-link-cli/src/helpers/watcher.test.ts +++ b/packages/code-link-cli/src/helpers/watcher.test.ts @@ -46,9 +46,13 @@ vi.mock("chokidar", () => { return { default: { watch }, watch } }) +/** Wait for the rename buffer to expire */ +const waitForBuffer = () => new Promise(resolve => setTimeout(resolve, 150)) + describe("initWatcher", () => { afterEach(() => { createdWatchers.length = 0 + vi.useRealTimers() }) it("ignores unsupported extensions and sanitizes added files", async () => { @@ -68,6 +72,9 @@ describe("initWatcher", () => { await fs.writeFile(rawPath, "export const X = 1;", "utf-8") await rawWatcher.__emit("add", rawPath) + // Adds are buffered for rename detection, wait for buffer to expire + await waitForBuffer() + const addEvent = events.find(e => e.kind === "add") expect(addEvent).toBeDefined() expect(addEvent?.relativePath).toBe("bad_name_.tsx") @@ -79,4 +86,583 @@ describe("initWatcher", () => { await watcher.close() await fs.rm(tmpDir, { recursive: true, force: true }) }) + + it("falls back to the raw path when sanitization rename fails", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const rawPath = path.join(tmpDir, "bad name!.tsx") + await fs.writeFile(rawPath, "export const X = 1;", "utf-8") + + const renameSpy = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("rename failed")) + await rawWatcher.__emit("add", rawPath) + + await waitForBuffer() + + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("add") + expect(events[0].relativePath).toBe("bad name!.tsx") + await expect(fs.readFile(rawPath, "utf-8")).resolves.toContain("export const X") + + renameSpy.mockRestore() + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("keeps using the raw path after sanitization rename fails", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const rawPath = path.join(tmpDir, "bad name!.tsx") + await fs.writeFile(rawPath, "export const Version = 1", "utf-8") + + const renameSpy = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("rename failed")) + await rawWatcher.__emit("add", rawPath) + await waitForBuffer() + + await fs.writeFile(rawPath, "export const Version = 2", "utf-8") + await rawWatcher.__emit("change", rawPath) + + const renamedPath = path.join(tmpDir, "GoodName.tsx") + await fs.rename(rawPath, renamedPath) + await rawWatcher.__emit("unlink", rawPath) + await rawWatcher.__emit("add", renamedPath) + + expect(events).toEqual([ + { + kind: "add", + relativePath: "bad name!.tsx", + content: "export const Version = 1", + }, + { + kind: "change", + relativePath: "bad name!.tsx", + content: "export const Version = 2", + }, + { + kind: "rename", + relativePath: "GoodName.tsx", + oldRelativePath: "bad name!.tsx", + content: "export const Version = 2", + }, + ]) + + renameSpy.mockRestore() + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) +}) + +describe("rename detection", () => { + afterEach(() => { + createdWatchers.length = 0 + vi.useRealTimers() + }) + + it("detects rename when unlink arrives before add", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const content = "export const Component = () => null;" + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + // Write original file and emit add to populate hash cache + const originalPath = path.join(tmpDir, "OldName.tsx") + await fs.writeFile(originalPath, content, "utf-8") + await rawWatcher.__emit("add", originalPath) + await waitForBuffer() + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("add") + events.length = 0 + + // Simulate rename: unlink old, then add new with same content + await fs.unlink(originalPath) + const newPath = path.join(tmpDir, "NewName.tsx") + await fs.writeFile(newPath, content, "utf-8") + await rawWatcher.__emit("unlink", originalPath) + await rawWatcher.__emit("add", newPath) + + // Should get a single rename event (matched immediately, no buffer wait needed) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("rename") + expect(events[0].relativePath).toBe("NewName.tsx") + expect(events[0].oldRelativePath).toBe("OldName.tsx") + expect(events[0].content).toBe(content) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("detects rename when add arrives before unlink", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const content = "export const Component = () => null;" + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + // Write original file and emit add to populate hash cache + const originalPath = path.join(tmpDir, "OldName.tsx") + await fs.writeFile(originalPath, content, "utf-8") + await rawWatcher.__emit("add", originalPath) + await waitForBuffer() + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("add") + events.length = 0 + + // Simulate rename: add new first, then unlink old + const newPath = path.join(tmpDir, "NewName.tsx") + await fs.writeFile(newPath, content, "utf-8") + await rawWatcher.__emit("add", newPath) + await fs.unlink(originalPath) + await rawWatcher.__emit("unlink", originalPath) + + // Should get a single rename event + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("rename") + expect(events[0].relativePath).toBe("NewName.tsx") + expect(events[0].oldRelativePath).toBe("OldName.tsx") + expect(events[0].content).toBe(content) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("emits change when unlink and add target the same path with identical content", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const content = "export const Component = () => null;" + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const filePath = path.join(tmpDir, "Component.tsx") + await fs.writeFile(filePath, content, "utf-8") + await rawWatcher.__emit("add", filePath) + await waitForBuffer() + events.length = 0 + + await fs.unlink(filePath) + await rawWatcher.__emit("unlink", filePath) + await fs.writeFile(filePath, content, "utf-8") + await rawWatcher.__emit("add", filePath) + + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + kind: "change", + relativePath: "Component.tsx", + content, + }) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("emits change when unlink and add target the same path with different content", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const filePath = path.join(tmpDir, "Component.tsx") + await fs.writeFile(filePath, "export const Version = 1", "utf-8") + await rawWatcher.__emit("add", filePath) + await waitForBuffer() + events.length = 0 + + await fs.unlink(filePath) + await rawWatcher.__emit("unlink", filePath) + await fs.writeFile(filePath, "export const Version = 2", "utf-8") + await rawWatcher.__emit("add", filePath) + + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + kind: "change", + relativePath: "Component.tsx", + content: "export const Version = 2", + }) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("emits change when add arrives before unlink for the same path and the file still exists", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const filePath = path.join(tmpDir, "Component.tsx") + await fs.writeFile(filePath, "export const Version = 1", "utf-8") + await rawWatcher.__emit("add", filePath) + await waitForBuffer() + events.length = 0 + + await fs.writeFile(filePath, "export const Version = 2", "utf-8") + await rawWatcher.__emit("add", filePath) + await rawWatcher.__emit("unlink", filePath) + + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + kind: "change", + relativePath: "Component.tsx", + content: "export const Version = 2", + }) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("suppresses transient add followed by unlink for a new file on the same path", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const filePath = path.join(tmpDir, "Component.tsx") + await fs.writeFile(filePath, "export const Temp = 1", "utf-8") + await rawWatcher.__emit("add", filePath) + await fs.unlink(filePath) + await rawWatcher.__emit("unlink", filePath) + + expect(events).toHaveLength(0) + + await waitForBuffer() + expect(events).toHaveLength(0) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("falls back to add and delete when delete sees multiple matching adds", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const content = "export const Component = () => null;" + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const originalPath = path.join(tmpDir, "OldName.tsx") + await fs.writeFile(originalPath, content, "utf-8") + await rawWatcher.__emit("add", originalPath) + await waitForBuffer() + events.length = 0 + + const newPathA = path.join(tmpDir, "NewNameA.tsx") + const newPathB = path.join(tmpDir, "NewNameB.tsx") + await fs.writeFile(newPathA, content, "utf-8") + await fs.writeFile(newPathB, content, "utf-8") + await rawWatcher.__emit("add", newPathA) + await rawWatcher.__emit("add", newPathB) + + await fs.unlink(originalPath) + await rawWatcher.__emit("unlink", originalPath) + + expect(events).toHaveLength(0) + + await waitForBuffer() + + expect(events).toHaveLength(3) + expect(events.some(event => event.kind === "rename")).toBe(false) + expect(events).toEqual( + expect.arrayContaining([ + { kind: "add", relativePath: "NewNameA.tsx", content }, + { kind: "add", relativePath: "NewNameB.tsx", content }, + { kind: "delete", relativePath: "OldName.tsx" }, + ]) + ) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("falls back to add and delete when add sees multiple matching deletes", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const content = "export const Component = () => null;" + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const originalPathA = path.join(tmpDir, "OldNameA.tsx") + const originalPathB = path.join(tmpDir, "OldNameB.tsx") + await fs.writeFile(originalPathA, content, "utf-8") + await fs.writeFile(originalPathB, content, "utf-8") + await rawWatcher.__emit("add", originalPathA) + await rawWatcher.__emit("add", originalPathB) + await waitForBuffer() + events.length = 0 + + await fs.unlink(originalPathA) + await fs.unlink(originalPathB) + await rawWatcher.__emit("unlink", originalPathA) + await rawWatcher.__emit("unlink", originalPathB) + + const newPath = path.join(tmpDir, "NewName.tsx") + await fs.writeFile(newPath, content, "utf-8") + await rawWatcher.__emit("add", newPath) + + expect(events).toHaveLength(0) + + await waitForBuffer() + + expect(events).toHaveLength(3) + expect(events.some(event => event.kind === "rename")).toBe(false) + expect(events).toEqual( + expect.arrayContaining([ + { kind: "delete", relativePath: "OldNameA.tsx" }, + { kind: "delete", relativePath: "OldNameB.tsx" }, + { kind: "add", relativePath: "NewName.tsx", content }, + ]) + ) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("emits normal delete when no matching add arrives within buffer", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const content = "export const X = 1;" + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + // Write and add to populate hash cache + const filePath = path.join(tmpDir, "Component.tsx") + await fs.writeFile(filePath, content, "utf-8") + await rawWatcher.__emit("add", filePath) + await waitForBuffer() + events.length = 0 + + // Unlink without a matching add + await fs.unlink(filePath) + await rawWatcher.__emit("unlink", filePath) + + // No event yet (buffered) + expect(events).toHaveLength(0) + + // Wait for the buffer timeout to expire + await waitForBuffer() + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("delete") + expect(events[0].relativePath).toBe("Component.tsx") + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("replaces a buffered add on the same path without leaking the old timer", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const filePath = path.join(tmpDir, "Component.tsx") + await fs.writeFile(filePath, "export const Version = 1", "utf-8") + await rawWatcher.__emit("add", filePath) + + await fs.writeFile(filePath, "export const Version = 2", "utf-8") + await rawWatcher.__emit("add", filePath) + + expect(events).toHaveLength(0) + + await waitForBuffer() + + expect(events).toEqual([ + { + kind: "add", + relativePath: "Component.tsx", + content: "export const Version = 2", + }, + ]) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("suppresses delete when a buffered new-file add is replaced on the same path", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const filePath = path.join(tmpDir, "Component.tsx") + await fs.writeFile(filePath, "export const Version = 1", "utf-8") + await rawWatcher.__emit("add", filePath) + + await fs.writeFile(filePath, "export const Version = 2", "utf-8") + await rawWatcher.__emit("add", filePath) + + await fs.unlink(filePath) + await rawWatcher.__emit("unlink", filePath) + + expect(events).toHaveLength(0) + + await waitForBuffer() + + expect(events).toHaveLength(0) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("emits add and delete separately when content differs", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + // Write and add original file + const originalPath = path.join(tmpDir, "Old.tsx") + await fs.writeFile(originalPath, "original content", "utf-8") + await rawWatcher.__emit("add", originalPath) + await waitForBuffer() + events.length = 0 + + // Unlink old file + await fs.unlink(originalPath) + await rawWatcher.__emit("unlink", originalPath) + + // Add a different file with different content + const newPath = path.join(tmpDir, "New.tsx") + await fs.writeFile(newPath, "completely different content", "utf-8") + await rawWatcher.__emit("add", newPath) + + // Both are buffered; no events yet + expect(events).toHaveLength(0) + + // Wait for buffer timeout — both should fire as separate events + await waitForBuffer() + expect(events).toHaveLength(2) + const kinds = events.map(e => e.kind).sort() + expect(kinds).toEqual(["add", "delete"]) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("suppresses echo events from sanitization rename", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const content = "export const X = 1;" + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + // Create a file with a name that needs sanitizing + const rawPath = path.join(tmpDir, "bad name!.tsx") + await fs.writeFile(rawPath, content, "utf-8") + await rawWatcher.__emit("add", rawPath) + + // Simulate the echo events chokidar would fire after sanitization rename + const sanitizedPath = path.join(tmpDir, "bad_name_.tsx") + await rawWatcher.__emit("unlink", rawPath) // echo unlink for old path + await rawWatcher.__emit("add", sanitizedPath) // echo add for new path + + await waitForBuffer() + + // Should only have the original add, no echoes + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("add") + expect(events[0].relativePath).toBe("bad_name_.tsx") + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("suppresses echo events from folder sanitization", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const content = "export const Y = 2;" + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + // Create a file in a folder with spaces (needs sanitizing) + const badFolderPath = path.join(tmpDir, "My Folder", "Component.tsx") + await fs.mkdir(path.join(tmpDir, "My Folder"), { recursive: true }) + await fs.writeFile(badFolderPath, content, "utf-8") + await rawWatcher.__emit("add", badFolderPath) + + // Simulate echo events from folder sanitization rename + const sanitizedFolderPath = path.join(tmpDir, "My_Folder", "Component.tsx") + await rawWatcher.__emit("unlink", badFolderPath) // echo unlink for old folder path + await rawWatcher.__emit("add", sanitizedFolderPath) // echo add for sanitized folder path + + await waitForBuffer() + + // Should only have the original add + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("add") + expect(events[0].relativePath).toBe("My_Folder/Component.tsx") + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("emits delete immediately when file has no cached hash", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", event => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + // Emit unlink for a file that was never added (no hash cached) + const unknownPath = path.join(tmpDir, "Unknown.tsx") + await rawWatcher.__emit("unlink", unknownPath) + + // Delete should fire immediately (no buffering) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe("delete") + expect(events[0].relativePath).toBe("Unknown.tsx") + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) }) diff --git a/packages/code-link-cli/src/helpers/watcher.ts b/packages/code-link-cli/src/helpers/watcher.ts index 9c39c582e..77c83a5d7 100644 --- a/packages/code-link-cli/src/helpers/watcher.ts +++ b/packages/code-link-cli/src/helpers/watcher.ts @@ -11,18 +11,110 @@ import path from "path" import type { WatcherEvent } from "../types.ts" import { debug, warn } from "../utils/logging.ts" import { getRelativePath } from "../utils/node-paths.ts" +import { hashFileContent } from "../utils/state-persistence.ts" export interface Watcher { on(event: "change", handler: (event: WatcherEvent) => void): void close(): Promise } +const RENAME_BUFFER_MS = 100 + +interface PendingDelete { + relativePath: string + contentHash: string + timer: ReturnType +} + +interface PendingAdd { + relativePath: string + contentHash: string + content: string + timer: ReturnType + previousContentHash?: string +} + +function findUniqueHashMatch( + pendingItems: Map, + contentHash: string +): string | undefined { + let matchingKey: string | undefined + + for (const [key, pending] of pendingItems) { + if (pending.contentHash !== contentHash) { + continue + } + + if (matchingKey !== undefined) { + return undefined + } + + matchingKey = key + } + + return matchingKey +} + +function matchPendingAddForDelete( + contentHash: string | undefined, + pendingAdds: Map +): { key: string; pendingAdd: PendingAdd } | null { + if (!contentHash) { + return null + } + + const matchingAddKey = findUniqueHashMatch(pendingAdds, contentHash) + if (!matchingAddKey) { + return null + } + + const pendingAdd = pendingAdds.get(matchingAddKey) + if (!pendingAdd) { + return null + } + + return { + key: matchingAddKey, + pendingAdd, + } +} + +function matchPendingDeleteForAdd( + contentHash: string, + pendingDeletes: Map +): { key: string; pendingDelete: PendingDelete } | null { + const matchingDeleteKey = findUniqueHashMatch(pendingDeletes, contentHash) + if (!matchingDeleteKey) { + return null + } + + const pendingDelete = pendingDeletes.get(matchingDeleteKey) + if (!pendingDelete) { + return null + } + + return { + key: matchingDeleteKey, + pendingDelete, + } +} + /** * Initializes a file watcher for the given directory */ export function initWatcher(filesDir: string): Watcher { const handlers: ((event: WatcherEvent) => void)[] = [] + // Content hash cache: tracks last-known hash for rename detection + const contentHashCache = new Map() + + // Pending deletes/adds awaiting potential rename matching (keyed by relativePath) + const pendingDeletes = new Map() + const pendingAdds = new Map() + + // Paths recently renamed by sanitization — used to suppress echo events from chokidar + const recentSanitizations = new Set() + const watcher = chokidar.watch(filesDir, { ignored: /(^|[/\\])\.\./, // ignore dotfiles persistent: true, @@ -31,54 +123,215 @@ export function initWatcher(filesDir: string): Watcher { debug(`Watching directory: ${filesDir}`) - // Helper to emit normalized events - const emitEvent = async (kind: "add" | "change" | "delete", absolutePath: string): Promise => { + // Watcher contract: + // - rename-to-self is treated as a content change + // - only unique hash matches become rename events + // - sanitize-on-add echo events are filtered before normal processing + const dispatchEvent = (event: WatcherEvent): void => { + let eventToDispatch = event + + if (event.kind === "rename" && event.relativePath === event.oldRelativePath) { + if (event.content === undefined) { + warn(`Skipping invalid same-path rename without content: ${event.relativePath}`) + return + } + + debug(`Converting same-path rename to change: ${event.relativePath}`) + eventToDispatch = { + kind: "change", + relativePath: event.relativePath, + content: event.content, + } + } + + debug(`Watcher event: ${eventToDispatch.kind} ${eventToDispatch.relativePath}`) + for (const handler of handlers) { + handler(eventToDispatch) + } + } + + /** + * Resolves the relative path identity for a watcher event. + * Only "add" may rewrite that identity by successfully sanitizing on disk. + */ + const resolveRelativePath = async ( + kind: "add" | "change" | "delete", + absolutePath: string + ): Promise<{ relativePath: string; effectiveAbsolutePath: string } | null> => { if (!isSupportedExtension(absolutePath)) { - return + return null } const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath)) - // Don't capitalize - preserve exact file names as they exist - // This ensures 1:1 sync with Framer without modifying user's casing choices - const sanitized = sanitizeFilePath(rawRelativePath, false) - const relativePath = sanitized.path + let relativePath = rawRelativePath - // If the user created a file that doesn't match our sanitization rules, - // rename it on disk to match what can be synced. let effectiveAbsolutePath = absolutePath - if (relativePath !== rawRelativePath && kind === "add") { - const newAbsolutePath = path.join(filesDir, relativePath) - try { - await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true }) - await fs.rename(absolutePath, newAbsolutePath) - debug(`Renamed ${rawRelativePath} -> ${relativePath}`) - effectiveAbsolutePath = newAbsolutePath - } catch (err) { - warn(`Failed to rename ${rawRelativePath}`, err) + if (kind === "add") { + const sanitized = sanitizeFilePath(rawRelativePath, false) + if (sanitized.path !== rawRelativePath) { + const nextRelativePath = sanitized.path + const newAbsolutePath = path.join(filesDir, nextRelativePath) + try { + await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true }) + await fs.rename(absolutePath, newAbsolutePath) + debug(`Renamed ${rawRelativePath} -> ${nextRelativePath}`) + relativePath = nextRelativePath + effectiveAbsolutePath = newAbsolutePath + + // Suppress the echo events chokidar will fire for this rename + recentSanitizations.add(rawRelativePath) // upcoming unlink echo + recentSanitizations.add(nextRelativePath) // upcoming add echo + setTimeout(() => { + recentSanitizations.delete(rawRelativePath) + recentSanitizations.delete(nextRelativePath) + }, RENAME_BUFFER_MS * 3) + } catch (err) { + warn(`Failed to rename ${rawRelativePath}`, err) + return { relativePath: rawRelativePath, effectiveAbsolutePath: absolutePath } + } } } - let content: string | undefined - if (kind !== "delete") { - try { - content = await fs.readFile(effectiveAbsolutePath, "utf-8") - } catch (err) { - debug(`Failed to read file ${relativePath}:`, err) + return { relativePath, effectiveAbsolutePath } + } + + // Helper to emit normalized events + const emitEvent = async (kind: "add" | "change" | "delete", absolutePath: string): Promise => { + // Suppress echo events caused by sanitization renames + const rawRelPath = normalizePath(getRelativePath(filesDir, absolutePath)) + if (recentSanitizations.delete(rawRelPath)) { + debug(`Suppressing sanitization echo: ${kind} ${rawRelPath}`) + return + } + + const resolved = await resolveRelativePath(kind, absolutePath) + if (!resolved) return + const { relativePath, effectiveAbsolutePath } = resolved + + if (kind === "delete") { + const lastHash = contentHashCache.get(relativePath) + contentHashCache.delete(relativePath) + + const samePathPendingAdd = pendingAdds.get(relativePath) + if (samePathPendingAdd) { + clearTimeout(samePathPendingAdd.timer) + pendingAdds.delete(relativePath) + + try { + const latestContent = await fs.readFile(effectiveAbsolutePath, "utf-8") + const latestHash = hashFileContent(latestContent) + contentHashCache.set(relativePath, latestHash) + dispatchEvent({ kind: "change", relativePath, content: latestContent }) + } catch { + if (samePathPendingAdd.previousContentHash !== undefined) { + dispatchEvent({ kind: "delete", relativePath }) + } else { + debug(`Suppressing transient add+delete: ${relativePath}`) + } + } return } + + const matchedAdd = matchPendingAddForDelete(lastHash, pendingAdds) + if (matchedAdd) { + clearTimeout(matchedAdd.pendingAdd.timer) + pendingAdds.delete(matchedAdd.key) + + // Emit as a single rename event + dispatchEvent({ + kind: "rename", + relativePath: matchedAdd.pendingAdd.relativePath, + oldRelativePath: relativePath, + content: matchedAdd.pendingAdd.content, + }) + return + } + + if (lastHash) { + // No pending add match — buffer this delete + const timer = setTimeout(() => { + pendingDeletes.delete(relativePath) + dispatchEvent({ kind: "delete", relativePath }) + }, RENAME_BUFFER_MS) + + pendingDeletes.set(relativePath, { relativePath, contentHash: lastHash, timer }) + } else { + // No cached hash — emit delete immediately + dispatchEvent({ kind: "delete", relativePath }) + } + return } - const event: WatcherEvent = { - kind, - relativePath, - content, + // For add/change, read file content + let content: string + try { + content = await fs.readFile(effectiveAbsolutePath, "utf-8") + } catch (err) { + debug(`Failed to read file ${relativePath}:`, err) + return } - debug(`Watcher event: ${kind} ${relativePath}`) + const previousContentHash = contentHashCache.get(relativePath) - for (const handler of handlers) { - handler(event) + // Update content hash cache + const contentHash = hashFileContent(content) + contentHashCache.set(relativePath, contentHash) + + if (kind === "add") { + const samePathPendingDelete = pendingDeletes.get(relativePath) + if (samePathPendingDelete) { + clearTimeout(samePathPendingDelete.timer) + pendingDeletes.delete(relativePath) + dispatchEvent({ kind: "change", relativePath, content }) + return + } + + const matchedDelete = matchPendingDeleteForAdd(contentHash, pendingDeletes) + if (matchedDelete) { + clearTimeout(matchedDelete.pendingDelete.timer) + pendingDeletes.delete(matchedDelete.key) + + // Emit as a single rename event + dispatchEvent({ + kind: "rename", + relativePath, + oldRelativePath: matchedDelete.pendingDelete.relativePath, + content, + }) + return + } + + // No pending delete match — buffer this add in case a delete arrives soon + const existingPendingAdd = pendingAdds.get(relativePath) + if (existingPendingAdd) { + clearTimeout(existingPendingAdd.timer) + } + const retainedPreviousContentHash = existingPendingAdd ? existingPendingAdd.previousContentHash : previousContentHash + const timer = setTimeout(() => { + pendingAdds.delete(relativePath) + dispatchEvent({ kind: "add", relativePath, content }) + }, RENAME_BUFFER_MS) + + pendingAdds.set(relativePath, { + relativePath, + contentHash, + content, + timer, + previousContentHash: retainedPreviousContentHash, + }) + return + } + + // If this file has a buffered add, cancel it and dispatch as "add" with fresh content + const pendingAdd = pendingAdds.get(relativePath) + if (pendingAdd) { + clearTimeout(pendingAdd.timer) + pendingAdds.delete(relativePath) + dispatchEvent({ kind: "add", relativePath, content }) + return } + + dispatchEvent({ kind, relativePath, content }) } watcher.on("add", filePath => { @@ -97,6 +350,16 @@ export function initWatcher(filesDir: string): Watcher { }, async close(): Promise { + for (const pending of pendingDeletes.values()) { + clearTimeout(pending.timer) + } + for (const pending of pendingAdds.values()) { + clearTimeout(pending.timer) + } + pendingDeletes.clear() + pendingAdds.clear() + contentHashCache.clear() + recentSanitizations.clear() await watcher.close() }, } diff --git a/packages/code-link-cli/src/types.ts b/packages/code-link-cli/src/types.ts index 92e967add..1875a318e 100644 --- a/packages/code-link-cli/src/types.ts +++ b/packages/code-link-cli/src/types.ts @@ -60,10 +60,12 @@ export interface ConflictResolution { } // Watcher events (CLI-specific) -export type WatcherEventKind = "add" | "change" | "delete" +export type WatcherEventKind = "add" | "change" | "delete" | "rename" export interface WatcherEvent { kind: WatcherEventKind relativePath: string content?: string + /** The original path before rename. Only set when kind === "rename". */ + oldRelativePath?: string } diff --git a/packages/code-link-cli/src/utils/hash-tracker.ts b/packages/code-link-cli/src/utils/hash-tracker.ts index 108da5eb3..b10517bb9 100644 --- a/packages/code-link-cli/src/utils/hash-tracker.ts +++ b/packages/code-link-cli/src/utils/hash-tracker.ts @@ -5,7 +5,8 @@ * and skipping watcher events for files we just wrote. */ -import { createHash } from "crypto" +import { normalizeCodeFileName } from "@code-link/shared" +import { hashFileContent } from "./state-persistence.ts" export interface HashTracker { remember(filePath: string, content: string): void @@ -24,20 +25,22 @@ export function createHashTracker(): HashTracker { const hashes = new Map() const pendingDeletes = new Map>() + const keyFor = (filePath: string) => normalizeCodeFileName(filePath) + return { remember(filePath: string, content: string): void { - const hash = hashContent(content) - hashes.set(filePath, hash) + const hash = hashFileContent(content) + hashes.set(keyFor(filePath), hash) }, shouldSkip(filePath: string, content: string): boolean { - const currentHash = hashContent(content) - const storedHash = hashes.get(filePath) + const currentHash = hashFileContent(content) + const storedHash = hashes.get(keyFor(filePath)) return storedHash === currentHash }, forget(filePath: string): void { - hashes.delete(filePath) + hashes.delete(keyFor(filePath)) }, clear(): void { @@ -45,35 +48,30 @@ export function createHashTracker(): HashTracker { }, markDelete(filePath: string): void { - const existingTimer = pendingDeletes.get(filePath) + const key = keyFor(filePath) + const existingTimer = pendingDeletes.get(key) if (existingTimer) { clearTimeout(existingTimer) } const timeout = setTimeout(() => { - pendingDeletes.delete(filePath) + pendingDeletes.delete(key) }, 5000) - pendingDeletes.set(filePath, timeout) + pendingDeletes.set(key, timeout) }, shouldSkipDelete(filePath: string): boolean { - return pendingDeletes.has(filePath) + return pendingDeletes.has(keyFor(filePath)) }, clearDelete(filePath: string): void { - const timeout = pendingDeletes.get(filePath) + const key = keyFor(filePath) + const timeout = pendingDeletes.get(key) if (timeout) { clearTimeout(timeout) } - pendingDeletes.delete(filePath) + pendingDeletes.delete(key) }, } } - -/** - * Computes a SHA256 hash of file content for comparison - */ -function hashContent(content: string): string { - return createHash("sha256").update(content).digest("hex") -} diff --git a/packages/code-link-cli/src/utils/project.test.ts b/packages/code-link-cli/src/utils/project.test.ts index da14f0acb..6e4a98696 100644 --- a/packages/code-link-cli/src/utils/project.test.ts +++ b/packages/code-link-cli/src/utils/project.test.ts @@ -16,6 +16,12 @@ describe("toDirectoryName", () => { it("replaces invalid chars preserving case and spaces", () => { expect(toDirectoryName("My Project")).toBe("My Project") expect(toDirectoryName("Hello World!")).toBe("Hello World") + expect(toDirectoryName("My-Project 2026!")).toBe("My-Project 2026") + }) + + it("removes boundary hyphens revealed by trimming", () => { + expect(toDirectoryName(" -Project")).toBe("Project") + expect(toDirectoryName("Project- ")).toBe("Project") }) }) diff --git a/packages/code-link-cli/src/utils/project.ts b/packages/code-link-cli/src/utils/project.ts index 42b103372..29313569b 100644 --- a/packages/code-link-cli/src/utils/project.ts +++ b/packages/code-link-cli/src/utils/project.ts @@ -20,7 +20,8 @@ export function toPackageName(name: string): string { export function toDirectoryName(name: string): string { return name - .replace(/[^a-zA-Z0-9-]/g, "-") + .replace(/[^a-zA-Z0-9 -]/g, "-") + .trim() .replace(/^-+|-+$/g, "") .replace(/-+/g, "-") } diff --git a/packages/code-link-cli/src/utils/state-persistence.ts b/packages/code-link-cli/src/utils/state-persistence.ts index f90f42f6f..d05257e58 100644 --- a/packages/code-link-cli/src/utils/state-persistence.ts +++ b/packages/code-link-cli/src/utils/state-persistence.ts @@ -6,12 +6,11 @@ * (hash matches), because that means the file wasn't edited while CLI was offline. */ -import { pluralize } from "@code-link/shared" +import { fileKeyForLookup, normalizeCodeFileName, pluralize } from "@code-link/shared" import { createHash } from "crypto" import fs from "fs/promises" import path from "path" import { debug, warn } from "./logging.ts" -import { normalizePath } from "./node-paths.ts" export interface PersistedFileState { timestamp: number // Remote modified timestamp from last sync @@ -25,15 +24,9 @@ interface PersistedState { const STATE_FILE_NAME = ".framer-sync-state.json" const CURRENT_VERSION = 1 -const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"] -const DEFAULT_EXTENSION = ".tsx" -export function normalizePersistedFileName(fileName: string): string { - let normalized = normalizePath(fileName.trim()) - if (!SUPPORTED_EXTENSIONS.some(ext => normalized.toLowerCase().endsWith(ext))) { - normalized = `${normalized}${DEFAULT_EXTENSION}` - } - return normalized +function persistedFileKey(fileName: string): string { + return fileKeyForLookup(normalizeCodeFileName(fileName)) } /** @@ -62,7 +55,7 @@ export async function loadPersistedState(projectDir: string): Promise "${normalizedName}" for compatibility`) } diff --git a/packages/code-link-shared/src/index.ts b/packages/code-link-shared/src/index.ts index a97f75b6f..dd852586b 100644 --- a/packages/code-link-shared/src/index.ts +++ b/packages/code-link-shared/src/index.ts @@ -7,6 +7,7 @@ export { ensureExtension, fileKeyForLookup, isSupportedExtension, + normalizeCodeFileName, normalizeCodeFilePath, normalizePath, pluralize, diff --git a/packages/code-link-shared/src/paths.test.ts b/packages/code-link-shared/src/paths.test.ts index 8c2b3178d..6fe5aaf49 100644 --- a/packages/code-link-shared/src/paths.test.ts +++ b/packages/code-link-shared/src/paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { ensureExtension, isSupportedExtension, normalizePath, sanitizeFilePath } from "./paths.ts" +import { ensureExtension, isSupportedExtension, normalizeCodeFileName, normalizePath, sanitizeFilePath } from "./paths.ts" describe("File Name Sanitization", () => { describe("sanitizeFilePath", () => { @@ -143,4 +143,14 @@ describe("File Name Sanitization", () => { expect(ensureExtension("utils", ".ts")).toBe("utils.ts") }) }) + + describe("normalizeCodeFileName", () => { + it("normalizes folder paths and adds the default extension", () => { + expect(normalizeCodeFileName("./components/../components/Button")).toBe("components/Button.tsx") + }) + + it("preserves existing supported extensions", () => { + expect(normalizeCodeFileName("utils/file.ts")).toBe("utils/file.ts") + }) + }) }) diff --git a/packages/code-link-shared/src/paths.ts b/packages/code-link-shared/src/paths.ts index 8e3ddaf67..f2979499a 100644 --- a/packages/code-link-shared/src/paths.ts +++ b/packages/code-link-shared/src/paths.ts @@ -141,6 +141,11 @@ export function canonicalFileName(filePath: string): string { return normalizeCodeFilePath(filePath) } +/** Normalized code-file name with default extension. */ +export function normalizeCodeFileName(filePath: string): string { + return canonicalFileName(ensureExtension(filePath)) +} + export function sanitizeFilePath(input: string, capitalizeReactComponent = true): SanitizedNameResult { const trimmed = input.trim() const [inputName, extension] = splitExtension(filename(trimmed)) diff --git a/packages/code-link-shared/src/types.ts b/packages/code-link-shared/src/types.ts index f2dc21bbc..8222d316d 100644 --- a/packages/code-link-shared/src/types.ts +++ b/packages/code-link-shared/src/types.ts @@ -53,6 +53,7 @@ export type CliToPluginMessage = fileNames: string[] requireConfirmation?: boolean } + | { type: "file-rename"; oldFileName: string; newFileName: string; content: string } | { type: "conflicts-detected"; conflicts: ConflictSummary[] } | { type: "conflict-version-request" @@ -65,6 +66,7 @@ const cliToPluginMessageTypes = [ "file-list", "file-change", "file-delete", + "file-rename", "conflicts-detected", "conflict-version-request", "sync-complete", diff --git a/plugins/code-link/package.json b/plugins/code-link/package.json index d99636f96..7b760e611 100644 --- a/plugins/code-link/package.json +++ b/plugins/code-link/package.json @@ -9,6 +9,7 @@ "check-biome": "run g:check-biome", "check-eslint": "run g:check-eslint", "check-typescript": "run g:check-typescript", + "check-vitest": "run g:check-vitest", "preview": "run g:preview", "pack": "run g:pack" }, diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index 8e99e9bbe..b48c5d629 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -1,5 +1,4 @@ import { - type CliToPluginMessage, type ConflictSummary, createSyncTracker, type Mode, @@ -11,6 +10,7 @@ import { import { framer } from "framer-plugin" import { useCallback, useEffect, useReducer, useRef, useState } from "react" import { CodeFilesAPI } from "./api" +import { createMessageHandler } from "./messages" import { copyToClipboard } from "./utils/clipboard" import { computeLineDiff } from "./utils/diffing" import * as log from "./utils/logger" @@ -106,6 +106,7 @@ export function App() { // Check initial permission state const initialPermissions = framer.isAllowedTo( "createCodeFile", + "CodeFile.rename", "CodeFile.setFileContent", "CodeFile.remove" ) @@ -115,6 +116,7 @@ export function App() { // Subscribe to permission changes unsubscribePermissions = framer.subscribeToIsAllowedTo( "createCodeFile", + "CodeFile.rename", "CodeFile.setFileContent", "CodeFile.remove", granted => { @@ -532,83 +534,3 @@ function ConflictPanel({ conflicts, onResolve }: ConflictPanelProps) { ) } - -function createMessageHandler({ - dispatch, - api, - syncTracker, -}: { - dispatch: (action: Action) => void - api: CodeFilesAPI - syncTracker: SyncTracker -}) { - return async function handleMessage(message: CliToPluginMessage, socket: WebSocket) { - log.debug("Handling message:", message.type) - - switch (message.type) { - case "request-files": - log.debug("Publishing snapshot to CLI") - await api.publishSnapshot(socket) - dispatch({ - type: "set-mode", - mode: "syncing", - }) - break - case "file-change": - log.debug("Applying remote change:", message.fileName) - await api.applyRemoteChange(message.fileName, message.content, socket) - syncTracker.remember(message.fileName, message.content) - dispatch({ type: "set-mode", mode: "idle" }) - break - case "file-delete": - if (message.requireConfirmation) { - log.debug(`Delete requires confirmation for ${message.fileNames.length} file(s)`) - const files: PendingDelete[] = [] - for (const fileName of message.fileNames) { - const content = await api.readCurrentContent(fileName) - // Only include files that exist in Framer (have content to restore) - if (content !== undefined) { - files.push({ fileName, content }) - } - } - if (files.length === 0) { - // No files exist in Framer, nothing to confirm - break - } - dispatch({ - type: "pending-deletes", - files, - }) - } else { - for (const fileName of message.fileNames) { - log.debug("Deleting file:", fileName) - await api.applyRemoteDelete(fileName) - } - } - break - case "conflicts-detected": - log.debug(`Received ${message.conflicts.length} conflicts from CLI`) - dispatch({ type: "conflicts", conflicts: message.conflicts }) - break - case "conflict-version-request": { - log.debug(`Fetching conflict versions for ${message.conflicts.length} files`) - const versions = await api.fetchConflictVersions(message.conflicts) - log.debug(`Sending version response for ${versions.length} files`) - socket.send( - JSON.stringify({ - type: "conflict-version-response", - versions, - }) - ) - break - } - case "sync-complete": - log.debug("Sync complete, transitioning to idle") - dispatch({ type: "set-mode", mode: "idle" }) - break - default: - log.warn("Unknown message type:", (message as unknown as { type: string }).type) - break - } - } -} diff --git a/plugins/code-link/src/api.test.ts b/plugins/code-link/src/api.test.ts new file mode 100644 index 000000000..c4640c90a --- /dev/null +++ b/plugins/code-link/src/api.test.ts @@ -0,0 +1,534 @@ +import type { SyncTracker } from "@code-link/shared" +import { afterEach, describe, expect, it, type Mock, vi } from "vitest" +import { CodeFilesAPI } from "./api" + +const { framerMock } = vi.hoisted(() => ({ + framerMock: { + createCodeFile: vi.fn(), + getCodeFiles: vi.fn(), + showUI: vi.fn(), + }, +})) + +vi.mock("framer-plugin", () => ({ + framer: framerMock, +})) + +vi.mock("./utils/logger", () => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})) + +type SentMessage = + | { type: "file-list"; files: { name: string; content: string }[] } + | { type: "file-change"; fileName: string; content: string } + | { type: "file-delete"; fileNames: string[]; requireConfirmation: boolean } + | { type: "file-synced"; fileName: string; remoteModifiedAt: number } + | { type: "error"; fileName: string; message: string } + +type FileSyncedMessage = Extract + +interface MockSocket { + send: Mock<(payload: string) => void> +} + +function createSocket(): MockSocket { + return { + send: vi.fn(), + } +} + +function parseSentMessage(payload: string): SentMessage { + return JSON.parse(payload) as SentMessage +} + +function getSentMessages(socket: MockSocket): SentMessage[] { + return socket.send.mock.calls.map(([payload]) => parseSentMessage(payload)) +} + +function expectFileSyncedMessage( + message: SentMessage | undefined, + fileName: string +): asserts message is FileSyncedMessage { + expect(message).toMatchObject({ + type: "file-synced", + fileName, + }) + + if (!message || message.type !== "file-synced") { + throw new Error(`Expected file-synced message for ${fileName}`) + } +} + +function createTracker() { + const remember = vi.fn() + const shouldSkip = vi.fn() + const forget = vi.fn() + const clear = vi.fn() + + return { + tracker: { + remember, + shouldSkip, + forget, + clear, + } satisfies SyncTracker, + remember, + } +} + +function createCodeFile({ content, name, path }: { content: string; name?: string; path?: string }) { + return { + content, + getVersions: vi.fn(), + name, + path, + remove: vi.fn(), + rename: vi.fn(), + setFileContent: vi.fn(), + } +} + +function setup() { + const tracker = createTracker() + return { + api: new CodeFilesAPI(), + socket: createSocket(), + tracker: tracker.tracker, + trackerRemember: tracker.remember, + } +} + +function mockCodeFiles(files: ReturnType[]) { + framerMock.getCodeFiles.mockResolvedValue(files) +} + +async function publishSnapshotAndClear({ + api, + socket, + files, +}: { + api: CodeFilesAPI + socket: MockSocket + files: ReturnType[] +}) { + mockCodeFiles(files) + await api.publishSnapshot(socket as unknown as WebSocket) + socket.send.mockClear() +} + +describe("CodeFilesAPI", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it("publishes a canonicalized snapshot and seeds later diffing", async () => { + const { api, socket, tracker, trackerRemember } = setup() + + const files = [ + createCodeFile({ path: "components/Foo.tsx", content: "export const Foo = 1" }), + createCodeFile({ name: "Bar.ts", content: "export const Bar = 1" }), + ] + mockCodeFiles(files) + + await api.publishSnapshot(socket as unknown as WebSocket) + + expect(getSentMessages(socket)).toEqual([ + { + type: "file-list", + files: [ + { name: "components/Foo.tsx", content: "export const Foo = 1" }, + { name: "Bar.ts", content: "export const Bar = 1" }, + ], + }, + ]) + + socket.send.mockClear() + mockCodeFiles(files) + + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(socket.send).not.toHaveBeenCalled() + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("emits incremental file changes and deletes", async () => { + const { api, socket, tracker, trackerRemember } = setup() + + await publishSnapshotAndClear({ + api, + socket, + files: [ + createCodeFile({ name: "Changed.tsx", content: "export const Changed = 1" }), + createCodeFile({ name: "Removed.tsx", content: "export const Removed = 1" }), + ], + }) + + mockCodeFiles([ + createCodeFile({ name: "Changed.tsx", content: "export const Changed = 2" }), + createCodeFile({ name: "Added.tsx", content: "export const Added = 1" }), + ]) + + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(getSentMessages(socket)).toHaveLength(3) + expect(getSentMessages(socket)).toEqual( + expect.arrayContaining([ + { + type: "file-change", + fileName: "Changed.tsx", + content: "export const Changed = 2", + }, + { + type: "file-change", + fileName: "Added.tsx", + content: "export const Added = 1", + }, + { + type: "file-delete", + fileNames: ["Removed.tsx"], + requireConfirmation: false, + }, + ]) + ) + expect(trackerRemember).toHaveBeenCalledTimes(2) + expect(trackerRemember).toHaveBeenNthCalledWith(1, "Changed.tsx", "export const Changed = 2") + expect(trackerRemember).toHaveBeenNthCalledWith(2, "Added.tsx", "export const Added = 1") + }) + + it("normalizes extensionless remote changes and seeds snapshot state after a successful write", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const content = "export const New = 1" + + framerMock.getCodeFiles.mockResolvedValueOnce([]) + + await api.applyRemoteChange("New", content, socket as unknown as WebSocket) + + expect(framerMock.createCodeFile).toHaveBeenCalledWith("New.tsx", content, { + editViaPlugin: false, + }) + const [syncMessage] = getSentMessages(socket) + expectFileSyncedMessage(syncMessage, "New.tsx") + expect(syncMessage.remoteModifiedAt).toEqual(expect.any(Number)) + + socket.send.mockClear() + mockCodeFiles([createCodeFile({ name: "New.tsx", content })]) + + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(socket.send).not.toHaveBeenCalled() + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("updates an existing extensionless Framer file instead of creating a duplicate", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const oldContent = "export const New = 1" + const newContent = "export const New = 2" + const existing = createCodeFile({ name: "New", content: oldContent }) + + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + + await api.applyRemoteChange("New", newContent, socket as unknown as WebSocket) + + expect(existing.setFileContent).toHaveBeenCalledWith(newContent) + expect(framerMock.createCodeFile).not.toHaveBeenCalled() + const [syncMessage] = getSentMessages(socket) + expectFileSyncedMessage(syncMessage, "New.tsx") + + socket.send.mockClear() + mockCodeFiles([createCodeFile({ name: "New", content: newContent })]) + + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(socket.send).not.toHaveBeenCalled() + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("seeds snapshot before a remote write finishes to avoid echoing subscription updates", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const oldContent = "export const Race = 1" + const newContent = "export const Race = 2" + const existing = createCodeFile({ name: "Race.tsx", content: oldContent }) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "Race.tsx", content: oldContent })], + }) + + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + existing.setFileContent.mockImplementation(async (content: string) => { + existing.content = content + framerMock.getCodeFiles.mockResolvedValue([existing]) + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + }) + + await api.applyRemoteChange("Race.tsx", newContent, socket as unknown as WebSocket) + + const sentMessages = getSentMessages(socket) + expect(sentMessages).toHaveLength(1) + expectFileSyncedMessage(sentMessages[0], "Race.tsx") + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("does not update snapshot state when a remote write fails", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const oldContent = "export const Broken = 1" + const newContent = "export const Broken = 2" + const existing = createCodeFile({ name: "Broken.tsx", content: oldContent }) + + existing.setFileContent.mockRejectedValueOnce(new Error("write failed")) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "Broken.tsx", content: oldContent })], + }) + + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + + await expect(api.applyRemoteChange("Broken.tsx", newContent, socket as unknown as WebSocket)).rejects.toThrow( + "write failed" + ) + expect(socket.send).not.toHaveBeenCalled() + + mockCodeFiles([createCodeFile({ name: "Broken.tsx", content: newContent })]) + + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(getSentMessages(socket)).toEqual([ + { + type: "file-change", + fileName: "Broken.tsx", + content: newContent, + }, + ]) + expect(trackerRemember).toHaveBeenCalledWith("Broken.tsx", newContent) + }) + + it("renames a file to a normalized target and updates snapshot state", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const content = "export const Old = 1" + const existing = createCodeFile({ name: "Old.tsx", content }) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "Old.tsx", content })], + }) + + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + + await expect(api.applyRemoteRename("Old.tsx", "New", socket as unknown as WebSocket)).resolves.toBe(true) + + expect(existing.rename).toHaveBeenCalledWith("New.tsx") + const [syncMessage] = getSentMessages(socket) + expectFileSyncedMessage(syncMessage, "New.tsx") + expect(syncMessage.remoteModifiedAt).toEqual(expect.any(Number)) + + socket.send.mockClear() + mockCodeFiles([createCodeFile({ name: "New.tsx", content })]) + + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(socket.send).not.toHaveBeenCalled() + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("seeds snapshot before a remote rename finishes to avoid echoing subscription updates", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const content = "export const Old = 1" + const existing = createCodeFile({ name: "Old.tsx", content }) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "Old.tsx", content })], + }) + + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + existing.rename.mockImplementation(async (targetName: string) => { + existing.name = targetName + framerMock.getCodeFiles.mockResolvedValue([existing]) + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + }) + + await expect(api.applyRemoteRename("Old.tsx", "New", socket as unknown as WebSocket)).resolves.toBe(true) + + const sentMessages = getSentMessages(socket) + expect(sentMessages).toHaveLength(1) + expectFileSyncedMessage(sentMessages[0], "New.tsx") + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("finds an extensionless rename source using its normalized name", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const content = "export const Old = 1" + const existing = createCodeFile({ name: "Old", content }) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "Old", content })], + }) + + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + + await expect(api.applyRemoteRename("Old.tsx", "New", socket as unknown as WebSocket)).resolves.toBe(true) + + expect(existing.rename).toHaveBeenCalledWith("New.tsx") + + socket.send.mockClear() + mockCodeFiles([createCodeFile({ name: "New", content })]) + + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(socket.send).not.toHaveBeenCalled() + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("deletes an extensionless Framer file using a normalized name", async () => { + const { api, socket, tracker } = setup() + const existing = createCodeFile({ name: "DeleteMe", content: "export const DeleteMe = 1" }) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "DeleteMe", content: "export const DeleteMe = 1" })], + }) + + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + + await api.applyRemoteDelete("DeleteMe.tsx") + + expect(existing.remove).toHaveBeenCalled() + + mockCodeFiles([]) + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + expect(socket.send).not.toHaveBeenCalled() + }) + + it("seeds snapshot before a remote delete finishes to avoid echoing subscription updates", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const content = "export const DeleteMe = 1" + const existing = createCodeFile({ name: "DeleteMe.tsx", content }) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "DeleteMe.tsx", content })], + }) + + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + existing.remove.mockImplementation(async () => { + framerMock.getCodeFiles.mockResolvedValue([]) + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + }) + + await api.applyRemoteDelete("DeleteMe.tsx") + + expect(socket.send).not.toHaveBeenCalled() + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("restores snapshot state when a remote delete fails", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const content = "export const DeleteMe = 1" + const existing = createCodeFile({ name: "DeleteMe.tsx", content }) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "DeleteMe.tsx", content })], + }) + + existing.remove.mockRejectedValueOnce(new Error("delete failed")) + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + + await expect(api.applyRemoteDelete("DeleteMe.tsx")).rejects.toThrow("delete failed") + + mockCodeFiles([createCodeFile({ name: "DeleteMe.tsx", content })]) + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(socket.send).not.toHaveBeenCalled() + expect(trackerRemember).not.toHaveBeenCalled() + }) + + it("returns an error when rename cannot fetch code files", async () => { + const { api, socket } = setup() + + framerMock.getCodeFiles.mockRejectedValueOnce(new Error("fetch failed")) + + await expect(api.applyRemoteRename("Old.tsx", "New", socket as unknown as WebSocket)).resolves.toBe(false) + + expect(getSentMessages(socket)).toEqual([ + { + type: "error", + fileName: "New.tsx", + message: "Failed to fetch code files for rename Old.tsx -> New", + }, + ]) + }) + + it("returns an error for missing rename sources and clears stale snapshot state", async () => { + const { api, socket, tracker } = setup() + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "Old.tsx", content: "export const Old = 1" })], + }) + + framerMock.getCodeFiles.mockResolvedValueOnce([]) + + await expect(api.applyRemoteRename("Old.tsx", "New", socket as unknown as WebSocket)).resolves.toBe(false) + + expect(getSentMessages(socket)).toEqual([ + { + type: "error", + fileName: "New.tsx", + message: "Rename failed: Old.tsx not found in Framer", + }, + ]) + + socket.send.mockClear() + mockCodeFiles([]) + + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(socket.send).not.toHaveBeenCalled() + }) + + it("returns an error when rename throws and does not confirm sync", async () => { + const { api, socket, tracker, trackerRemember } = setup() + const content = "export const Old = 1" + const existing = createCodeFile({ name: "Old.tsx", content }) + + await publishSnapshotAndClear({ + api, + socket, + files: [createCodeFile({ name: "Old.tsx", content })], + }) + + existing.rename.mockRejectedValueOnce(new Error("rename failed")) + framerMock.getCodeFiles.mockResolvedValueOnce([existing]) + + await expect(api.applyRemoteRename("Old.tsx", "New", socket as unknown as WebSocket)).resolves.toBe(false) + + expect(getSentMessages(socket)).toEqual([ + { + type: "error", + fileName: "New.tsx", + message: "Failed to rename Old.tsx -> New", + }, + ]) + + socket.send.mockClear() + mockCodeFiles([createCodeFile({ name: "Old.tsx", content })]) + await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker) + + expect(socket.send).not.toHaveBeenCalled() + expect(trackerRemember).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/code-link/src/api.ts b/plugins/code-link/src/api.ts index 9904d6a7c..e7ebb257b 100644 --- a/plugins/code-link/src/api.ts +++ b/plugins/code-link/src/api.ts @@ -1,4 +1,4 @@ -import { canonicalFileName, ensureExtension, type SyncTracker } from "@code-link/shared" +import { normalizeCodeFileName, type SyncTracker } from "@code-link/shared" import { framer } from "framer-plugin" import * as log from "./utils/logger" @@ -11,6 +11,44 @@ import * as log from "./utils/logger" export class CodeFilesAPI { private lastSnapshot = new Map() + // Keep the snapshot aligned with the state Framer should expose, even while a remote mutation is in flight. + private async withExpectedSnapshotPatch( + patch: { + upserts?: { fileName: string; content: string }[] + deletes?: string[] + }, + run: () => Promise + ): Promise { + const previousEntries = new Map() + + for (const fileName of patch.deletes ?? []) { + if (!previousEntries.has(fileName)) { + previousEntries.set(fileName, this.lastSnapshot.get(fileName)) + } + this.lastSnapshot.delete(fileName) + } + + for (const entry of patch.upserts ?? []) { + if (!previousEntries.has(entry.fileName)) { + previousEntries.set(entry.fileName, this.lastSnapshot.get(entry.fileName)) + } + this.lastSnapshot.set(entry.fileName, entry.content) + } + + try { + return await run() + } catch (error) { + for (const [fileName, previousContent] of previousEntries) { + if (previousContent === undefined) { + this.lastSnapshot.delete(fileName) + } else { + this.lastSnapshot.set(fileName, previousContent) + } + } + throw error + } + } + private async getCodeFilesWithCanonicalNames() { // Always all files instead of single file calls. // The API internally does that anyways. @@ -26,7 +64,7 @@ export class CodeFilesAPI { return codeFiles.map(file => { const source = file.path || file.name return { - name: canonicalFileName(source), + name: normalizeCodeFileName(source), content: file.content, } }) @@ -77,11 +115,14 @@ export class CodeFilesAPI { } async applyRemoteChange(fileName: string, content: string, socket: WebSocket) { - const normalizedName = canonicalFileName(fileName) - // Update snapshot BEFORE upsert to prevent race with file subscription - this.lastSnapshot.set(normalizedName, content) + const normalizedName = normalizeCodeFileName(fileName) + const updatedAt = await this.withExpectedSnapshotPatch( + { + upserts: [{ fileName: normalizedName, content }], + }, + async () => await upsertFramerFile(normalizedName, content) + ) - const updatedAt = await upsertFramerFile(fileName, content) // Send file-synced message with timestamp const syncTimestamp = updatedAt ?? Date.now() log.debug( @@ -97,13 +138,20 @@ export class CodeFilesAPI { } async applyRemoteDelete(fileName: string) { - await deleteFramerFile(fileName) - this.lastSnapshot.delete(canonicalFileName(fileName)) + const normalizedName = normalizeCodeFileName(fileName) + await this.withExpectedSnapshotPatch( + { + deletes: [normalizedName], + }, + async () => { + await deleteFramerFile(normalizedName) + } + ) } async readCurrentContent(fileName: string) { const files = await this.getCodeFilesWithCanonicalNames() - const normalizedName = canonicalFileName(fileName) + const normalizedName = normalizeCodeFileName(fileName) return files.find(file => file.name === normalizedName)?.content } @@ -123,7 +171,7 @@ export class CodeFilesAPI { const versionPromises = requests.map(async request => { const file = codeFiles.find( - f => canonicalFileName(f.path || f.name) === canonicalFileName(request.fileName) + f => normalizeCodeFileName(f.path || f.name) === normalizeCodeFileName(request.fileName) ) if (!file) { @@ -159,19 +207,87 @@ export class CodeFilesAPI { log.debug(`Returning version data for ${String(results.length)} files`) return results } + + async applyRemoteRename(oldFileName: string, newFileName: string, socket: WebSocket): Promise { + const sourceFileName = normalizeCodeFileName(oldFileName) + const targetFileName = normalizeCodeFileName(newFileName) + + let codeFiles + try { + codeFiles = await framer.getCodeFiles() + } catch (err) { + const message = `Failed to fetch code files for rename ${oldFileName} -> ${newFileName}` + log.error(message, err) + socket.send( + JSON.stringify({ + type: "error", + fileName: targetFileName, + message, + }) + ) + return false + } + + const existing = codeFiles.find(file => normalizeCodeFileName(file.path || file.name) === sourceFileName) + + if (!existing) { + this.lastSnapshot.delete(sourceFileName) + const message = `Rename failed: ${oldFileName} not found in Framer` + log.warn(message) + socket.send( + JSON.stringify({ + type: "error", + fileName: targetFileName, + message, + }) + ) + return false + } + + const content = this.lastSnapshot.get(sourceFileName) ?? existing.content + + try { + await this.withExpectedSnapshotPatch( + { + upserts: [{ fileName: targetFileName, content }], + deletes: [sourceFileName], + }, + async () => await existing.rename(targetFileName) + ) + socket.send( + JSON.stringify({ + type: "file-synced", + fileName: targetFileName, + remoteModifiedAt: Date.now(), + }) + ) + return true + } catch (err) { + const message = `Failed to rename ${oldFileName} -> ${newFileName}` + log.error(message, err) + socket.send( + JSON.stringify({ + type: "error", + fileName: targetFileName, + message, + }) + ) + return false + } + } } async function upsertFramerFile(fileName: string, content: string): Promise { - const normalisedName = canonicalFileName(fileName) + const normalisedName = normalizeCodeFileName(fileName) const codeFiles = await framer.getCodeFiles() - const existing = codeFiles.find(file => canonicalFileName(file.path || file.name) === normalisedName) + const existing = codeFiles.find(file => normalizeCodeFileName(file.path || file.name) === normalisedName) if (existing) { await existing.setFileContent(content) return Date.now() } - await framer.createCodeFile(ensureExtension(normalisedName), content, { + await framer.createCodeFile(normalisedName, content, { editViaPlugin: false, }) @@ -179,9 +295,9 @@ async function upsertFramerFile(fileName: string, content: string): Promise canonicalFileName(file.path || file.name) === normalisedName) + const existing = codeFiles.find(file => normalizeCodeFileName(file.path || file.name) === normalisedName) if (existing) { await existing.remove() diff --git a/plugins/code-link/src/messages.ts b/plugins/code-link/src/messages.ts new file mode 100644 index 000000000..c9a1a6b5c --- /dev/null +++ b/plugins/code-link/src/messages.ts @@ -0,0 +1,105 @@ +import { + type CliToPluginMessage, + type ConflictSummary, + type Mode, + normalizeCodeFileName, + type PendingDelete, + type SyncTracker, +} from "@code-link/shared" +import type { CodeFilesAPI } from "./api" +import * as log from "./utils/logger" + +type MessageHandlerAction = + | { type: "set-mode"; mode: Mode } + | { type: "pending-deletes"; files: PendingDelete[] } + | { type: "conflicts"; conflicts: ConflictSummary[] } + +export function createMessageHandler({ + dispatch, + api, + syncTracker, +}: { + dispatch: (action: MessageHandlerAction) => void + api: CodeFilesAPI + syncTracker: SyncTracker +}) { + return async function handleMessage(message: CliToPluginMessage, socket: WebSocket) { + log.debug("Handling message:", message.type) + + switch (message.type) { + case "request-files": + log.debug("Publishing snapshot to CLI") + await api.publishSnapshot(socket) + dispatch({ + type: "set-mode", + mode: "syncing", + }) + break + case "file-change": + log.debug("Applying remote change:", message.fileName) + await api.applyRemoteChange(message.fileName, message.content, socket) + syncTracker.remember(normalizeCodeFileName(message.fileName), message.content) + dispatch({ type: "set-mode", mode: "idle" }) + break + case "file-rename": { + const { oldFileName, newFileName, content } = message + log.debug(`Renaming file: ${oldFileName} → ${newFileName}`) + if (await api.applyRemoteRename(oldFileName, newFileName, socket)) { + syncTracker.forget(normalizeCodeFileName(oldFileName)) + syncTracker.remember(normalizeCodeFileName(newFileName), content) + } + dispatch({ type: "set-mode", mode: "idle" }) + break + } + case "file-delete": + if (message.requireConfirmation) { + log.debug(`Delete requires confirmation for ${message.fileNames.length} file(s)`) + const files: PendingDelete[] = [] + for (const fileName of message.fileNames) { + const content = await api.readCurrentContent(fileName) + // Only include files that exist in Framer (have content to restore) + if (content !== undefined) { + files.push({ fileName, content }) + } + } + if (files.length === 0) { + // No files exist in Framer, nothing to confirm + break + } + dispatch({ + type: "pending-deletes", + files, + }) + } else { + for (const fileName of message.fileNames) { + log.debug("Deleting file:", fileName) + await api.applyRemoteDelete(fileName) + } + } + break + case "conflicts-detected": + log.debug(`Received ${message.conflicts.length} conflicts from CLI`) + dispatch({ type: "conflicts", conflicts: message.conflicts }) + break + case "conflict-version-request": { + log.debug(`Fetching conflict versions for ${message.conflicts.length} files`) + const versions = await api.fetchConflictVersions(message.conflicts) + log.debug(`Sending version response for ${versions.length} files`) + socket.send( + JSON.stringify({ + type: "conflict-version-response", + versions, + }) + ) + break + } + case "sync-complete": + log.debug("Sync complete, transitioning to idle") + dispatch({ type: "set-mode", mode: "idle" }) + break + default: + log.warn("Unknown message type:", (message as unknown as { type: string }).type) + break + } + } +}