diff options
| author | haoyuren <13851610112@163.com> | 2026-04-25 17:21:27 -0300 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-04-25 17:21:27 -0300 |
| commit | 59cb73960ee68a47adbdc05915847cb8d45f795e (patch) | |
| tree | 1e9a0b0cec3f68ab9d283898232e7408643e7ac1 /src/renderer | |
| parent | 9b5256718c2117511f0253a656bb8cff7410b92a (diff) | |
Fix Overleaf file tree sync updates
Diffstat (limited to 'src/renderer')
| -rw-r--r-- | src/renderer/src/App.tsx | 22 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 1 | ||||
| -rw-r--r-- | src/renderer/src/components/FileTree.tsx | 20 | ||||
| -rw-r--r-- | src/renderer/src/ot/cmAdapter.ts | 21 | ||||
| -rw-r--r-- | src/renderer/src/ot/otClient.ts | 52 | ||||
| -rw-r--r-- | src/renderer/src/ot/overleafSync.ts | 45 | ||||
| -rw-r--r-- | src/renderer/src/utils/projectEntitySync.ts | 423 |
7 files changed, 509 insertions, 75 deletions
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1905e79..808176b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -18,6 +18,12 @@ import SearchPanel from './components/SearchPanel' import StatusBar from './components/StatusBar' import type { OverleafDocSync } from './ot/overleafSync' import { colorForUser, type RemoteCursor } from './extensions/remoteCursors' +import { + applyEntityCreated, + applyEntityMoved, + applyEntityRemoved, + applyEntityRenamed, +} from './utils/projectEntitySync' export const activeDocSyncs = new Map<string, OverleafDocSync>() @@ -106,12 +112,11 @@ export default function App() { if (sync) sync.replaceContent(data.content, data.baseContent) }) - // Listen for new docs created locally (e.g. by Claude Code) - const unsubNewDoc = window.api.onSyncNewDoc((data) => { - if (data.docId) { - useAppStore.getState().addDocPath(data.docId, data.relPath) - } - }) + // Keep the file tree in sync with Overleaf project-entity socket events. + const unsubEntityCreated = window.api.onSyncEntityCreated(applyEntityCreated) + const unsubEntityRemoved = window.api.onSyncEntityRemoved(applyEntityRemoved) + const unsubEntityRenamed = window.api.onSyncEntityRenamed(applyEntityRenamed) + const unsubEntityMoved = window.api.onSyncEntityMoved(applyEntityMoved) // Listen for initial comment data (threads + contexts) from background fetch on connect const unsubInitThreads = window.api.onCommentsInitThreads?.((data) => { @@ -202,7 +207,10 @@ export default function App() { unsubState() unsubRejoined() unsubExternalEdit() - unsubNewDoc() + unsubEntityCreated() + unsubEntityRemoved() + unsubEntityRenamed() + unsubEntityMoved() unsubInitThreads?.() unsubInitContexts?.() unsubCommentsEvent?.() diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx index 97adf1b..b8075f7 100644 --- a/src/renderer/src/components/Editor.tsx +++ b/src/renderer/src/components/Editor.tsx @@ -218,6 +218,7 @@ export default function Editor() { const docId = pathDocMap[activeTab] const version = docId ? docVersions[docId] : undefined if (docId && version !== undefined) { + window.api.otAttachDoc(docId) const docSync = new OverleafDocSync(docId, version) docSyncRef.current = docSync activeDocSyncs.set(docId, docSync) diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx index a95b0c3..1b9ce5e 100644 --- a/src/renderer/src/components/FileTree.tsx +++ b/src/renderer/src/components/FileTree.tsx @@ -176,8 +176,6 @@ export default function FileTree() { const result = await window.api.overleafRenameEntity(projectId, entityType, entityId, newName.trim()) if (result.success) { useAppStore.getState().setStatusMessage(`Renamed to ${newName.trim()}`) - // Reconnect to refresh file tree - await reconnectProject(projectId) } else { useAppStore.getState().setStatusMessage(`Rename failed: ${result.message}`) } @@ -210,7 +208,6 @@ export default function FileTree() { const result = await window.api.overleafDeleteEntity(projectId, entityType, entityId) if (result.success) { useAppStore.getState().setStatusMessage(`Deleted ${node.name}`) - await reconnectProject(projectId) } else { useAppStore.getState().setStatusMessage(`Delete failed: ${result.message}`) } @@ -233,7 +230,6 @@ export default function FileTree() { const result = await window.api.overleafCreateDoc(projectId, parentId, name.trim()) if (result.success) { useAppStore.getState().setStatusMessage(`Created ${name.trim()}`) - await reconnectProject(projectId) } else { useAppStore.getState().setStatusMessage(`Create failed: ${result.message}`) } @@ -256,7 +252,6 @@ export default function FileTree() { const result = await window.api.overleafCreateFolder(projectId, parentId, name.trim()) if (result.success) { useAppStore.getState().setStatusMessage(`Created folder ${name.trim()}`) - await reconnectProject(projectId) } else { useAppStore.getState().setStatusMessage(`Create failed: ${result.message}`) } @@ -304,8 +299,6 @@ export default function FileTree() { } } - // Refresh file tree - await reconnectProject(projectId) }, []) const handleOpenInOverleaf = () => { @@ -376,16 +369,3 @@ export default function FileTree() { </div> ) } - -/** Reconnect to refresh the file tree after a file operation */ -async function reconnectProject(projectId: string) { - const result = await window.api.otConnect(projectId) - if (result.success) { - const store = useAppStore.getState() - if (result.files) store.setFiles(result.files as any) - if (result.project) store.setOverleafProject(result.project) - if (result.docPathMap && result.pathDocMap) store.setDocMaps(result.docPathMap, result.pathDocMap) - if (result.fileRefs) store.setFileRefs(result.fileRefs) - if (result.rootFolderId) store.setRootFolderId(result.rootFolderId) - } -} diff --git a/src/renderer/src/ot/cmAdapter.ts b/src/renderer/src/ot/cmAdapter.ts index 87c23bf..640217e 100644 --- a/src/renderer/src/ot/cmAdapter.ts +++ b/src/renderer/src/ot/cmAdapter.ts @@ -47,25 +47,14 @@ export function changeSetToOtOps(changes: ChangeSet, oldDoc: Text): OtOp[] { */ export function otOpsToChangeSpec(ops: OtOp[]): ChangeSpec[] { const specs: ChangeSpec[] = [] - // Sort ops by position (process in order). Inserts before deletes at same position. - const sorted = [...ops].filter(op => isInsert(op) || isDelete(op)).sort((a, b) => { - if (a.p !== b.p) return a.p - b.p - // Inserts before deletes at same position - if (isInsert(a) && isDelete(b)) return -1 - if (isDelete(a) && isInsert(b)) return 1 - return 0 - }) - - // We need to adjust positions as we apply ops sequentially - let posShift = 0 - for (const op of sorted) { + // Overleaf/ShareJS text ops are sequential: every component position is + // relative to the document after previous components in the same op. + for (const op of ops) { if (isInsert(op)) { - specs.push({ from: op.p + posShift, insert: op.i }) - posShift += op.i.length + specs.push({ from: op.p, insert: op.i }) } else if (isDelete(op)) { - specs.push({ from: op.p + posShift, to: op.p + posShift + op.d.length }) - posShift -= op.d.length + specs.push({ from: op.p, to: op.p + op.d.length }) } } diff --git a/src/renderer/src/ot/otClient.ts b/src/renderer/src/ot/otClient.ts index 4a0a873..0395c70 100644 --- a/src/renderer/src/ot/otClient.ts +++ b/src/renderer/src/ot/otClient.ts @@ -8,10 +8,16 @@ import { transformOps } from './transform' export type SendFn = (ops: OtOp[], version: number) => void export type ApplyFn = (ops: OtOp[]) => void +interface QueuedRemoteUpdate { + ops: OtOp[] + version: number +} + export class OtClient { private state: OtState private sendFn: SendFn private applyFn: ApplyFn + private queuedRemoteUpdates: QueuedRemoteUpdate[] = [] constructor(version: number, sendFn: SendFn, applyFn: ApplyFn) { this.state = { name: 'synchronized', inflight: null, buffer: null, version } @@ -88,18 +94,38 @@ export class OtClient { break case 'synchronized': - // Unexpected ack in synchronized state, ignore - console.warn('[OtClient] unexpected ack in synchronized state') + // Duplicate ack. The server can send both own-source echoes and + // explicit no-op acks depending on deployment/version. break } + + this.processQueuedRemoteUpdates() } /** Called when server sends a remote operation */ onRemoteOps(ops: OtOp[], newVersion: number) { + // ShareJS update.v is the document version before the op is applied. + // Drop duplicates and queue out-of-order messages until their base version + // catches up, matching Overleaf's in-order processing. + if (newVersion < this.state.version) { + return + } + if (newVersion > this.state.version) { + this.queueRemoteUpdate(ops, newVersion) + return + } + + this.applyRemoteOps(ops, newVersion) + this.processQueuedRemoteUpdates() + } + + private applyRemoteOps(ops: OtOp[], newVersion: number) { + const nextVersion = newVersion + 1 + switch (this.state.name) { case 'synchronized': // Apply directly - this.state = { ...this.state, version: newVersion } + this.state = { ...this.state, version: nextVersion } this.applyFn(ops) break @@ -109,7 +135,7 @@ export class OtClient { this.state = { ...this.state, inflight: transformedInflight, - version: newVersion + version: nextVersion } this.applyFn(transformedRemote) break @@ -123,7 +149,7 @@ export class OtClient { ...this.state, inflight: inflightAfterRemote, buffer: bufferAfterRemote, - version: newVersion + version: nextVersion } this.applyFn(remoteAfterBuffer) break @@ -131,8 +157,24 @@ export class OtClient { } } + private queueRemoteUpdate(ops: OtOp[], version: number) { + if (this.queuedRemoteUpdates.some((update) => update.version === version)) return + this.queuedRemoteUpdates.push({ ops, version }) + this.queuedRemoteUpdates.sort((a, b) => a.version - b.version) + } + + private processQueuedRemoteUpdates() { + let nextIndex = this.queuedRemoteUpdates.findIndex((update) => update.version === this.state.version) + while (nextIndex !== -1) { + const [next] = this.queuedRemoteUpdates.splice(nextIndex, 1) + this.applyRemoteOps(next.ops, next.version) + nextIndex = this.queuedRemoteUpdates.findIndex((update) => update.version === this.state.version) + } + } + /** Reset to a known version (e.g. after reconnect) */ reset(version: number) { this.state = { name: 'synchronized', inflight: null, buffer: null, version } + this.queuedRemoteUpdates = [] } } diff --git a/src/renderer/src/ot/overleafSync.ts b/src/renderer/src/ot/overleafSync.ts index 4a6deda..3f4b194 100644 --- a/src/renderer/src/ot/overleafSync.ts +++ b/src/renderer/src/ot/overleafSync.ts @@ -85,13 +85,15 @@ export class OverleafDocSync { const specs = otOpsToChangeSpec(ops) if (specs.length === 0) return - this.view.dispatch({ - changes: specs, - annotations: [ - remoteUpdateAnnotation.of(true), - Transaction.addToHistory.of(false) - ] - }) + for (const changes of specs) { + this.view.dispatch({ + changes, + annotations: [ + remoteUpdateAnnotation.of(true), + Transaction.addToHistory.of(false) + ] + }) + } } /** Called when server acknowledges our ops */ @@ -126,32 +128,21 @@ export class OverleafDocSync { } /** Replace entire editor content with new content (external edit from disk). - * If baseContent is provided, does a three-way merge to preserve concurrent - * remote changes that arrived while the disk edit was being debounced. */ - replaceContent(newContent: string, baseContent?: string) { + * Computes a minimal diff from the current editor state to the new content + * and dispatches it as a local transaction (which the OT extension picks up). */ + replaceContent(newContent: string, _baseContent?: string) { if (!this.view) return const currentContent = this.view.state.doc.toString() if (currentContent === newContent) return + // Direct two-way diff: always diff current editor state → new disk content. + // We intentionally do NOT three-way merge with baseContent because the bridge's + // lastKnownContent (used as baseContent) races with onEditorContentChanged and + // frequently doesn't match the editor's actual state, causing patch_apply to + // produce garbled text when it "succeeds" via fuzzy matching. const dmp = new diff_match_patch() - let targetContent = newContent - - // Three-way merge: if editor has diverged from the base (due to remote edits), - // apply only the disk changes (base→new) as patches on top of current editor state - if (baseContent !== undefined && currentContent !== baseContent) { - const patches = dmp.patch_make(baseContent, newContent) - const [merged, results] = dmp.patch_apply(patches, currentContent) - if (results.length > 0 && results.every(r => r)) { - targetContent = merged - } - // If patch failed, fall through to two-way diff (full replacement) - } - - if (currentContent === targetContent) return - - // Use diff to compute minimal changes so comment range positions remap correctly - const diffs = dmp.diff_main(currentContent, targetContent) + const diffs = dmp.diff_main(currentContent, newContent) dmp.diff_cleanupEfficiency(diffs) const changes: ChangeSpec[] = [] diff --git a/src/renderer/src/utils/projectEntitySync.ts b/src/renderer/src/utils/projectEntitySync.ts new file mode 100644 index 0000000..e328438 --- /dev/null +++ b/src/renderer/src/utils/projectEntitySync.ts @@ -0,0 +1,423 @@ +// Copyright (c) 2026 Yuren Hao +// Licensed under AGPL-3.0 - see LICENSE file + +import { useAppStore, type FileNode } from '../stores/appStore' + +export type SyncEntityKind = 'doc' | 'file' | 'folder' + +export interface SyncEntityCreated { + kind: SyncEntityKind + entityId: string + relPath: string + name: string + parentFolderId?: string +} + +export interface SyncEntityRemoved { + kind: SyncEntityKind + entityId: string + relPath: string +} + +export interface SyncEntityRenamed { + kind: SyncEntityKind + entityId: string + oldPath: string + newPath: string + newName: string +} + +export interface SyncEntityMoved { + kind: SyncEntityKind + entityId: string + oldPath: string + newPath: string + parentFolderId: string +} + +function stripTrailingSlash(path: string): string { + return path.replace(/^\/+/, '').replace(/\/+$/, '') +} + +function normalizePath(path: string, kind: SyncEntityKind): string { + const stripped = stripTrailingSlash(path) + if (kind === 'folder') return stripped ? `${stripped}/` : '' + return stripped +} + +function pathParts(path: string, kind: SyncEntityKind): string[] { + const normalized = normalizePath(path, kind) + return stripTrailingSlash(normalized).split('/').filter(Boolean) +} + +function pathMatchesKind(path: string, kind: SyncEntityKind, targetPath: string): boolean { + return normalizePath(path, kind) === normalizePath(targetPath, kind) +} + +function isNodeMatch(node: FileNode, kind: SyncEntityKind, entityId: string, relPath?: string): boolean { + if (kind === 'doc') { + return node.docId === entityId || (!!relPath && !node.isDir && pathMatchesKind(node.path, kind, relPath)) + } + if (kind === 'file') { + return node.fileRefId === entityId || (!!relPath && !node.isDir && pathMatchesKind(node.path, kind, relPath)) + } + return node.folderId === entityId || (!!relPath && node.isDir && pathMatchesKind(node.path, kind, relPath)) +} + +function withNode(nodes: FileNode[], index: number, node: FileNode): FileNode[] { + if (index === -1) return [...nodes, node] + return nodes.map((existing, i) => (i === index ? node : existing)) +} + +function createLeafNode(entity: SyncEntityCreated, existing?: FileNode): FileNode { + const path = normalizePath(entity.relPath, entity.kind) + const base: FileNode = { + name: entity.name, + path, + isDir: entity.kind === 'folder' + } + + if (entity.kind === 'doc') { + base.docId = entity.entityId + } else if (entity.kind === 'file') { + base.fileRefId = entity.entityId + } else { + base.folderId = entity.entityId + base.children = existing?.children ?? [] + } + + return base +} + +function upsertFileTreeNode(files: FileNode[], entity: SyncEntityCreated): FileNode[] { + const parts = pathParts(entity.relPath, entity.kind) + if (parts.length === 0) return files + const parentFolderPath = entity.kind === 'folder' || parts.length <= 1 + ? '' + : `${parts.slice(0, -1).join('/')}/` + + const upsert = (nodes: FileNode[], depth: number, prefix: string): FileNode[] => { + const name = parts[depth] + const isLeaf = depth === parts.length - 1 + const currentPath = isLeaf + ? normalizePath(entity.relPath, entity.kind) + : `${prefix}${name}/` + + const index = nodes.findIndex((node) => ( + node.path === currentPath || (isLeaf && isNodeMatch(node, entity.kind, entity.entityId, entity.relPath)) + )) + const existing = index >= 0 ? nodes[index] : undefined + + if (isLeaf) { + return withNode(nodes, index, createLeafNode(entity, existing)) + } + + const folderNode: FileNode = { + name, + path: currentPath, + isDir: true, + folderId: currentPath === parentFolderPath ? entity.parentFolderId : existing?.folderId, + children: upsert(existing?.children ?? [], depth + 1, currentPath) + } + + return withNode(nodes, index, folderNode) + } + + return upsert(files, 0, '') +} + +function removeFileTreeNode(files: FileNode[], entity: SyncEntityRemoved): FileNode[] { + return files.flatMap((node) => { + if (isNodeMatch(node, entity.kind, entity.entityId, entity.relPath)) return [] + if (node.children) { + return [{ ...node, children: removeFileTreeNode(node.children, entity) }] + } + return [node] + }) +} + +function rewriteNodePath(node: FileNode, oldPath: string, newPath: string): FileNode { + const oldPrefix = normalizePath(oldPath, 'folder') + const newPrefix = normalizePath(newPath, 'folder') + const rewrittenPath = node.path.startsWith(oldPrefix) + ? newPrefix + node.path.slice(oldPrefix.length) + : node.path + + return { + ...node, + path: rewrittenPath, + children: node.children?.map((child) => rewriteNodePath(child, oldPath, newPath)) + } +} + +function renameFileTreeNode(files: FileNode[], entity: SyncEntityRenamed): FileNode[] { + return files.map((node) => { + if (isNodeMatch(node, entity.kind, entity.entityId, entity.oldPath)) { + const nextPath = normalizePath(entity.newPath, entity.kind) + return { + ...node, + name: entity.newName, + path: nextPath, + children: node.isDir + ? node.children?.map((child) => rewriteNodePath(child, entity.oldPath, entity.newPath)) + : node.children + } + } + if (node.children) { + return { ...node, children: renameFileTreeNode(node.children, entity) } + } + return node + }) +} + +function extractFileTreeNode( + files: FileNode[], + kind: SyncEntityKind, + entityId: string, + oldPath: string +): { files: FileNode[]; node: FileNode | null } { + let found: FileNode | null = null + const nextFiles = files.flatMap((node) => { + if (isNodeMatch(node, kind, entityId, oldPath)) { + found = node + return [] + } + if (node.children) { + const result = extractFileTreeNode(node.children, kind, entityId, oldPath) + if (result.node) found = result.node + return [{ ...node, children: result.files }] + } + return [node] + }) + + return { files: nextFiles, node: found } +} + +function insertExistingNode(files: FileNode[], node: FileNode): FileNode[] { + const parts = stripTrailingSlash(node.path).split('/').filter(Boolean) + if (parts.length === 0) return files + + const insert = (nodes: FileNode[], depth: number, prefix: string): FileNode[] => { + const name = parts[depth] + const isLeaf = depth === parts.length - 1 + const currentPath = isLeaf ? node.path : `${prefix}${name}/` + const index = nodes.findIndex((candidate) => candidate.path === currentPath) + + if (isLeaf) { + return withNode(nodes, index, node) + } + + const existing = index >= 0 ? nodes[index] : undefined + const folderNode: FileNode = { + name, + path: currentPath, + isDir: true, + folderId: existing?.folderId, + children: insert(existing?.children ?? [], depth + 1, currentPath) + } + + return withNode(nodes, index, folderNode) + } + + return insert(files, 0, '') +} + +function moveFileTreeNode(files: FileNode[], entity: SyncEntityMoved): FileNode[] { + const { files: withoutNode, node } = extractFileTreeNode(files, entity.kind, entity.entityId, entity.oldPath) + if (!node) return files + + const newPath = normalizePath(entity.newPath, entity.kind) + const movedNode: FileNode = { + ...node, + path: newPath, + children: node.isDir + ? node.children?.map((child) => rewriteNodePath(child, entity.oldPath, entity.newPath)) + : node.children + } + + return insertExistingNode(withoutNode, movedNode) +} + +function isAffectedPath(path: string, kind: SyncEntityKind, relPath: string): boolean { + if (kind !== 'folder') return path === normalizePath(relPath, kind) + const prefix = normalizePath(relPath, 'folder') + return path.startsWith(prefix) +} + +function rewritePath(path: string, kind: SyncEntityKind, oldPath: string, newPath: string): string { + if (kind !== 'folder') { + return path === normalizePath(oldPath, kind) ? normalizePath(newPath, kind) : path + } + + const oldPrefix = normalizePath(oldPath, 'folder') + const newPrefix = normalizePath(newPath, 'folder') + return path.startsWith(oldPrefix) ? newPrefix + path.slice(oldPrefix.length) : path +} + +function rewriteDocMaps( + docPathMap: Record<string, string>, + kind: SyncEntityKind, + entityId: string, + oldPath: string, + newPath: string +) { + const nextDocPathMap: Record<string, string> = {} + const nextPathDocMap: Record<string, string> = {} + + for (const [docId, path] of Object.entries(docPathMap)) { + const rewritten = kind === 'doc' && docId === entityId + ? normalizePath(newPath, 'doc') + : rewritePath(path, kind, oldPath, newPath) + nextDocPathMap[docId] = rewritten + nextPathDocMap[rewritten] = docId + } + + return { docPathMap: nextDocPathMap, pathDocMap: nextPathDocMap } +} + +function rewriteFileRefs( + fileRefs: Array<{ id: string; path: string }>, + kind: SyncEntityKind, + entityId: string, + oldPath: string, + newPath: string +) { + return fileRefs.map((ref) => ({ + ...ref, + path: kind === 'file' && ref.id === entityId + ? normalizePath(newPath, 'file') + : rewritePath(ref.path, kind, oldPath, newPath) + })) +} + +function rewriteOpenState( + openTabs: Array<{ path: string; name: string; modified: boolean }>, + activeTab: string | null, + fileContents: Record<string, string>, + kind: SyncEntityKind, + oldPath: string, + newPath: string +) { + const nextOpenTabs = openTabs.map((tab) => { + const path = rewritePath(tab.path, kind, oldPath, newPath) + return { + ...tab, + path, + name: path.split('/').pop() || tab.name + } + }) + const nextActiveTab = activeTab ? rewritePath(activeTab, kind, oldPath, newPath) : activeTab + const nextFileContents: Record<string, string> = {} + for (const [path, content] of Object.entries(fileContents)) { + nextFileContents[rewritePath(path, kind, oldPath, newPath)] = content + } + + return { openTabs: nextOpenTabs, activeTab: nextActiveTab, fileContents: nextFileContents } +} + +function removeOpenState( + openTabs: Array<{ path: string; name: string; modified: boolean }>, + activeTab: string | null, + fileContents: Record<string, string>, + kind: SyncEntityKind, + relPath: string +) { + const nextOpenTabs = openTabs.filter((tab) => !isAffectedPath(tab.path, kind, relPath)) + const nextActiveTab = activeTab && isAffectedPath(activeTab, kind, relPath) + ? (nextOpenTabs[nextOpenTabs.length - 1]?.path ?? null) + : activeTab + const nextFileContents: Record<string, string> = {} + for (const [path, content] of Object.entries(fileContents)) { + if (!isAffectedPath(path, kind, relPath)) nextFileContents[path] = content + } + + return { openTabs: nextOpenTabs, activeTab: nextActiveTab, fileContents: nextFileContents } +} + +export function applyEntityCreated(entity: SyncEntityCreated): void { + useAppStore.setState((state) => { + const files = upsertFileTreeNode(state.files, entity) + const docPathMap = { ...state.docPathMap } + const pathDocMap = { ...state.pathDocMap } + let fileRefs = state.fileRefs + + if (entity.kind === 'doc') { + const relPath = normalizePath(entity.relPath, entity.kind) + docPathMap[entity.entityId] = relPath + pathDocMap[relPath] = entity.entityId + } else if (entity.kind === 'file') { + const relPath = normalizePath(entity.relPath, entity.kind) + fileRefs = [ + ...state.fileRefs.filter((ref) => ref.id !== entity.entityId && ref.path !== relPath), + { id: entity.entityId, path: relPath } + ] + } + + return { files, docPathMap, pathDocMap, fileRefs } + }) +} + +export function applyEntityRemoved(entity: SyncEntityRemoved): void { + useAppStore.setState((state) => { + const relPath = normalizePath(entity.relPath, entity.kind) + const files = removeFileTreeNode(state.files, entity) + const docPathMap: Record<string, string> = {} + const pathDocMap: Record<string, string> = {} + + for (const [docId, path] of Object.entries(state.docPathMap)) { + if (entity.kind === 'doc' ? docId === entity.entityId : isAffectedPath(path, entity.kind, relPath)) { + continue + } + docPathMap[docId] = path + pathDocMap[path] = docId + } + + const fileRefs = state.fileRefs.filter((ref) => ( + entity.kind === 'file' + ? ref.id !== entity.entityId + : !isAffectedPath(ref.path, entity.kind, relPath) + )) + const openState = removeOpenState(state.openTabs, state.activeTab, state.fileContents, entity.kind, relPath) + const mainDocument = entity.kind === 'doc' && state.mainDocument === entity.entityId + ? null + : state.mainDocument + + return { files, docPathMap, pathDocMap, fileRefs, mainDocument, ...openState } + }) +} + +export function applyEntityRenamed(entity: SyncEntityRenamed): void { + useAppStore.setState((state) => { + const files = renameFileTreeNode(state.files, entity) + const maps = rewriteDocMaps(state.docPathMap, entity.kind, entity.entityId, entity.oldPath, entity.newPath) + const fileRefs = rewriteFileRefs(state.fileRefs, entity.kind, entity.entityId, entity.oldPath, entity.newPath) + const openState = rewriteOpenState( + state.openTabs, + state.activeTab, + state.fileContents, + entity.kind, + entity.oldPath, + entity.newPath + ) + + return { files, ...maps, fileRefs, ...openState } + }) +} + +export function applyEntityMoved(entity: SyncEntityMoved): void { + useAppStore.setState((state) => { + const files = moveFileTreeNode(state.files, entity) + const maps = rewriteDocMaps(state.docPathMap, entity.kind, entity.entityId, entity.oldPath, entity.newPath) + const fileRefs = rewriteFileRefs(state.fileRefs, entity.kind, entity.entityId, entity.oldPath, entity.newPath) + const openState = rewriteOpenState( + state.openTabs, + state.activeTab, + state.fileContents, + entity.kind, + entity.oldPath, + entity.newPath + ) + + return { files, ...maps, fileRefs, ...openState } + }) +} |
