Files
kandev/apps/web/hooks/use-file-editors.ts
T

459 lines
16 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. */
async function buildFileEditorState(
filePath: string,
response: FileContentResponse,
): Promise<FileEditorState> {
const fileName = filePath.split("/").pop() || filePath;
const hash = await calculateHash(response.content);
return {
path: filePath,
name: fileName,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
};
}
/** 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, 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, ...(markdownPreview ? { markdownPreview } : {}), pinned: persistAsPinned },
];
});
}
type RestoreTabsParams = {
activeSessionId: string;
savedTabs: Array<{ path: string; name: string; markdownPreview?: boolean; pinned?: boolean }>;
savedActiveTab: string;
setFileState: (path: string, state: FileEditorState) => void;
addFileEditorPanel: (
path: string,
name: string,
opts?: { quiet?: boolean; pin?: boolean },
) => 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,
});
// 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,
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);
const hash = await calculateHash(response.content);
setFileState(savedTab.path, {
path: savedTab.path,
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 },
) => 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 },
) => 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) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
const files = getOpenFiles();
if (files.has(filePath)) {
addFileEditorPanel(filePath, filePath.split("/").pop() || filePath);
return;
}
try {
const response: FileContentResponse = await requestFileContent(
client,
currentSessionId,
filePath,
);
const state = await buildFileEditorState(filePath, response);
// 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);
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) => {
const file = getOpenFiles().get(path);
if (!file) return;
const nextIsDirty = newContent !== file.originalContent;
// VSCode-style: editing a preview file auto-promotes its tab so the
// user's unsaved changes aren't silently discarded when they open
// another file. Promote BEFORE updating file state so the openFiles
// subscription sees the promoted flag when it fires from updateFileState.
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 });
},
[updateFileState, promotePreviewToPinned],
);
const { saveFile, deleteFileAction, applyRemoteUpdate } = useSaveDeleteActions({
activeSessionIdRef,
updateFileState,
setSavingFiles,
toast,
});
const openFileInMarkdownPreview = useCallback(
async (filePath: string) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
const files = getOpenFiles();
if (files.has(filePath)) {
updateFileState(filePath, { markdownPreview: true });
addFileEditorPanel(filePath, filePath.split("/").pop() || filePath);
return;
}
try {
const response: FileContentResponse = await requestFileContent(
client,
currentSessionId,
filePath,
);
const state = await buildFileEditorState(filePath, response);
addFileEditorPanel(filePath, state.name);
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,
};
}