Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f73b3ad
Fix: Suppress sanitization echo events in file watcher
huntercaron Mar 6, 2026
98e4c37
Fix: Only send rename confirmation when Framer file is found
huntercaron Mar 6, 2026
28065de
Fix: Cancel buffered add on rapid create+edit to prevent stale content
huntercaron Mar 6, 2026
269ed9a
Deduplicate SHA256 hash function in hash-tracker
huntercaron Mar 6, 2026
550ba5a
Fix: Add error handling to SEND_FILE_RENAME to prevent state corrupti…
huntercaron Mar 9, 2026
f5bee3e
Update based on feedback
huntercaron Mar 9, 2026
f845100
Fix permissions and feedback
huntercaron Mar 9, 2026
b52fa26
Fix: Remove unnecessary String() cast on already-narrowed string type
huntercaron Mar 9, 2026
2a4c08a
Fix: Remove redundant runtime type guard for file-rename content
huntercaron Mar 9, 2026
3b6eafb
Refactor for clarity
huntercaron Mar 10, 2026
85a9e1a
Remove redundant permissions check
huntercaron Mar 10, 2026
089a253
Rename echo prevention
huntercaron Mar 10, 2026
2838b81
Polish from feedback
huntercaron Mar 10, 2026
389a6b7
Refactor code file name normalization
huntercaron Mar 10, 2026
cf0bc43
Re-order imports
huntercaron Mar 10, 2026
f32f9d3
Add tests for Plugin side
huntercaron Mar 10, 2026
b993e55
Improve test typing
huntercaron Mar 10, 2026
ef08eaf
Update based on feedback
huntercaron Mar 10, 2026
906e120
Update from feedback
huntercaron Mar 10, 2026
ecc862d
Seems solid
huntercaron Mar 10, 2026
ff5c996
Refactor snapshot checking on Plugin side
huntercaron Mar 10, 2026
ccfd301
From feedback
huntercaron Mar 10, 2026
9bd6365
Feedback
huntercaron Mar 10, 2026
5968df2
Fixes from feedback
huntercaron Mar 10, 2026
b1a9a75
Clean up name normalization functions
huntercaron Mar 12, 2026
553d7af
Formatting
huntercaron Mar 12, 2026
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
410 changes: 410 additions & 0 deletions packages/code-link-cli/src/controller.rename.test.ts

Large diffs are not rendered by default.

106 changes: 101 additions & 5 deletions packages/code-link-cli/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import type { CliToPluginMessage, PluginToCliMessage } from "@code-link/shared"
import { pluralize, shortProjectHash } from "@code-link/shared"
import { normalizeCodeFilePathWithExtension, pluralize, shortProjectHash } from "@code-link/shared"
import fs from "fs/promises"
import path from "path"
import type { WebSocket } from "ws"
Expand Down Expand Up @@ -165,6 +165,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"
Expand All @@ -178,6 +184,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 }
Expand Down Expand Up @@ -557,6 +568,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 }
Expand Down Expand Up @@ -676,11 +704,13 @@ async function executeEffect(
hashTracker: ReturnType<typeof createHashTracker>
installer: Installer | null
fileMetadataCache: FileMetadataCache
pendingRenameConfirmations: Map<string, PendingRenameConfirmation>
userActions: PluginUserPromptCoordinator
syncState: SyncState
}
): Promise<SyncEvent[]> {
const { config, hashTracker, installer, fileMetadataCache, userActions, syncState } = context
const { config, hashTracker, installer, fileMetadataCache, pendingRenameConfirmations, userActions, syncState } =
context

switch (effect.type) {
case "INIT_WORKSPACE": {
Expand Down Expand Up @@ -853,12 +883,26 @@ 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(
normalizeCodeFilePathWithExtension(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)
if (currentContent !== null) {
hashTracker.remember(effect.fileName, currentContent)
}
pendingRenameConfirmations.delete(normalizeCodeFilePathWithExtension(effect.fileName))
}

return []
}

Expand Down Expand Up @@ -904,6 +948,47 @@ async function executeEffect(
return []
}

case "SEND_FILE_RENAME": {
const normalizedNewFileName = normalizeCodeFilePathWithExtension(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(normalizeCodeFilePathWithExtension(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 => {
Expand Down Expand Up @@ -1014,6 +1099,7 @@ export async function start(config: Config): Promise<void> {

const hashTracker = createHashTracker()
const fileMetadataCache = new FileMetadataCache()
const pendingRenameConfirmations = new Map<string, PendingRenameConfirmation>()
let installer: Installer | null = null

// State machine state
Expand Down Expand Up @@ -1053,6 +1139,7 @@ export async function start(config: Config): Promise<void> {
hashTracker,
installer,
fileMetadataCache,
pendingRenameConfirmations,
userActions,
syncState,
})
Expand Down Expand Up @@ -1101,6 +1188,7 @@ export async function start(config: Config): Promise<void> {
return
}
debug(`New handshake received in ${syncState.mode} mode, resetting sync state`)
pendingRenameConfirmations.clear()
await processEvent({ type: "DISCONNECT" })
}

Expand Down Expand Up @@ -1221,6 +1309,13 @@ export async function start(config: Config): Promise<void> {
}
break

case "error":
if (message.fileName) {
pendingRenameConfirmations.delete(normalizeCodeFilePathWithExtension(message.fileName))
}
warn(message.message)
return

case "conflicts-resolved":
event = {
type: "CONFLICTS_RESOLVED",
Expand Down Expand Up @@ -1263,6 +1358,7 @@ export async function start(config: Config): Promise<void> {
status("Disconnected, waiting to reconnect...")
})
void (async () => {
pendingRenameConfirmations.clear()
await processEvent({ type: "DISCONNECT" })
userActions.cleanup()
})()
Expand Down Expand Up @@ -1300,4 +1396,4 @@ export async function start(config: Config): Promise<void> {
}

// Export for testing
export { transition }
export { executeEffect, transition }
6 changes: 3 additions & 3 deletions packages/code-link-cli/src/helpers/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { autoResolveConflicts, DEFAULT_REMOTE_DRIFT_MS, detectConflicts } from "
function makeConflict(overrides: Partial<Conflict> = {}): 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,
}
}
Expand Down
9 changes: 5 additions & 4 deletions packages/code-link-cli/src/helpers/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
}
}
Expand Down
Loading
Loading