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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions packages/code-link-cli/src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
})
132 changes: 125 additions & 7 deletions packages/code-link-cli/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ type Effect =
newFileName: string
content: string
}
| { type: "UPDATE_CONFLICT_DATA"; conflicts: Conflict[] }
| { type: "PERSIST_STATE" }
| {
type: "SYNC_COMPLETE"
Expand All @@ -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
Expand Down Expand Up @@ -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") {
Expand All @@ -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}`),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading