Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
182 changes: 182 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,186 @@ 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("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,
}
Comment thread
huntercaron marked this conversation as resolved.
Comment thread
huntercaron marked this conversation as resolved.
}

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,56 @@ 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: allow add/change to sync, defer delete/rename
// (delete triggers a confirmation prompt that would conflict with the conflict UI,
// and rename sends a message that would dismiss the conflict panel)
if (kind === "delete" || kind === "rename") {
effects.push(log("debug", `Deferring ${kind} during conflict resolution: ${relativePath}`))
return { state, effects }
}
// add/change: fall through to normal processing
} else {
effects.push(log("debug", `Ignoring watcher event in ${state.mode} mode: ${kind} ${relativePath}`))
return { state, effects }
}
}

switch (kind) {
Expand Down Expand Up @@ -851,6 +959,16 @@ async function executeEffect(
return []
}

case "UPDATE_CONFLICT_DATA": {
if (syncState.socket) {
await sendMessage(syncState.socket, {
type: "conflicts-detected",
conflicts: effect.conflicts,
})
}
return []
Comment thread
huntercaron marked this conversation as resolved.
}

case "REQUEST_CONFLICT_VERSIONS": {
if (!syncState.socket) {
warn("Cannot request conflict versions without active socket")
Expand Down
9 changes: 9 additions & 0 deletions plugins/code-link/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
huntercaron marked this conversation as resolved.
Comment thread
huntercaron marked this conversation as resolved.
return {
...state,
mode: action.mode,
Expand All @@ -67,6 +72,10 @@ function reducer(state: State, action: Action): State {
mode: "info",
}
case "pending-deletes":
// Queue deletes but don't switch mode during conflict resolution
if (state.mode === "conflict_resolution" && state.conflicts.length > 0) {
return { ...state, pendingDeletes: [...state.pendingDeletes, ...action.files] }
Comment thread
huntercaron marked this conversation as resolved.
Outdated
}
return {
...state,
pendingDeletes: [...state.pendingDeletes, ...action.files],
Expand Down
Loading
Loading