Skip to content

Commit 504fb7c

Browse files
committed
Update from feedback
1 parent 22c975c commit 504fb7c

File tree

4 files changed

+187
-102
lines changed

4 files changed

+187
-102
lines changed

plugins/code-link/src/App.tsx

Lines changed: 1 addition & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import {
2-
type CliToPluginMessage,
32
type ConflictSummary,
43
createSyncTracker,
54
type Mode,
6-
normalizeCodeFileName,
75
type PendingDelete,
86
type ProjectInfo,
97
type SyncTracker,
@@ -12,6 +10,7 @@ import {
1210
import { framer } from "framer-plugin"
1311
import { useCallback, useEffect, useReducer, useRef, useState } from "react"
1412
import { CodeFilesAPI } from "./api"
13+
import { createMessageHandler } from "./messages"
1514
import { copyToClipboard } from "./utils/clipboard"
1615
import { computeLineDiff } from "./utils/diffing"
1716
import * as log from "./utils/logger"
@@ -535,93 +534,3 @@ function ConflictPanel({ conflicts, onResolve }: ConflictPanelProps) {
535534
</main>
536535
)
537536
}
538-
539-
function createMessageHandler({
540-
dispatch,
541-
api,
542-
syncTracker,
543-
}: {
544-
dispatch: (action: Action) => void
545-
api: CodeFilesAPI
546-
syncTracker: SyncTracker
547-
}) {
548-
return async function handleMessage(message: CliToPluginMessage, socket: WebSocket) {
549-
log.debug("Handling message:", message.type)
550-
551-
switch (message.type) {
552-
case "request-files":
553-
log.debug("Publishing snapshot to CLI")
554-
await api.publishSnapshot(socket)
555-
dispatch({
556-
type: "set-mode",
557-
mode: "syncing",
558-
})
559-
break
560-
case "file-change":
561-
log.debug("Applying remote change:", message.fileName)
562-
await api.applyRemoteChange(message.fileName, message.content, socket)
563-
syncTracker.remember(normalizeCodeFileName(message.fileName), message.content)
564-
dispatch({ type: "set-mode", mode: "idle" })
565-
break
566-
case "file-rename": {
567-
const { oldFileName, newFileName, content } = message
568-
log.debug(`Renaming file: ${oldFileName}${newFileName}`)
569-
if (await api.applyRemoteRename(oldFileName, newFileName, socket)) {
570-
syncTracker.forget(oldFileName)
571-
syncTracker.remember(normalizeCodeFileName(newFileName), content)
572-
}
573-
dispatch({ type: "set-mode", mode: "idle" })
574-
break
575-
}
576-
case "file-delete":
577-
if (message.requireConfirmation) {
578-
log.debug(`Delete requires confirmation for ${message.fileNames.length} file(s)`)
579-
const files: PendingDelete[] = []
580-
for (const fileName of message.fileNames) {
581-
const content = await api.readCurrentContent(fileName)
582-
// Only include files that exist in Framer (have content to restore)
583-
if (content !== undefined) {
584-
files.push({ fileName, content })
585-
}
586-
}
587-
if (files.length === 0) {
588-
// No files exist in Framer, nothing to confirm
589-
break
590-
}
591-
dispatch({
592-
type: "pending-deletes",
593-
files,
594-
})
595-
} else {
596-
for (const fileName of message.fileNames) {
597-
log.debug("Deleting file:", fileName)
598-
await api.applyRemoteDelete(fileName)
599-
}
600-
}
601-
break
602-
case "conflicts-detected":
603-
log.debug(`Received ${message.conflicts.length} conflicts from CLI`)
604-
dispatch({ type: "conflicts", conflicts: message.conflicts })
605-
break
606-
case "conflict-version-request": {
607-
log.debug(`Fetching conflict versions for ${message.conflicts.length} files`)
608-
const versions = await api.fetchConflictVersions(message.conflicts)
609-
log.debug(`Sending version response for ${versions.length} files`)
610-
socket.send(
611-
JSON.stringify({
612-
type: "conflict-version-response",
613-
versions,
614-
})
615-
)
616-
break
617-
}
618-
case "sync-complete":
619-
log.debug("Sync complete, transitioning to idle")
620-
dispatch({ type: "set-mode", mode: "idle" })
621-
break
622-
default:
623-
log.warn("Unknown message type:", (message as unknown as { type: string }).type)
624-
break
625-
}
626-
}
627-
}

plugins/code-link/src/api.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,30 @@ describe("CodeFilesAPI", () => {
221221
expect(trackerRemember).not.toHaveBeenCalled()
222222
})
223223

224+
it("updates an existing extensionless Framer file instead of creating a duplicate", async () => {
225+
const { api, socket, tracker, trackerRemember } = setup()
226+
const oldContent = "export const New = 1"
227+
const newContent = "export const New = 2"
228+
const existing = createCodeFile({ name: "New", content: oldContent })
229+
230+
framerMock.getCodeFiles.mockResolvedValueOnce([existing])
231+
232+
await api.applyRemoteChange("New", newContent, socket as unknown as WebSocket)
233+
234+
expect(existing.setFileContent).toHaveBeenCalledWith(newContent)
235+
expect(framerMock.createCodeFile).not.toHaveBeenCalled()
236+
const [syncMessage] = getSentMessages(socket)
237+
expectFileSyncedMessage(syncMessage, "New.tsx")
238+
239+
socket.send.mockClear()
240+
mockCodeFiles([createCodeFile({ name: "New", content: newContent })])
241+
242+
await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker)
243+
244+
expect(socket.send).not.toHaveBeenCalled()
245+
expect(trackerRemember).not.toHaveBeenCalled()
246+
})
247+
224248
it("seeds snapshot before a remote write finishes to avoid echoing subscription updates", async () => {
225249
const { api, socket, tracker, trackerRemember } = setup()
226250
const oldContent = "export const Race = 1"
@@ -312,6 +336,53 @@ describe("CodeFilesAPI", () => {
312336
expect(trackerRemember).not.toHaveBeenCalled()
313337
})
314338

339+
it("finds an extensionless rename source using its normalized name", async () => {
340+
const { api, socket, tracker, trackerRemember } = setup()
341+
const content = "export const Old = 1"
342+
const existing = createCodeFile({ name: "Old", content })
343+
344+
await publishSnapshotAndClear({
345+
api,
346+
socket,
347+
files: [createCodeFile({ name: "Old", content })],
348+
})
349+
350+
framerMock.getCodeFiles.mockResolvedValueOnce([existing])
351+
352+
await expect(api.applyRemoteRename("Old.tsx", "New", socket as unknown as WebSocket)).resolves.toBe(true)
353+
354+
expect(existing.rename).toHaveBeenCalledWith("New.tsx")
355+
356+
socket.send.mockClear()
357+
mockCodeFiles([createCodeFile({ name: "New", content })])
358+
359+
await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker)
360+
361+
expect(socket.send).not.toHaveBeenCalled()
362+
expect(trackerRemember).not.toHaveBeenCalled()
363+
})
364+
365+
it("deletes an extensionless Framer file using a normalized name", async () => {
366+
const { api, socket, tracker } = setup()
367+
const existing = createCodeFile({ name: "DeleteMe", content: "export const DeleteMe = 1" })
368+
369+
await publishSnapshotAndClear({
370+
api,
371+
socket,
372+
files: [createCodeFile({ name: "DeleteMe", content: "export const DeleteMe = 1" })],
373+
})
374+
375+
framerMock.getCodeFiles.mockResolvedValueOnce([existing])
376+
377+
await api.applyRemoteDelete("DeleteMe.tsx")
378+
379+
expect(existing.remove).toHaveBeenCalled()
380+
381+
mockCodeFiles([])
382+
await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker)
383+
expect(socket.send).not.toHaveBeenCalled()
384+
})
385+
315386
it("returns an error when rename cannot fetch code files", async () => {
316387
const { api, socket } = setup()
317388

plugins/code-link/src/api.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { canonicalFileName, normalizeCodeFileName, type SyncTracker } from "@code-link/shared"
1+
import { normalizeCodeFileName, type SyncTracker } from "@code-link/shared"
22
import { framer } from "framer-plugin"
33
import * as log from "./utils/logger"
44

@@ -26,7 +26,7 @@ export class CodeFilesAPI {
2626
return codeFiles.map(file => {
2727
const source = file.path || file.name
2828
return {
29-
name: canonicalFileName(source),
29+
name: normalizeCodeFileName(source),
3030
content: file.content,
3131
}
3232
})
@@ -111,12 +111,12 @@ export class CodeFilesAPI {
111111

112112
async applyRemoteDelete(fileName: string) {
113113
await deleteFramerFile(fileName)
114-
this.lastSnapshot.delete(canonicalFileName(fileName))
114+
this.lastSnapshot.delete(normalizeCodeFileName(fileName))
115115
}
116116

117117
async readCurrentContent(fileName: string) {
118118
const files = await this.getCodeFilesWithCanonicalNames()
119-
const normalizedName = canonicalFileName(fileName)
119+
const normalizedName = normalizeCodeFileName(fileName)
120120
return files.find(file => file.name === normalizedName)?.content
121121
}
122122

@@ -136,7 +136,7 @@ export class CodeFilesAPI {
136136

137137
const versionPromises = requests.map(async request => {
138138
const file = codeFiles.find(
139-
f => canonicalFileName(f.path || f.name) === canonicalFileName(request.fileName)
139+
f => normalizeCodeFileName(f.path || f.name) === normalizeCodeFileName(request.fileName)
140140
)
141141

142142
if (!file) {
@@ -174,7 +174,7 @@ export class CodeFilesAPI {
174174
}
175175

176176
async applyRemoteRename(oldFileName: string, newFileName: string, socket: WebSocket): Promise<boolean> {
177-
const sourceFileName = canonicalFileName(oldFileName)
177+
const sourceFileName = normalizeCodeFileName(oldFileName)
178178
const targetFileName = normalizeCodeFileName(newFileName)
179179

180180
let codeFiles
@@ -193,7 +193,7 @@ export class CodeFilesAPI {
193193
return false
194194
}
195195

196-
const existing = codeFiles.find(file => canonicalFileName(file.path || file.name) === sourceFileName)
196+
const existing = codeFiles.find(file => normalizeCodeFileName(file.path || file.name) === sourceFileName)
197197

198198
if (!existing) {
199199
this.lastSnapshot.delete(sourceFileName)
@@ -242,7 +242,7 @@ export class CodeFilesAPI {
242242
async function upsertFramerFile(fileName: string, content: string): Promise<number | undefined> {
243243
const normalisedName = normalizeCodeFileName(fileName)
244244
const codeFiles = await framer.getCodeFiles()
245-
const existing = codeFiles.find(file => canonicalFileName(file.path || file.name) === normalisedName)
245+
const existing = codeFiles.find(file => normalizeCodeFileName(file.path || file.name) === normalisedName)
246246

247247
if (existing) {
248248
await existing.setFileContent(content)
@@ -257,9 +257,9 @@ async function upsertFramerFile(fileName: string, content: string): Promise<numb
257257
}
258258

259259
async function deleteFramerFile(fileName: string) {
260-
const normalisedName = canonicalFileName(fileName)
260+
const normalisedName = normalizeCodeFileName(fileName)
261261
const codeFiles = await framer.getCodeFiles()
262-
const existing = codeFiles.find(file => canonicalFileName(file.path || file.name) === normalisedName)
262+
const existing = codeFiles.find(file => normalizeCodeFileName(file.path || file.name) === normalisedName)
263263

264264
if (existing) {
265265
await existing.remove()

plugins/code-link/src/messages.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {
2+
type CliToPluginMessage,
3+
type ConflictSummary,
4+
type Mode,
5+
normalizeCodeFileName,
6+
type PendingDelete,
7+
type SyncTracker,
8+
} from "@code-link/shared"
9+
import type { CodeFilesAPI } from "./api"
10+
import * as log from "./utils/logger"
11+
12+
type MessageHandlerAction =
13+
| { type: "set-mode"; mode: Mode }
14+
| { type: "pending-deletes"; files: PendingDelete[] }
15+
| { type: "conflicts"; conflicts: ConflictSummary[] }
16+
17+
export function createMessageHandler({
18+
dispatch,
19+
api,
20+
syncTracker,
21+
}: {
22+
dispatch: (action: MessageHandlerAction) => void
23+
api: CodeFilesAPI
24+
syncTracker: SyncTracker
25+
}) {
26+
return async function handleMessage(message: CliToPluginMessage, socket: WebSocket) {
27+
log.debug("Handling message:", message.type)
28+
29+
switch (message.type) {
30+
case "request-files":
31+
log.debug("Publishing snapshot to CLI")
32+
await api.publishSnapshot(socket)
33+
dispatch({
34+
type: "set-mode",
35+
mode: "syncing",
36+
})
37+
break
38+
case "file-change":
39+
log.debug("Applying remote change:", message.fileName)
40+
await api.applyRemoteChange(message.fileName, message.content, socket)
41+
syncTracker.remember(normalizeCodeFileName(message.fileName), message.content)
42+
dispatch({ type: "set-mode", mode: "idle" })
43+
break
44+
case "file-rename": {
45+
const { oldFileName, newFileName, content } = message
46+
log.debug(`Renaming file: ${oldFileName}${newFileName}`)
47+
if (await api.applyRemoteRename(oldFileName, newFileName, socket)) {
48+
syncTracker.forget(normalizeCodeFileName(oldFileName))
49+
syncTracker.remember(normalizeCodeFileName(newFileName), content)
50+
}
51+
dispatch({ type: "set-mode", mode: "idle" })
52+
break
53+
}
54+
case "file-delete":
55+
if (message.requireConfirmation) {
56+
log.debug(`Delete requires confirmation for ${message.fileNames.length} file(s)`)
57+
const files: PendingDelete[] = []
58+
for (const fileName of message.fileNames) {
59+
const content = await api.readCurrentContent(fileName)
60+
// Only include files that exist in Framer (have content to restore)
61+
if (content !== undefined) {
62+
files.push({ fileName, content })
63+
}
64+
}
65+
if (files.length === 0) {
66+
// No files exist in Framer, nothing to confirm
67+
break
68+
}
69+
dispatch({
70+
type: "pending-deletes",
71+
files,
72+
})
73+
} else {
74+
for (const fileName of message.fileNames) {
75+
log.debug("Deleting file:", fileName)
76+
await api.applyRemoteDelete(fileName)
77+
}
78+
}
79+
break
80+
case "conflicts-detected":
81+
log.debug(`Received ${message.conflicts.length} conflicts from CLI`)
82+
dispatch({ type: "conflicts", conflicts: message.conflicts })
83+
break
84+
case "conflict-version-request": {
85+
log.debug(`Fetching conflict versions for ${message.conflicts.length} files`)
86+
const versions = await api.fetchConflictVersions(message.conflicts)
87+
log.debug(`Sending version response for ${versions.length} files`)
88+
socket.send(
89+
JSON.stringify({
90+
type: "conflict-version-response",
91+
versions,
92+
})
93+
)
94+
break
95+
}
96+
case "sync-complete":
97+
log.debug("Sync complete, transitioning to idle")
98+
dispatch({ type: "set-mode", mode: "idle" })
99+
break
100+
default:
101+
log.warn("Unknown message type:", (message as unknown as { type: string }).type)
102+
break
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)