214a8f5cce
* 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.
519 lines
18 KiB
TypeScript
519 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useDockviewStore, type FileEditorState } from "@/lib/state/dockview-store";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { requestFileContent } from "@/lib/ws/workspace-files";
|
|
import {
|
|
getOpenFileTabs,
|
|
setOpenFileTabs as saveOpenFileTabs,
|
|
getActiveTabForSession,
|
|
setActiveTabForSession,
|
|
} from "@/lib/local-storage";
|
|
import { calculateHash } from "@/lib/utils/file-diff";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
|
import type { FileContentResponse } from "@/lib/types/backend";
|
|
import { useSaveDeleteActions } from "./use-file-save-delete";
|
|
import { PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
|
|
import { useOpenFileWorkspaceSync } from "./file-editors-sync";
|
|
|
|
// Module-level guard: ensures restoration only runs once across all hook instances
|
|
let _restoredSessionId: string | null = null;
|
|
let _restorationInProgress = false;
|
|
|
|
// Pending cursor positions: set before opening a file, consumed by the editor on mount.
|
|
// Used by LSP Go-to-Definition to jump to the correct line/column.
|
|
const _pendingCursorPositions = new Map<string, { line: number; column: number }>();
|
|
|
|
export function setPendingCursorPosition(path: string, line: number, column: number) {
|
|
_pendingCursorPositions.set(path, { line, column });
|
|
}
|
|
|
|
export function consumePendingCursorPosition(
|
|
path: string,
|
|
): { line: number; column: number } | undefined {
|
|
const pos = _pendingCursorPositions.get(path);
|
|
if (pos) _pendingCursorPositions.delete(path);
|
|
return pos;
|
|
}
|
|
|
|
/** Read openFiles from the store without subscribing to changes. */
|
|
function getOpenFiles() {
|
|
return useDockviewStore.getState().openFiles;
|
|
}
|
|
|
|
/**
|
|
* Build a FileEditorState from a file content response. `repo` is the
|
|
* multi-repo subpath (repository_name) the file belongs to; it is recorded on
|
|
* the state so subsequent save/sync/delete requests stay scoped to the right
|
|
* repository instead of resolving against the bare task root.
|
|
*/
|
|
export async function buildFileEditorState(
|
|
filePath: string,
|
|
response: FileContentResponse,
|
|
repo?: string,
|
|
): Promise<FileEditorState> {
|
|
const fileName = filePath.split("/").pop() || filePath;
|
|
const hash = await calculateHash(response.content);
|
|
return {
|
|
path: filePath,
|
|
repo,
|
|
name: fileName,
|
|
content: response.content,
|
|
originalContent: response.content,
|
|
originalHash: hash,
|
|
isDirty: false,
|
|
isBinary: response.is_binary,
|
|
resolvedPath: response.resolved_path,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch a file's content and build its editor state, returning null if the
|
|
* active session changed while the request was in flight — a late response must
|
|
* not write content for a file the user has navigated away from.
|
|
*/
|
|
export async function fetchFileEditorState(
|
|
client: NonNullable<ReturnType<typeof getWebSocketClient>>,
|
|
sessionId: string,
|
|
filePath: string,
|
|
repo: string | undefined,
|
|
activeSessionIdRef: React.MutableRefObject<string | null>,
|
|
): Promise<FileEditorState | null> {
|
|
const response = await requestFileContent(client, sessionId, filePath, repo);
|
|
if (activeSessionIdRef.current !== sessionId) return null;
|
|
return buildFileEditorState(filePath, response, repo);
|
|
}
|
|
|
|
/**
|
|
* Apply an editor buffer change, auto-promoting a preview tab to pinned on the
|
|
* first edit so the user's unsaved changes aren't discarded when another file
|
|
* is opened. Promote BEFORE updating state so the openFiles subscription sees
|
|
* the promoted flag when it fires from updateFileState.
|
|
*/
|
|
function applyFileChange(
|
|
path: string,
|
|
newContent: string,
|
|
updateFileState: (path: string, updates: Partial<FileEditorState>) => void,
|
|
promotePreviewToPinned: (type: "file-editor") => void,
|
|
) {
|
|
const file = getOpenFiles().get(path);
|
|
if (!file) return;
|
|
const nextIsDirty = newContent !== file.originalContent;
|
|
if (nextIsDirty && !file.isDirty) {
|
|
const preview = useDockviewStore.getState().api?.getPanel(PREVIEW_FILE_EDITOR_ID);
|
|
if ((preview?.params as Record<string, unknown> | undefined)?.previewItemId === path) {
|
|
promotePreviewToPinned("file-editor");
|
|
}
|
|
}
|
|
updateFileState(path, { content: newContent, isDirty: nextIsDirty });
|
|
}
|
|
|
|
/** Build the sessionStorage tab records from live openFiles + dockview state. */
|
|
function buildPersistedTabs(
|
|
api: ReturnType<typeof useDockviewStore.getState>["api"],
|
|
openFiles: Map<string, FileEditorState>,
|
|
) {
|
|
const preview = api?.getPanel(PREVIEW_FILE_EDITOR_ID);
|
|
const previewParams = preview?.params as Record<string, unknown> | undefined;
|
|
const previewPath = (previewParams?.previewItemId ?? null) as string | null;
|
|
const isPromoted = previewParams?.promoted === true;
|
|
return Array.from(openFiles.values()).flatMap(({ path, name, repo, markdownPreview }) => {
|
|
const isPinned = !!api?.getPanel(`file:${path}`);
|
|
const isPreview = !isPinned && path === previewPath;
|
|
if (!isPinned && !isPreview) return [];
|
|
// Promoted previews persist as pinned so edits survive refresh
|
|
const persistAsPinned = isPinned || (isPreview && isPromoted);
|
|
return [
|
|
{
|
|
path,
|
|
name,
|
|
...(repo ? { repo } : {}),
|
|
...(markdownPreview ? { markdownPreview } : {}),
|
|
pinned: persistAsPinned,
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
type RestoreTabsParams = {
|
|
activeSessionId: string;
|
|
savedTabs: Array<{
|
|
path: string;
|
|
name: string;
|
|
repo?: string;
|
|
markdownPreview?: boolean;
|
|
pinned?: boolean;
|
|
}>;
|
|
savedActiveTab: string;
|
|
setFileState: (path: string, state: FileEditorState) => void;
|
|
addFileEditorPanel: (
|
|
path: string,
|
|
name: string,
|
|
opts?: { quiet?: boolean; pin?: boolean; repo?: string },
|
|
) => void;
|
|
};
|
|
|
|
/**
|
|
* Skip persisted tabs the dockview snapshot already restored (preview slot or
|
|
* existing pinned panel). Re-adding with `pin: true` here would create a
|
|
* duplicate `file:<path>` alongside the existing `preview:file-editor` —
|
|
* see the round-trip task switch where a promoted preview was persisted as
|
|
* pinned in localStorage and then re-opened on top of the restored preview.
|
|
*/
|
|
function isAlreadyRestored(
|
|
dockApi: ReturnType<typeof useDockviewStore.getState>["api"],
|
|
path: string,
|
|
): boolean {
|
|
if (!dockApi) return false;
|
|
if (dockApi.getPanel(`file:${path}`)) return true;
|
|
const previewParams = dockApi.getPanel(PREVIEW_FILE_EDITOR_ID)?.params as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
return previewParams?.previewItemId === path;
|
|
}
|
|
|
|
async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Promise<void> {
|
|
const { activeSessionId, savedTabs, savedActiveTab, setFileState, addFileEditorPanel } = params;
|
|
const client = getWebSocketClient();
|
|
if (!client) {
|
|
if (retryCount < 5) {
|
|
setTimeout(() => loadAndRestoreTabs(params, retryCount + 1), 200);
|
|
return;
|
|
}
|
|
_restorationInProgress = false;
|
|
return;
|
|
}
|
|
if (_restoredSessionId !== activeSessionId) {
|
|
_restorationInProgress = false;
|
|
return;
|
|
}
|
|
// Create all panels immediately so tabs are visible right away.
|
|
// Content is fetched afterwards; if it fails, `useFileLoader` in
|
|
// FileEditorPanel retries when the executor becomes available.
|
|
const dockApi = useDockviewStore.getState().api;
|
|
for (const savedTab of savedTabs) {
|
|
if (isAlreadyRestored(dockApi, savedTab.path)) continue;
|
|
addFileEditorPanel(savedTab.path, savedTab.name, {
|
|
quiet: true,
|
|
pin: savedTab.pinned,
|
|
repo: savedTab.repo,
|
|
});
|
|
// Seed a placeholder file state synchronously, carrying the restored
|
|
// `markdownPreview` flag. This makes `openFiles.has(path)` true the moment
|
|
// FileEditorPanel mounts, which suppresses its own `useFileLoader` fetch.
|
|
// Without this seed, useFileLoader races the per-tab fetch below: both call
|
|
// setFileState (a wholesale replace), and useFileLoader's state has no
|
|
// markdownPreview — so when it wins the race (common under CPU load) the
|
|
// restored preview flag is clobbered and the tab reopens in code view.
|
|
setFileState(savedTab.path, {
|
|
path: savedTab.path,
|
|
repo: savedTab.repo,
|
|
name: savedTab.name,
|
|
content: "",
|
|
originalContent: "",
|
|
originalHash: "",
|
|
isDirty: false,
|
|
markdownPreview: savedTab.markdownPreview,
|
|
});
|
|
}
|
|
for (const savedTab of savedTabs) {
|
|
try {
|
|
const response = await requestFileContent(
|
|
client,
|
|
activeSessionId,
|
|
savedTab.path,
|
|
savedTab.repo,
|
|
);
|
|
const hash = await calculateHash(response.content);
|
|
setFileState(savedTab.path, {
|
|
path: savedTab.path,
|
|
repo: savedTab.repo,
|
|
name: savedTab.name,
|
|
content: response.content,
|
|
originalContent: response.content,
|
|
originalHash: hash,
|
|
isDirty: false,
|
|
isBinary: response.is_binary,
|
|
markdownPreview: savedTab.markdownPreview,
|
|
});
|
|
} catch {
|
|
/* useFileLoader will retry when executor is ready */
|
|
}
|
|
}
|
|
const targetPanel = dockApi?.getPanel(savedActiveTab);
|
|
if (targetPanel) targetPanel.api.setActive();
|
|
_restorationInProgress = false;
|
|
}
|
|
|
|
type FileEditorEffectsParams = {
|
|
activeSessionId: string | null;
|
|
activeSessionIdRef: React.MutableRefObject<string | null>;
|
|
setFileState: (path: string, state: FileEditorState) => void;
|
|
addFileEditorPanel: (
|
|
path: string,
|
|
name: string,
|
|
opts?: { quiet?: boolean; pin?: boolean; repo?: string },
|
|
) => void;
|
|
clearFileStates: () => void;
|
|
removeFileState: (path: string) => void;
|
|
api: ReturnType<typeof useDockviewStore.getState>["api"];
|
|
};
|
|
|
|
function useFileEditorEffects({
|
|
activeSessionId,
|
|
activeSessionIdRef,
|
|
setFileState,
|
|
addFileEditorPanel,
|
|
clearFileStates,
|
|
removeFileState,
|
|
api,
|
|
}: FileEditorEffectsParams) {
|
|
useEffect(() => {
|
|
if (!activeSessionId || _restoredSessionId === activeSessionId) return;
|
|
_restoredSessionId = activeSessionId;
|
|
// Set the flag BEFORE clearing so the openFiles subscription doesn't
|
|
// overwrite saved tabs with an empty list during the clear.
|
|
_restorationInProgress = true;
|
|
clearFileStates();
|
|
const savedTabs = getOpenFileTabs(activeSessionId);
|
|
const savedActiveTab = getActiveTabForSession(activeSessionId, "chat");
|
|
if (savedTabs.length === 0) {
|
|
_restorationInProgress = false;
|
|
return;
|
|
}
|
|
void loadAndRestoreTabs({
|
|
activeSessionId,
|
|
savedTabs,
|
|
savedActiveTab,
|
|
setFileState,
|
|
addFileEditorPanel,
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeSessionId]);
|
|
|
|
useEffect(() => {
|
|
const unsub = useDockviewStore.subscribe((state, prevState) => {
|
|
if (state.openFiles === prevState.openFiles) return;
|
|
const sessionId = activeSessionIdRef.current;
|
|
if (!sessionId || _restorationInProgress || state.isRestoringLayout) return;
|
|
saveOpenFileTabs(sessionId, buildPersistedTabs(state.api, state.openFiles));
|
|
});
|
|
return unsub;
|
|
}, [activeSessionIdRef]);
|
|
|
|
useEffect(() => {
|
|
if (!api || !activeSessionId) return;
|
|
const disposable = api.onDidActivePanelChange((event) => {
|
|
if (_restorationInProgress) return;
|
|
if (event) setActiveTabForSession(activeSessionId, event.id);
|
|
});
|
|
return () => disposable.dispose();
|
|
}, [api, activeSessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!api) return;
|
|
const disposable = api.onDidRemovePanel((event) => {
|
|
if (event.id.startsWith("file:")) {
|
|
removeFileState(event.id.replace("file:", ""));
|
|
return;
|
|
}
|
|
// Preview panel closed: drop whichever file it was showing — but NOT if a
|
|
// pinned panel for the same file already exists (e.g. the preview was
|
|
// just promoted to pinned, which removes the preview before creating the
|
|
// pinned panel; wiping the file state here would drop the user's dirty
|
|
// buffer during auto-promote-on-edit).
|
|
if (event.id === PREVIEW_FILE_EDITOR_ID) {
|
|
const path = (event.params?.previewItemId as string | undefined) ?? null;
|
|
if (!path) return;
|
|
const pinnedStillOpen = !!api.getPanel(`file:${path}`);
|
|
if (!pinnedStillOpen) removeFileState(path);
|
|
}
|
|
});
|
|
return () => disposable.dispose();
|
|
}, [api, removeFileState]);
|
|
}
|
|
|
|
type FileEditorActionsParams = {
|
|
activeSessionIdRef: React.MutableRefObject<string | null>;
|
|
setFileState: (path: string, state: FileEditorState) => void;
|
|
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
|
|
addFileEditorPanel: (
|
|
path: string,
|
|
name: string,
|
|
opts?: { quiet?: boolean; pin?: boolean; repo?: string },
|
|
) => void;
|
|
promotePreviewToPinned: (type: "file-editor") => void;
|
|
setSavingFiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
toast: ReturnType<typeof useToast>["toast"];
|
|
};
|
|
|
|
function useFileEditorActions({
|
|
activeSessionIdRef,
|
|
setFileState,
|
|
updateFileState,
|
|
addFileEditorPanel,
|
|
promotePreviewToPinned,
|
|
setSavingFiles,
|
|
toast,
|
|
}: FileEditorActionsParams) {
|
|
const openFile = useCallback(
|
|
async (filePath: string, repo?: string) => {
|
|
const client = getWebSocketClient();
|
|
const currentSessionId = activeSessionIdRef.current;
|
|
if (!client || !currentSessionId) return;
|
|
const files = getOpenFiles();
|
|
if (files.has(filePath)) {
|
|
const tabName = filePath.split("/").pop() || filePath;
|
|
addFileEditorPanel(filePath, tabName, { repo: files.get(filePath)?.repo });
|
|
return;
|
|
}
|
|
try {
|
|
const state = await fetchFileEditorState(
|
|
client,
|
|
currentSessionId,
|
|
filePath,
|
|
repo,
|
|
activeSessionIdRef,
|
|
);
|
|
if (!state) return;
|
|
// Create the panel BEFORE setting file state. The openFiles subscription
|
|
// triggers tab persistence — it needs the dockview panel to already exist
|
|
// so buildPersistedTabs can detect whether the file is preview or pinned.
|
|
addFileEditorPanel(filePath, state.name, { repo });
|
|
setFileState(filePath, state);
|
|
} catch (error) {
|
|
toast({
|
|
title: "Failed to open file",
|
|
description: error instanceof Error ? error.message : "Unknown error",
|
|
variant: "error",
|
|
});
|
|
}
|
|
},
|
|
[activeSessionIdRef, addFileEditorPanel, setFileState, toast],
|
|
);
|
|
|
|
const handleFileChange = useCallback(
|
|
(path: string, newContent: string) =>
|
|
applyFileChange(path, newContent, updateFileState, promotePreviewToPinned),
|
|
[updateFileState, promotePreviewToPinned],
|
|
);
|
|
|
|
const { saveFile, deleteFileAction, applyRemoteUpdate } = useSaveDeleteActions({
|
|
activeSessionIdRef,
|
|
updateFileState,
|
|
setSavingFiles,
|
|
toast,
|
|
});
|
|
|
|
const openFileInMarkdownPreview = useCallback(
|
|
async (filePath: string, repo?: string) => {
|
|
const client = getWebSocketClient();
|
|
const currentSessionId = activeSessionIdRef.current;
|
|
if (!client || !currentSessionId) return;
|
|
const files = getOpenFiles();
|
|
if (files.has(filePath)) {
|
|
updateFileState(filePath, { markdownPreview: true });
|
|
const name = filePath.split("/").pop() || filePath;
|
|
addFileEditorPanel(filePath, name, { repo: files.get(filePath)?.repo });
|
|
return;
|
|
}
|
|
try {
|
|
const state = await fetchFileEditorState(
|
|
client,
|
|
currentSessionId,
|
|
filePath,
|
|
repo,
|
|
activeSessionIdRef,
|
|
);
|
|
if (!state) return;
|
|
addFileEditorPanel(filePath, state.name, { repo });
|
|
setFileState(filePath, { ...state, markdownPreview: true });
|
|
} catch (error) {
|
|
toast({
|
|
title: "Failed to open file",
|
|
description: error instanceof Error ? error.message : "Unknown error",
|
|
variant: "error",
|
|
});
|
|
}
|
|
},
|
|
[activeSessionIdRef, setFileState, updateFileState, addFileEditorPanel, toast],
|
|
);
|
|
|
|
return {
|
|
openFile,
|
|
openFileInMarkdownPreview,
|
|
handleFileChange,
|
|
saveFile,
|
|
deleteFileAction,
|
|
applyRemoteUpdate,
|
|
};
|
|
}
|
|
|
|
export function useFileEditors() {
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const gitStatus = useSessionGitStatus(activeSessionId);
|
|
const { toast } = useToast();
|
|
const [savingFiles, setSavingFiles] = useState<Set<string>>(new Set());
|
|
|
|
const setFileState = useDockviewStore((s) => s.setFileState);
|
|
const updateFileState = useDockviewStore((s) => s.updateFileState);
|
|
const removeFileState = useDockviewStore((s) => s.removeFileState);
|
|
const clearFileStates = useDockviewStore((s) => s.clearFileStates);
|
|
const addFileEditorPanel = useDockviewStore((s) => s.addFileEditorPanel);
|
|
const promotePreviewToPinned = useDockviewStore((s) => s.promotePreviewToPinned);
|
|
const openFiles = useDockviewStore((s) => s.openFiles);
|
|
const api = useDockviewStore((s) => s.api);
|
|
const gitFileSignaturesRef = useRef<Map<string, string>>(new Map());
|
|
|
|
const activeSessionIdRef = useRef(activeSessionId);
|
|
useEffect(() => {
|
|
activeSessionIdRef.current = activeSessionId;
|
|
}, [activeSessionId]);
|
|
|
|
useFileEditorEffects({
|
|
activeSessionId,
|
|
activeSessionIdRef,
|
|
setFileState,
|
|
addFileEditorPanel,
|
|
clearFileStates,
|
|
removeFileState,
|
|
api,
|
|
});
|
|
useOpenFileWorkspaceSync({
|
|
gitStatus,
|
|
openFiles,
|
|
updateFileState,
|
|
activeSessionIdRef,
|
|
gitFileSignaturesRef,
|
|
});
|
|
const {
|
|
openFile,
|
|
openFileInMarkdownPreview,
|
|
handleFileChange,
|
|
saveFile,
|
|
deleteFileAction,
|
|
applyRemoteUpdate,
|
|
} = useFileEditorActions({
|
|
activeSessionIdRef,
|
|
setFileState,
|
|
updateFileState,
|
|
addFileEditorPanel,
|
|
promotePreviewToPinned,
|
|
setSavingFiles,
|
|
toast,
|
|
});
|
|
|
|
return {
|
|
savingFiles,
|
|
openFile,
|
|
openFileInMarkdownPreview,
|
|
saveFile,
|
|
deleteFile: deleteFileAction,
|
|
handleFileChange,
|
|
applyRemoteUpdate,
|
|
};
|
|
}
|