diff --git a/packages/code-link-cli/src/controller.test.ts b/packages/code-link-cli/src/controller.test.ts index 081accb6d..2b550de81 100644 --- a/packages/code-link-cli/src/controller.test.ts +++ b/packages/code-link-cli/src/controller.test.ts @@ -623,4 +623,215 @@ describe("Code Link", () => { expect(result.effects.some(e => e.type === "DETECT_CONFLICTS")).toBe(true) }) }) + + // SYNC DURING CONFLICT RESOLUTION + // Non-conflicted files should continue syncing while the conflict prompt is open. + // Conflicted files should have their pendingConflicts data updated live. + + describe("Sync During Conflict Resolution", () => { + const baseConflict = { + fileName: "Conflicted.tsx", + localContent: "local version", + remoteContent: "framer version", + localModifiedAt: Date.now(), + remoteModifiedAt: Date.now(), + } + + it("syncs non-conflicted local changes during conflict resolution", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "WATCHER_EVENT", + event: { kind: "change", relativePath: "Other.tsx", content: "updated content" }, + }) + + expect(result.effects.some(e => e.type === "SEND_LOCAL_CHANGE")).toBe(true) + const effect = result.effects.find(e => e.type === "SEND_LOCAL_CHANGE") + expect(effect).toMatchObject({ fileName: "Other.tsx" }) + }) + + it("updates pendingConflicts.localContent on conflicted local change", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "WATCHER_EVENT", + event: { kind: "change", relativePath: "Conflicted.tsx", content: "newer local version" }, + }) + + expect(result.state.mode).toBe("conflict_resolution") + expect(result.effects.some(e => e.type === "UPDATE_CONFLICT_DATA")).toBe(true) + expect(result.effects.some(e => e.type === "SEND_LOCAL_CHANGE")).toBe(false) + + const updatedConflict = (result.state as { pendingConflicts: typeof baseConflict[] }).pendingConflicts[0] + expect(updatedConflict.localContent).toBe("newer local version") + }) + + it("sets localContent to null on conflicted local delete", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "WATCHER_EVENT", + event: { kind: "delete", relativePath: "Conflicted.tsx" }, + }) + + expect(result.effects.some(e => e.type === "UPDATE_CONFLICT_DATA")).toBe(true) + expect(result.effects.some(e => e.type === "LOCAL_INITIATED_FILE_DELETE")).toBe(false) + + const updatedConflict = (result.state as { pendingConflicts: typeof baseConflict[] }).pendingConflicts[0] + expect(updatedConflict.localContent).toBeNull() + }) + + it("ignores rename of conflicted file", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "WATCHER_EVENT", + event: { + kind: "rename", + relativePath: "Renamed.tsx", + oldRelativePath: "Conflicted.tsx", + content: "local version", + }, + }) + + expect(result.effects.some(e => e.type === "SEND_FILE_RENAME")).toBe(false) + expect(result.effects.some(e => e.type === "UPDATE_CONFLICT_DATA")).toBe(false) + expect(result.effects.some(e => e.type === "LOG" && e.level === "debug")).toBe(true) + }) + + it("emits LOCAL_INITIATED_FILE_DELETE for non-conflicted local delete during conflict resolution", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "WATCHER_EVENT", + event: { kind: "delete", relativePath: "Other.tsx" }, + }) + + expect(result.effects.some(e => e.type === "LOCAL_INITIATED_FILE_DELETE")).toBe(true) + const effect = result.effects.find(e => e.type === "LOCAL_INITIATED_FILE_DELETE") + expect(effect).toMatchObject({ fileNames: ["Other.tsx"] }) + }) + + it("emits SEND_FILE_RENAME for non-conflicted local rename during conflict resolution", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "WATCHER_EVENT", + event: { + kind: "rename", + relativePath: "Renamed.tsx", + oldRelativePath: "Other.tsx", + content: "file content", + }, + }) + + expect(result.effects.some(e => e.type === "SEND_FILE_RENAME")).toBe(true) + const effect = result.effects.find(e => e.type === "SEND_FILE_RENAME") + expect(effect).toMatchObject({ oldFileName: "Other.tsx", newFileName: "Renamed.tsx" }) + }) + + it("applies non-conflicted remote changes during conflict resolution", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "REMOTE_FILE_CHANGE", + file: { name: "Other.tsx", content: "remote update", modifiedAt: Date.now() }, + }) + + expect(result.effects.some(e => e.type === "WRITE_FILES")).toBe(true) + }) + + it("updates pendingConflicts.remoteContent on conflicted remote change", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "REMOTE_FILE_CHANGE", + file: { name: "Conflicted.tsx", content: "newer framer version", modifiedAt: Date.now() + 5000 }, + }) + + expect(result.state.mode).toBe("conflict_resolution") + expect(result.effects.some(e => e.type === "UPDATE_CONFLICT_DATA")).toBe(true) + expect(result.effects.some(e => e.type === "WRITE_FILES")).toBe(false) + + const updatedConflict = (result.state as { pendingConflicts: typeof baseConflict[] }).pendingConflicts[0] + expect(updatedConflict.remoteContent).toBe("newer framer version") + }) + + it("sets remoteContent to null on conflicted remote delete without deleting from disk", () => { + const state = conflictResolutionState([baseConflict]) + const result = transition(state, { + type: "REMOTE_FILE_DELETE", + fileName: "Conflicted.tsx", + }) + + expect(result.effects.some(e => e.type === "UPDATE_CONFLICT_DATA")).toBe(true) + expect(result.effects.some(e => e.type === "DELETE_LOCAL_FILES")).toBe(false) + + const updatedConflict = (result.state as { pendingConflicts: typeof baseConflict[] }).pendingConflicts[0] + expect(updatedConflict.remoteContent).toBeNull() + }) + + it("auto-resolves conflict when local content converges to match remote", () => { + const state = conflictResolutionState([ + { fileName: "A.tsx", localContent: "old local", remoteContent: "framer version" }, + ]) + const result = transition(state, { + type: "WATCHER_EVENT", + event: { kind: "change", relativePath: "A.tsx", content: "framer version" }, + }) + + // Content matches — conflict should be auto-resolved, transition to watching + expect(result.state.mode).toBe("watching") + expect(result.effects.some(e => e.type === "PERSIST_STATE")).toBe(true) + expect(result.effects.some(e => e.type === "SYNC_COMPLETE")).toBe(true) + expect(result.effects.some(e => e.type === "UPDATE_CONFLICT_DATA")).toBe(false) + }) + + it("auto-resolves conflict when remote content converges to match local", () => { + const state = conflictResolutionState([ + { fileName: "A.tsx", localContent: "local version", remoteContent: "old framer" }, + ]) + const result = transition(state, { + type: "REMOTE_FILE_CHANGE", + file: { name: "A.tsx", content: "local version", modifiedAt: Date.now() }, + }) + + expect(result.state.mode).toBe("watching") + expect(result.effects.some(e => e.type === "PERSIST_STATE")).toBe(true) + }) + + it("auto-resolves only the converged conflict, keeps the rest", () => { + const state = conflictResolutionState([ + { fileName: "A.tsx", localContent: "same", remoteContent: "different" }, + { fileName: "B.tsx", localContent: "local B", remoteContent: "framer B" }, + ]) + const result = transition(state, { + type: "REMOTE_FILE_CHANGE", + file: { name: "A.tsx", content: "same", modifiedAt: Date.now() }, + }) + + // A.tsx resolved, B.tsx still pending + expect(result.state.mode).toBe("conflict_resolution") + expect(result.effects.some(e => e.type === "UPDATE_CONFLICT_DATA")).toBe(true) + const updateEffect = result.effects.find(e => e.type === "UPDATE_CONFLICT_DATA") + expect((updateEffect as { conflicts: { fileName: string }[] }).conflicts).toHaveLength(1) + expect((updateEffect as { conflicts: { fileName: string }[] }).conflicts[0].fileName).toBe("B.tsx") + }) + + it("handles sequential local and remote updates to the same conflicted file", () => { + const state = conflictResolutionState([baseConflict]) + + // First: local change + const afterLocal = transition(state, { + type: "WATCHER_EVENT", + event: { kind: "change", relativePath: "Conflicted.tsx", content: "local edit 2" }, + }) + + const midConflict = (afterLocal.state as { pendingConflicts: typeof baseConflict[] }).pendingConflicts[0] + expect(midConflict.localContent).toBe("local edit 2") + expect(midConflict.remoteContent).toBe("framer version") // unchanged + + // Second: remote change on the updated state + const afterRemote = transition(afterLocal.state, { + type: "REMOTE_FILE_CHANGE", + file: { name: "Conflicted.tsx", content: "framer edit 2", modifiedAt: Date.now() + 5000 }, + }) + + const finalConflict = (afterRemote.state as { pendingConflicts: typeof baseConflict[] }).pendingConflicts[0] + expect(finalConflict.localContent).toBe("local edit 2") + expect(finalConflict.remoteContent).toBe("framer edit 2") + }) + }) }) diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index fd33dcda2..06a40f84f 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -171,6 +171,7 @@ type Effect = newFileName: string content: string } + | { type: "UPDATE_CONFLICT_DATA"; conflicts: Conflict[] } | { type: "PERSIST_STATE" } | { type: "SYNC_COMPLETE" @@ -194,6 +195,40 @@ function log(level: "info" | "debug" | "warn" | "success", message: string): Eff return { type: "LOG", level, message } } +/** + * After updating a conflict's content, filter out any conflicts where both sides now match. + * If no conflicts remain, transition to watching mode. Otherwise emit updated conflict data. + */ +function applyConflictUpdate( + state: ConflictResolutionState, + updatedConflicts: Conflict[], + effects: Effect[] +): { state: SyncState; effects: Effect[] } { + const remaining = updatedConflicts.filter(c => c.localContent !== c.remoteContent) + + if (remaining.length === 0) { + // All conflicts resolved — transition to watching + effects.push( + log("debug", "All conflicts auto-resolved (content converged)"), + { type: "PERSIST_STATE" }, + { + type: "SYNC_COMPLETE", + totalCount: updatedConflicts.length, + updatedCount: updatedConflicts.length, + unchangedCount: 0, + } + ) + const { pendingConflicts: _discarded, ...rest } = state + return { + state: { ...rest, mode: "watching", pendingRemoteChanges: [] }, + effects, + } + } + + effects.push({ type: "UPDATE_CONFLICT_DATA", conflicts: remaining }) + return { state: { ...state, pendingConflicts: remaining }, effects } +} + /** * Pure state transition function * Takes current state + event, returns new state + effects to execute @@ -389,9 +424,25 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff const validation = validateIncomingChange(event.fileMeta, state.mode) if (validation.action === "queue") { - // Changes during initial sync are ignored - the snapshot handles reconciliation - effects.push(log("debug", `Ignoring file change during sync: ${event.file.name}`)) - return { state, effects } + if (state.mode === "conflict_resolution") { + const conflictIndex = state.pendingConflicts.findIndex(c => c.fileName === event.file.name) + if (conflictIndex >= 0) { + // Update conflict with latest remote content + const updatedConflicts = [...state.pendingConflicts] + updatedConflicts[conflictIndex] = { + ...updatedConflicts[conflictIndex], + remoteContent: event.file.content, + remoteModifiedAt: event.file.modifiedAt, + } + effects.push(log("debug", `Updated conflict with latest remote content: ${event.file.name}`)) + return applyConflictUpdate(state, updatedConflicts, effects) + } + // Non-conflicted file during conflict resolution: fall through to apply + } else { + // Changes during initial sync are ignored - the snapshot handles reconciliation + effects.push(log("debug", `Ignoring file change during sync: ${event.file.name}`)) + return { state, effects } + } } if (validation.action === "reject") { @@ -416,7 +467,18 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff return { state, effects } } - // Remote deletes should always be applied immediately + // During conflict resolution, update conflict data instead of deleting + if (state.mode === "conflict_resolution") { + const conflictIndex = state.pendingConflicts.findIndex(c => c.fileName === event.fileName) + if (conflictIndex >= 0) { + const updatedConflicts = [...state.pendingConflicts] + updatedConflicts[conflictIndex] = { ...updatedConflicts[conflictIndex], remoteContent: null } + effects.push(log("debug", `Updated conflict with remote delete: ${event.fileName}`)) + return applyConflictUpdate(state, updatedConflicts, effects) + } + } + + // Remote deletes applied immediately // (the file is already gone from Framer) effects.push( log("debug", `Remote delete applied: ${event.fileName}`), @@ -539,10 +601,52 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff // Local file system change detected const { kind, relativePath, content } = event.event - // Only process changes in watching mode + // Only process changes in watching or conflict_resolution mode if (state.mode !== "watching") { - effects.push(log("debug", `Ignoring watcher event in ${state.mode} mode: ${kind} ${relativePath}`)) - return { state, effects } + if (state.mode === "conflict_resolution") { + const conflictIndex = state.pendingConflicts.findIndex(c => c.fileName === relativePath) + + if (conflictIndex >= 0) { + if ((kind === "add" || kind === "change") && content !== undefined) { + // Update conflict with latest local content + const updatedConflicts = [...state.pendingConflicts] + updatedConflicts[conflictIndex] = { ...updatedConflicts[conflictIndex], localContent: content } + effects.push(log("debug", `Updated conflict with latest local content: ${relativePath}`)) + return applyConflictUpdate(state, updatedConflicts, effects) + } + if (kind === "delete") { + // Local deleted a conflicted file + const updatedConflicts = [...state.pendingConflicts] + updatedConflicts[conflictIndex] = { ...updatedConflicts[conflictIndex], localContent: null } + effects.push(log("debug", `Updated conflict with local delete: ${relativePath}`)) + return applyConflictUpdate(state, updatedConflicts, effects) + } + if (kind === "rename") { + // Renaming a conflicted file during resolution is ambiguous; ignore + effects.push(log("debug", `Ignoring rename of conflicted file: ${relativePath}`)) + return { state, effects } + } + } + + // Check if rename's old path is a conflicted file + if (kind === "rename" && event.event.oldRelativePath) { + const oldConflictIndex = state.pendingConflicts.findIndex( + c => c.fileName === event.event.oldRelativePath + ) + if (oldConflictIndex >= 0) { + effects.push(log("debug", `Ignoring rename of conflicted file: ${event.event.oldRelativePath} → ${relativePath}`)) + return { state, effects } + } + } + + // Non-conflicted file: fall through to normal processing + // - delete → LOCAL_INITIATED_FILE_DELETE (pending-deletes guard queues, clear-conflicts surfaces) + // - rename → SEND_FILE_RENAME (set-mode guard prevents conflict UI dismissal) + // - add/change → SEND_LOCAL_CHANGE + } else { + effects.push(log("debug", `Ignoring watcher event in ${state.mode} mode: ${kind} ${relativePath}`)) + return { state, effects } + } } switch (kind) { @@ -852,6 +956,16 @@ async function executeEffect( return [] } + case "UPDATE_CONFLICT_DATA": { + if (syncState.socket) { + await sendMessage(syncState.socket, { + type: "conflicts-detected", + conflicts: effect.conflicts, + }) + } + return [] + } + case "REQUEST_CONFLICT_VERSIONS": { if (!syncState.socket) { warn("Cannot request conflict versions without active socket") @@ -1035,6 +1149,10 @@ async function executeEffect( } case "SYNC_COMPLETE": { + // Wait for any in-flight user prompts (e.g. delete confirmations) to settle + // before notifying the plugin that sync is complete + await userActions.whenEmpty() + const shutdownIfOneOffSync = async () => { if (!config.once) return false diff --git a/packages/code-link-cli/src/helpers/plugin-prompts.ts b/packages/code-link-cli/src/helpers/plugin-prompts.ts index dd440a5af..e6057839c 100644 --- a/packages/code-link-cli/src/helpers/plugin-prompts.ts +++ b/packages/code-link-cli/src/helpers/plugin-prompts.ts @@ -24,6 +24,7 @@ interface PendingAction { export class PluginUserPromptCoordinator { private pendingActions = new Map() + private emptyResolvers: Array<() => void> = [] /** * Register a pending action and return a typed promise @@ -127,6 +128,17 @@ export class PluginUserPromptCoordinator { } } + /** + * Returns a promise that resolves when all pending actions have settled. + * If no actions are pending, resolves immediately. + */ + whenEmpty(): Promise { + if (this.pendingActions.size === 0) return Promise.resolve() + return new Promise(resolve => { + this.emptyResolvers.push(resolve) + }) + } + /** * Handle incoming confirmation response */ @@ -140,6 +152,7 @@ export class PluginUserPromptCoordinator { this.pendingActions.delete(actionId) pending.resolve(value) debug(`Confirmed: ${actionId}`) + this.flushEmptyResolvers() return true } @@ -152,5 +165,13 @@ export class PluginUserPromptCoordinator { debug(`Cancelled pending action: ${actionId}`) } this.pendingActions.clear() + this.flushEmptyResolvers() + } + + private flushEmptyResolvers(): void { + if (this.pendingActions.size === 0 && this.emptyResolvers.length > 0) { + for (const resolve of this.emptyResolvers) resolve() + this.emptyResolvers = [] + } } } diff --git a/plugins/code-link/src/App.css b/plugins/code-link/src/App.css index 3629169d6..ba9e53226 100644 --- a/plugins/code-link/src/App.css +++ b/plugins/code-link/src/App.css @@ -181,10 +181,19 @@ html, .single-file-view { display: flex; - justify-content: space-between; align-items: center; padding-bottom: 15px; + overflow: hidden; + min-width: 0; +} + +.single-file-view .file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; } .list-header { @@ -231,6 +240,7 @@ html, overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; } .list .lines-changed { diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index 980493d42..317d6a45f 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -57,6 +57,11 @@ function reducer(state: State, action: Action): State { mode: action.granted ? (state.mode === "info" ? "loading" : state.mode) : "info", } case "set-mode": + // Don't dismiss conflict resolution while conflicts are pending, + // but always allow "replaced" so the plugin can close when another tab takes over. + if (state.mode === "conflict_resolution" && state.conflicts.length > 0 && action.mode !== "replaced") { + return state + } return { ...state, mode: action.mode, @@ -67,6 +72,24 @@ function reducer(state: State, action: Action): State { mode: "info", } case "pending-deletes": + // During conflict resolution, merge deletes into the conflict panel + if (state.mode === "conflict_resolution" && state.conflicts.length > 0) { + const newConflicts = action.files.reduce((acc, file) => { + if (file.content !== undefined) { + acc.push({ + fileName: file.fileName, + localContent: null, + remoteContent: file.content, + }) + } + return acc + }, []) + return { + ...state, + conflicts: [...state.conflicts, ...newConflicts], + pendingDeletes: [...state.pendingDeletes, ...action.files], + } + } return { ...state, pendingDeletes: [...state.pendingDeletes, ...action.files], @@ -81,7 +104,7 @@ function reducer(state: State, action: Action): State { mode: "conflict_resolution", } case "clear-conflicts": - return { ...state, conflicts: [], mode: "idle" } + return { ...state, conflicts: [], pendingDeletes: [], mode: "idle" } } } @@ -196,7 +219,26 @@ export function App() { }, []) const resolveConflicts = (choice: "local" | "remote") => { - // Send all conflict resolutions at once + // Resolve merged deletes first — CLI is awaiting delete-confirmed/cancelled + if (state.pendingDeletes.length > 0) { + if (choice === "local") { + sendMessage({ + type: "delete-confirmed", + fileNames: state.pendingDeletes.map(d => d.fileName), + }) + } else { + sendMessage({ + type: "delete-cancelled", + files: state.pendingDeletes.reduce<{ fileName: string; content: string }[]>((acc, d) => { + if (d.content !== undefined) { + acc.push({ fileName: d.fileName, content: d.content }) + } + return acc + }, []), + }) + } + } + sendMessage({ type: "conflicts-resolved", resolution: choice, diff --git a/plugins/code-link/src/messages.ts b/plugins/code-link/src/messages.ts index 654346c46..f94c97d6d 100644 --- a/plugins/code-link/src/messages.ts +++ b/plugins/code-link/src/messages.ts @@ -13,6 +13,7 @@ type MessageHandlerAction = | { type: "set-mode"; mode: Mode } | { type: "pending-deletes"; files: PendingDelete[] } | { type: "conflicts"; conflicts: ConflictSummary[] } + | { type: "clear-conflicts" } export function createMessageHandler({ dispatch, @@ -94,8 +95,8 @@ export function createMessageHandler({ break } case "sync-complete": - log.debug("Sync complete, transitioning to idle") - dispatch({ type: "set-mode", mode: "idle" }) + log.debug("Sync complete. Transitioning out of conflict mode.") + dispatch({ type: "clear-conflicts" }) break default: log.warn("Unknown message type:", (message as unknown as { type: string }).type)