"use client"; import { useEffect } from "react"; import type { FileEditorState } from "@/lib/state/dockview-store"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileContent } from "@/lib/ws/workspace-files"; import { calculateHash } from "@/lib/utils/file-diff"; import { updatePanelAfterSave } from "./use-file-save-delete"; import type { FileInfo } from "@/lib/state/store"; import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; function buildChangeFacetSignature(facet: FileInfo["staged_change"]): string { if (!facet) return ""; return [ facet.status, facet.is_symlink ?? "", facet.additions ?? 0, facet.deletions ?? 0, facet.old_path ?? "", facet.diff ?? "", facet.diff_skip_reason ?? "", ].join("\0"); } /** * Builds a stable signature string from a file's git status entry. The hook * compares signatures across renders to decide whether the editor's content * needs re-syncing from the workspace. */ export function buildGitFileSignature(file: FileInfo | undefined): string { if (!file) return "__clean__"; return [ file.status ?? "", file.is_symlink ?? "", file.staged ? "1" : "0", String(file.additions ?? 0), String(file.deletions ?? 0), file.old_path ?? "", file.diff ?? "", buildChangeFacetSignature(file.staged_change), buildChangeFacetSignature(file.unstaged_change), ].join("|"); } export type SyncOpenFileArgs = { client: ReturnType; sessionId: string; fileKey: string; path: string; repo?: string; updateFileState: (path: string, updates: Partial) => void; }; /** * Re-fetches file content from the workspace and reconciles it with the open * editor buffer. Behavior depends on dirty state: * - Clean buffer: replaces `content` + `originalContent` so the editor shows * the latest disk state without losing user focus/cursor. * - Dirty buffer with matching remote content: clears dirty flag (the user's * edits happen to equal what landed on disk). * - Dirty buffer with different remote content: surfaces a "Reload" affordance * via `hasRemoteUpdate` so the user explicitly chooses to clobber edits. */ export async function syncOpenFileFromWorkspace({ client, sessionId, fileKey, path, repo, updateFileState, }: SyncOpenFileArgs): Promise { if (!client) return; try { const response = await requestFileContent(client, sessionId, path, repo); const latest = useDockviewStore.getState().openFiles.get(fileKey); if (!latest) return; // The tab may have been swapped to a different repo's file at the same path // key while the fetch was in flight; the response is then for the old repo. // Drop it rather than writing stale content into the new buffer. if (latest.repo !== repo) return; const remoteHash = await calculateHash(response.content); if (latest.isDirty) { if (response.content === latest.content) { updateFileState(fileKey, { resolvedPath: response.resolved_path, originalContent: response.content, originalHash: remoteHash, isDirty: false, hasRemoteUpdate: false, remoteContent: undefined, remoteOriginalHash: undefined, }); updatePanelAfterSave(path, latest.name, latest.repo); return; } if ( latest.hasRemoteUpdate && latest.remoteContent === response.content && latest.resolvedPath === response.resolved_path ) return; updateFileState(fileKey, { resolvedPath: response.resolved_path, hasRemoteUpdate: true, remoteContent: response.content, remoteOriginalHash: remoteHash, }); return; } if ( latest.content === response.content && latest.originalHash === remoteHash && latest.resolvedPath === response.resolved_path && !latest.hasRemoteUpdate ) { return; } updateFileState(fileKey, { resolvedPath: response.resolved_path, content: response.content, originalContent: response.content, originalHash: remoteHash, isDirty: false, isBinary: response.is_binary, hasRemoteUpdate: false, remoteContent: undefined, remoteOriginalHash: undefined, }); } catch { // Ignore sync failures; user can continue editing. } } export type OpenFileWorkspaceSyncParams = { gitStatus: GitStatusEntry | undefined; openFiles: Map; updateFileState: (path: string, updates: Partial) => void; activeSessionIdRef: React.MutableRefObject; gitFileSignaturesRef: React.MutableRefObject>; }; /** * Watches gitStatus + openFiles and refetches open editor buffers from disk * whenever a file's git signature changes (the agent edited it, the user ran * git pull, etc.). Skips the very first observation per file so that opening * a file doesn't trigger an immediate redundant refetch. */ export function useOpenFileWorkspaceSync({ gitStatus, openFiles, updateFileState, activeSessionIdRef, gitFileSignaturesRef, }: OpenFileWorkspaceSyncParams) { useEffect(() => { const sigMap = gitFileSignaturesRef.current; for (const path of Array.from(sigMap.keys())) { if (!openFiles.has(path)) sigMap.delete(path); } }, [openFiles, gitFileSignaturesRef]); useEffect(() => { const client = getWebSocketClient(); const sessionId = activeSessionIdRef.current; if (!client || !sessionId) return; const gitFiles = gitStatus?.files ?? {}; const sigMap = gitFileSignaturesRef.current; for (const [fileKey, file] of openFiles.entries()) { if ((gitStatus?.repository_name ?? "") !== (file.repo ?? "")) continue; // For symlinks, also check the resolved target path in git status const gitFileInfo = (gitFiles[file.path] ?? (file.resolvedPath ? gitFiles[file.resolvedPath] : undefined)) as FileInfo | undefined; const nextSignature = buildGitFileSignature(gitFileInfo); const prevSignature = sigMap.get(fileKey); if (prevSignature === undefined) { sigMap.set(fileKey, nextSignature); continue; } if (prevSignature === nextSignature) continue; sigMap.set(fileKey, nextSignature); void syncOpenFileFromWorkspace({ client, sessionId, fileKey, path: file.path, repo: file.repo, updateFileState, }); } }, [gitStatus, openFiles, updateFileState, activeSessionIdRef, gitFileSignaturesRef]); }