"use client"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { FileEditorContent } from "./file-editor-content"; import { FileImageViewer } from "./file-image-viewer"; import { FileBinaryViewer } from "./file-binary-viewer"; import { useAppStore } from "@/components/state-provider"; import { useDockviewStore, type FileEditorState } from "@/lib/state/dockview-store"; import { useFileEditors } from "@/hooks/use-file-editors"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import { getFileCategory } from "@/lib/utils/file-types"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileContent } from "@/lib/ws/workspace-files"; import { calculateHash } from "@/lib/utils/file-diff"; import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; import { syncOpenFileFromWorkspace } from "@/hooks/file-editors-sync"; type FileCategory = "image" | "binary" | "text"; function isMarkdownFile(path: string): boolean { const ext = path.split(".").pop()?.toLowerCase(); return ext === "md" || ext === "mdx"; } function resolveFileCategory(isBinary: boolean, path: string): FileCategory { if (!isBinary) return "text"; return getFileCategory(path) === "image" ? "image" : "binary"; } function ImagePanel({ path, worktreePath }: { path: string; worktreePath: string | undefined }) { const [imageContent, setImageContent] = useState(null); const content = imageContent ?? (() => { const c = useDockviewStore.getState().openFiles.get(path)?.content ?? ""; queueMicrotask(() => setImageContent(c)); return c; })(); return ( ); } function useFileLoader( hasFile: boolean, activeSessionId: string | null, path: string, setFileState: (path: string, state: FileEditorState) => void, repo?: string, ) { // Key the in-flight guard by session+path+repo. If any of them changes while // a fetch is running, the new effect run starts a fresh fetch (rather than // being silently blocked) and the stale response is dropped on arrival — so // content from the wrong session/repo can never land in the buffer. const inFlightKeyRef = useRef(null); useEffect(() => { if (hasFile || !activeSessionId) return; const key = `${activeSessionId}${path}${repo ?? ""}`; if (inFlightKeyRef.current === key) return; inFlightKeyRef.current = key; const client = getWebSocketClient(); if (!client) { inFlightKeyRef.current = null; return; } requestFileContent(client, activeSessionId, path, repo) .then(async (response) => { if (inFlightKeyRef.current !== key) return; const hash = await calculateHash(response.content); const name = path.split("/").pop() || path; const state: FileEditorState = { path, repo, name, content: response.content, originalContent: response.content, originalHash: hash, isDirty: false, isBinary: response.is_binary, }; setFileState(path, state); }) .catch(() => { /* stays on loading state */ }) .finally(() => { if (inFlightKeyRef.current === key) inFlightKeyRef.current = null; }); }, [hasFile, activeSessionId, path, setFileState, repo]); } /** * Force a workspace sync whenever the panel becomes the active dockview tab. * * Background: `useOpenFileWorkspaceSync` (mounted at the parent useFileEditors * level) refetches file content when gitStatus signatures change. That signal * arrives via the backend's workspace_tracker poll loop, which can be in * `PollModeSlow` (30s interval) until the gateway's focus signal upgrades it * to `PollModeFast` — there are two documented races in * `manager_subscription.go:FlushSessionMode` where the focus signal can miss * the mode upgrade for a brief window. When the missed window lines up with a * file edit, the editor shows stale content until the next slow-poll cycle. * * Tab activation is a deterministic, user-driven signal that the editor's * content is about to be looked at. Forcing a sync on activation closes the * WS-event-miss gap without depending on git polling cadence. * * Safe by construction: syncOpenFileFromWorkspace is dirty-buffer aware — * clean buffers get their content replaced, dirty buffers surface a Reload * affordance via `hasRemoteUpdate` rather than clobbering edits. */ function useResyncOnTabActivate( panelId: string, hasFile: boolean, activeSessionId: string | null, path: string, updateFileState: (path: string, updates: Partial) => void, ) { useEffect(() => { if (!hasFile || !activeSessionId) return; // panelPortalManager.acquire() runs in usePortalSlot's mount effect (the // dockview-side slot), which fires before child portals' effects, so the // entry is virtually always present here. There is one acceptable miss: // a fromJSON layout restore can swap `entry.api` for the same panelId // without remounting this component, which would silently leave the // subscription pointing at a disposed api. fromJSON is rare and // `useOpenFileWorkspaceSync` still covers the common polling gap, so we // accept that edge case rather than wiring a manager-level subscription. const entry = panelPortalManager.get(panelId); if (!entry?.api) return; const syncNow = () => { const client = getWebSocketClient(); if (!client) return; void syncOpenFileFromWorkspace({ client, sessionId: activeSessionId, path, updateFileState }); }; // If the panel is already the active tab when this effect first runs, // onDidActiveChange won't fire (no transition), but the user is already // looking at the editor — sync immediately so the initial open path // benefits from the same WS-event-miss recovery as later activations. if (entry.api.isActive) syncNow(); const disposable = entry.api.onDidActiveChange((event) => { if (event.isActive) syncNow(); }); return () => disposable.dispose(); }, [panelId, hasFile, activeSessionId, path, updateFileState]); } type FileEditorPanelProps = { panelId: string; params: Record; }; export const FileEditorPanel = memo(function FileEditorPanel({ panelId, params, }: FileEditorPanelProps) { const path = params.path as string; const repo = params.repo as string | undefined; const hasFile = useDockviewStore((s) => s.openFiles.has(path)); const content = useDockviewStore((s) => s.openFiles.get(path)?.content ?? ""); const isDirty = useDockviewStore((s) => s.openFiles.get(path)?.isDirty ?? false); const hasRemoteUpdate = useDockviewStore((s) => s.openFiles.get(path)?.hasRemoteUpdate ?? false); const isBinary = useDockviewStore((s) => s.openFiles.get(path)?.isBinary ?? false); const originalContent = useDockviewStore((s) => s.openFiles.get(path)?.originalContent ?? ""); const markdownPreview = useDockviewStore((s) => s.openFiles.get(path)?.markdownPreview ?? false); const setFileState = useDockviewStore((s) => s.setFileState); const updateFileState = useDockviewStore((s) => s.updateFileState); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const activeSession = useAppStore((state) => activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null, ); const gitStatus = useSessionGitStatus(activeSessionId); const vcsDiff = gitStatus?.files?.[path]?.diff; const { savingFiles, handleFileChange, saveFile, deleteFile, applyRemoteUpdate } = useFileEditors(); useFileLoader(hasFile, activeSessionId, path, setFileState, repo); useResyncOnTabActivate(panelId, hasFile, activeSessionId, path, updateFileState); const onChange = useCallback( (newContent: string) => handleFileChange(path, newContent), [handleFileChange, path], ); const onSave = useCallback(() => saveFile(path), [saveFile, path]); const onReloadFromAgent = useCallback(() => applyRemoteUpdate(path), [applyRemoteUpdate, path]); const onDelete = useCallback(() => deleteFile(path), [deleteFile, path]); const onToggleMarkdownPreview = useCallback( () => updateFileState(path, { markdownPreview: !markdownPreview }), [updateFileState, path, markdownPreview], ); if (!hasFile) { return ( Loading file... ); } const worktreePath = activeSession?.worktree_path ?? undefined; const category = resolveFileCategory(isBinary, path); if (category === "image") return ; if (category === "binary") { return ( ); } const isMarkdown = isMarkdownFile(path); return ( ); });