Files
kandev/apps/web/components/task/file-editor-panel.tsx
T

244 lines
9.4 KiB
TypeScript

"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<string | null>(null);
const content =
imageContent ??
(() => {
const c = useDockviewStore.getState().openFiles.get(path)?.content ?? "";
queueMicrotask(() => setImageContent(c));
return c;
})();
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<FileImageViewer path={path} content={content} worktreePath={worktreePath} />
</PanelBody>
</PanelRoot>
);
}
function useFileLoader(
hasFile: boolean,
activeSessionId: string | null,
path: string,
setFileState: (path: string, state: FileEditorState) => void,
) {
const loadingRef = useRef(false);
useEffect(() => {
if (hasFile || loadingRef.current || !activeSessionId) return;
loadingRef.current = true;
const client = getWebSocketClient();
if (!client) {
loadingRef.current = false;
return;
}
requestFileContent(client, activeSessionId, path)
.then(async (response) => {
const hash = await calculateHash(response.content);
const name = path.split("/").pop() || path;
const state: FileEditorState = {
path,
name,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
};
setFileState(path, state);
})
.catch(() => {
/* stays on loading state */
})
.finally(() => {
loadingRef.current = false;
});
}, [hasFile, activeSessionId, path, setFileState]);
}
/**
* 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<FileEditorState>) => 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<string, unknown>;
};
export const FileEditorPanel = memo(function FileEditorPanel({
panelId,
params,
}: FileEditorPanelProps) {
const path = params.path as string;
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);
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 (
<PanelRoot>
<PanelBody
padding={false}
scroll={false}
className="flex items-center justify-center text-muted-foreground text-sm"
>
Loading file...
</PanelBody>
</PanelRoot>
);
}
const worktreePath = activeSession?.worktree_path ?? undefined;
const category = resolveFileCategory(isBinary, path);
if (category === "image") return <ImagePanel path={path} worktreePath={worktreePath} />;
if (category === "binary") {
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<FileBinaryViewer path={path} worktreePath={worktreePath} />
</PanelBody>
</PanelRoot>
);
}
const isMarkdown = isMarkdownFile(path);
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<FileEditorContent
path={path}
content={content}
originalContent={originalContent}
isDirty={isDirty}
hasRemoteUpdate={hasRemoteUpdate}
vcsDiff={vcsDiff}
isSaving={savingFiles.has(path)}
sessionId={activeSessionId || undefined}
worktreePath={worktreePath}
enableComments={!!activeSessionId}
markdownPreview={isMarkdown ? markdownPreview : false}
onToggleMarkdownPreview={isMarkdown ? onToggleMarkdownPreview : undefined}
onChange={onChange}
onSave={onSave}
onReloadFromAgent={onReloadFromAgent}
onDelete={onDelete}
/>
</PanelBody>
</PanelRoot>
);
});