Files
kandev/apps/web/components/task/file-editor-panel.tsx
T
José Almeida 214a8f5cce fix: scope multi-repo file editor open/save to the right repo (#1382)
* fix: scope multi-repo file editor open/save to the right repo

The editor open/save/sync/delete chain dropped the repository_name in
multi-repo task workspaces, so the backend resolved paths against the
bare task root and failed with "file not found". Thread the repo
subpath through openFile, save, sync, and delete, and store it on
FileEditorState.

* fix: pass repo from diff Edit button to multi-repo file editor

The diff/changes "Edit" button opened files without their repository
scope, so multi-repo files resolved against the bare task root and
failed with "file not found". Thread repository_name through the diff
and review toolbars into onOpenFile.

* fix: preserve repo across tab restore and stale syncs

Address review feedback: persisted file tabs dropped the repo subpath,
so a multi-repo file reopened against the bare task root after a browser
refresh. Persist and restore `repo` on StoredFileTab. Also guard the
workspace sync against a tab being swapped to a different repo mid-fetch,
and widen the remaining onOpenFile type declarations to carry repo.

* fix: thread repo into editor panel fallback fetch

Address review feedback: FileEditorPanel's useFileLoader fallback fetch
(triggered when the open-files map is transiently empty, e.g. after a
session switch) called requestFileContent without the repo subpath,
re-introducing the multi-repo "file not found" failure. Record repo on
the panel params and forward it through useFileLoader. Also assert the
full restored-tab shape in the local-storage round-trip test.

* fix: guard editor fetches against stale session/repo races

Address review feedback: file open and the panel fallback fetch could
apply a late response after the user navigated away. openFile now drops
the result if the active session changed mid-fetch (via fetchFileEditorState),
and the panel loader keys its in-flight guard by session+path+repo so a
changed target re-fetches and stale responses are discarded.
2026-06-15 17:59:05 +01:00

254 lines
10 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,
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<string | null>(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<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 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 (
<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>
);
});