Files
kandev/apps/web/components/task/task-center-panel-restoration.ts
T
2026-07-30 10:44:30 +01:00

270 lines
8.6 KiB
TypeScript

"use client";
import { useRef, useEffect, useCallback } from "react";
import type { OpenFileTab } from "@/lib/types/backend";
import { getWebSocketClient } from "@/lib/ws/connection";
import {
getOpenFileTabs,
getActiveTabForSession,
setActiveTabForSession,
type StoredFileTab,
} from "@/lib/local-storage";
import { calculateHash, generateUnifiedDiff } from "@/lib/utils/file-diff";
import { requestFileContent, updateFileContent, deleteFile } from "@/lib/ws/workspace-files";
import { useToast } from "@/components/toast-provider";
import { getFileTabKey } from "./task-center-panel-file-tabs";
export type FileTabRestorationOptions = {
activeSessionId: string | null;
leftTab: string;
setLeftTab: (tab: string) => void;
setOpenFileTabs: React.Dispatch<React.SetStateAction<OpenFileTab[]>>;
};
export type FileSaveDeleteOptions = {
activeSessionId: string | null;
openFileTabs: OpenFileTab[];
setOpenFileTabs: React.Dispatch<React.SetStateAction<OpenFileTab[]>>;
setSavingFiles: React.Dispatch<React.SetStateAction<Set<string>>>;
handleCloseFileTab: (fileKey: string) => void;
};
export function toPrimaryTab(savedTab: string) {
return savedTab === "chat" || savedTab === "changes" ? savedTab : "chat";
}
export async function loadSavedFileTabs(sessionId: string, savedTabs: StoredFileTab[]) {
const client = getWebSocketClient();
if (!client) return null;
const loadedTabs: OpenFileTab[] = [];
for (const savedTab of savedTabs) {
try {
const response = await requestFileContent(client, sessionId, savedTab.path, savedTab.repo);
const hash = await calculateHash(response.content);
loadedTabs.push({
path: savedTab.path,
name: savedTab.name,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
repo: savedTab.repo,
markdownPreview: savedTab.markdownPreview,
});
} catch {
/* skip failed tabs */
}
}
return loadedTabs;
}
type RestoreLoadedTabsParams = {
loadedTabs: OpenFileTab[];
savedActiveTab: string;
restoredTabsRef: React.MutableRefObject<string | null>;
restorationInProgressRef: React.MutableRefObject<boolean>;
setOpenFileTabs: React.Dispatch<React.SetStateAction<OpenFileTab[]>>;
setLeftTab: (tab: string) => void;
};
function restoreLoadedTabs({
loadedTabs,
savedActiveTab,
restorationInProgressRef,
setOpenFileTabs,
setLeftTab,
}: RestoreLoadedTabsParams) {
if (loadedTabs.length > 0) {
setOpenFileTabs(loadedTabs);
if (savedActiveTab.startsWith("file:")) {
const savedFileKey = savedActiveTab.slice("file:".length);
// Existing sessions may have stored the old path-only tab value. Prefer
// the repo-aware key, while keeping that persisted state restorable.
const activeTab = loadedTabs.find(
(tab) => getFileTabKey(tab) === savedFileKey || tab.path === savedFileKey,
);
if (activeTab) {
setTimeout(() => {
setLeftTab(`file:${getFileTabKey(activeTab)}`);
restorationInProgressRef.current = false;
}, 0);
} else {
setLeftTab("chat");
restorationInProgressRef.current = false;
}
} else {
setLeftTab(savedActiveTab);
restorationInProgressRef.current = false;
}
} else {
setLeftTab(toPrimaryTab(savedActiveTab));
restorationInProgressRef.current = false;
}
}
export function useFileTabRestoration({
activeSessionId,
leftTab,
setLeftTab,
setOpenFileTabs,
}: FileTabRestorationOptions) {
const restoredTabsRef = useRef<string | null>(null);
const restorationInProgressRef = useRef<boolean>(false);
const prevSessionRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (prevSessionRef.current && leftTab)
setActiveTabForSession(prevSessionRef.current, leftTab);
};
}, [leftTab]);
useEffect(() => {
if (!activeSessionId) return;
if (restoredTabsRef.current !== activeSessionId) {
if (prevSessionRef.current && prevSessionRef.current !== activeSessionId)
setActiveTabForSession(prevSessionRef.current, leftTab);
restoredTabsRef.current = activeSessionId;
prevSessionRef.current = activeSessionId;
restorationInProgressRef.current = false;
setOpenFileTabs([]);
} else if (restorationInProgressRef.current || restoredTabsRef.current === activeSessionId) {
return;
}
const savedTabs = getOpenFileTabs(activeSessionId);
const savedActiveTab = getActiveTabForSession(activeSessionId, "chat");
if (savedTabs.length === 0) {
setLeftTab(toPrimaryTab(savedActiveTab));
return;
}
restorationInProgressRef.current = true;
const loadTabs = async (retryCount = 0): Promise<void> => {
const loadedTabs = await loadSavedFileTabs(activeSessionId, savedTabs);
if (!loadedTabs) {
if (retryCount < 5) {
setTimeout(() => loadTabs(retryCount + 1), 200);
return;
}
restorationInProgressRef.current = false;
return;
}
if (restoredTabsRef.current !== activeSessionId) {
restorationInProgressRef.current = false;
return;
}
restoreLoadedTabs({
loadedTabs,
savedActiveTab,
restoredTabsRef,
restorationInProgressRef,
setOpenFileTabs,
setLeftTab,
});
};
void loadTabs();
// eslint-disable-next-line react-hooks/exhaustive-deps -- leftTab is intentionally excluded to prevent re-running on tab changes
}, [activeSessionId]);
useEffect(() => {
if (!activeSessionId || restorationInProgressRef.current) return;
setActiveTabForSession(activeSessionId, leftTab);
}, [activeSessionId, leftTab]);
return { restorationInProgressRef };
}
export function useFileSaveDelete({
activeSessionId,
openFileTabs,
setOpenFileTabs,
setSavingFiles,
handleCloseFileTab,
}: FileSaveDeleteOptions) {
const { toast } = useToast();
const handleFileSave = useCallback(
async (path: string, repo?: string) => {
const fileKey = getFileTabKey({ path, repo });
const tab = openFileTabs.find((item) => getFileTabKey(item) === fileKey);
if (!tab || !tab.isDirty) return;
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
setSavingFiles((prev) => new Set(prev).add(fileKey));
try {
const diff = generateUnifiedDiff(tab.originalContent, tab.content, tab.path);
const response = await updateFileContent(client, activeSessionId, {
path,
diff,
originalHash: tab.originalHash,
desiredContent: tab.content,
repo: tab.repo,
});
if (response.success && response.new_hash) {
setOpenFileTabs((prev) =>
prev.map((t) =>
getFileTabKey(t) === fileKey
? {
...t,
originalContent: t.content,
originalHash: response.new_hash!,
isDirty: false,
}
: t,
),
);
} else {
toast({
title: "Save failed",
description: response.error || "Failed to save file",
variant: "error",
});
}
} catch (error) {
toast({
title: "Save failed",
description:
error instanceof Error ? error.message : "An error occurred while saving the file",
variant: "error",
});
} finally {
setSavingFiles((prev) => {
const next = new Set(prev);
next.delete(fileKey);
return next;
});
}
},
[openFileTabs, activeSessionId, toast, setOpenFileTabs, setSavingFiles],
);
const handleFileDelete = useCallback(
async (path: string, repo?: string) => {
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
try {
const response = await deleteFile(client, activeSessionId, path, repo);
if (response.success) {
handleCloseFileTab(getFileTabKey({ path, repo }));
} else {
toast({
title: "Delete failed",
description: response.error || "Failed to delete file",
variant: "error",
});
}
} catch (error) {
toast({
title: "Delete failed",
description:
error instanceof Error ? error.message : "An error occurred while deleting the file",
variant: "error",
});
}
},
[activeSessionId, handleCloseFileTab, toast],
);
return { handleFileSave, handleFileDelete };
}