Files
kandev/apps/web/components/task/task-files-panel-hooks.ts
T

368 lines
12 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useAppStore } from "@/components/state-provider";
import { useOpenSessionInEditor } from "@/hooks/use-open-session-in-editor";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { useGitOperations } from "@/hooks/use-git-operations";
import { useSessionFileReviews } from "@/hooks/use-session-file-reviews";
import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff";
import { hashDiff, normalizeDiffContent } from "@/components/review/types";
import type { FileInfo } from "@/lib/state/store";
import { useToast } from "@/components/toast-provider";
import { useFileOperations } from "@/hooks/use-file-operations";
import type { OpenFileTab } from "@/lib/types/backend";
import {
getFilesPanelTab,
setFilesPanelTab,
hasUserSelectedFilesPanelTab,
setUserSelectedFilesPanelTab,
} from "@/lib/local-storage";
import { usePanelActions } from "@/hooks/use-panel-actions";
import { requestCommitDiff } from "./commit-diff-request";
export type GitStatusFiles = Record<
string,
{
diff?: string;
additions?: number;
deletions?: number;
staged?: boolean;
status?: string;
old_path?: string;
}
>;
export type CumulativeDiffFiles = Record<string, { diff?: string }>;
export type ReviewMap = Map<string, { reviewed?: boolean; diffHash?: string }>;
function collectDiffPaths(
gitStatusFiles: GitStatusFiles | undefined,
cumulativeFiles: CumulativeDiffFiles | undefined,
): Set<string> {
const paths = new Set<string>();
if (gitStatusFiles) {
for (const [path, file] of Object.entries(gitStatusFiles)) {
if (file.diff && normalizeDiffContent(file.diff)) paths.add(path);
}
}
if (cumulativeFiles) {
for (const [path, file] of Object.entries(cumulativeFiles)) {
if (!paths.has(path) && file.diff && normalizeDiffContent(file.diff)) paths.add(path);
}
}
return paths;
}
function getDiffContentForPath(
path: string,
gitStatusFiles: GitStatusFiles | undefined,
cumulativeFiles: CumulativeDiffFiles | undefined,
): string {
const uncommittedFile = gitStatusFiles?.[path];
if (uncommittedFile?.diff) return normalizeDiffContent(uncommittedFile.diff);
const cumulativeFile = cumulativeFiles?.[path];
if (cumulativeFile?.diff) return normalizeDiffContent(cumulativeFile.diff);
return "";
}
export function computeReviewProgress(
gitStatusFiles: GitStatusFiles | undefined,
cumulativeFiles: CumulativeDiffFiles | undefined,
reviews: ReviewMap,
): { reviewedCount: number; totalFileCount: number } {
const paths = collectDiffPaths(gitStatusFiles, cumulativeFiles);
let reviewed = 0;
for (const path of paths) {
const state = reviews.get(path);
if (!state?.reviewed) continue;
const diffContent = getDiffContentForPath(path, gitStatusFiles, cumulativeFiles);
if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue;
reviewed++;
}
return { reviewedCount: reviewed, totalFileCount: paths.size };
}
export function useCommitDiffs(activeSessionId: string | null | undefined) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const sessionTaskId = useAppStore((state) =>
activeSessionId ? state.taskSessions.items[activeSessionId]?.task_id : undefined,
);
const agentctlReady = useAppStore((state) =>
activeSessionId
? state.sessionAgentctl.itemsBySessionId[activeSessionId]?.status === "ready"
: false,
);
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
const [commitDiffs, setCommitDiffs] = useState<Record<string, Record<string, FileInfo>>>({});
const [loadingCommitSha, setLoadingCommitSha] = useState<string | null>(null);
const { toast } = useToast();
const fetchCommitDiff = useCallback(
async (commitSha: string) => {
if (!activeSessionId || commitDiffs[commitSha]) return;
setLoadingCommitSha(commitSha);
try {
const response = await requestCommitDiff({
sessionId: activeSessionId,
taskId: sessionTaskId ?? activeTaskId ?? null,
commitSha,
agentctlReady,
});
if (response?.success && response.files) {
const files = response.files;
setCommitDiffs((prev) => ({ ...prev, [commitSha]: files }));
}
} catch (err) {
toast({
title: "Failed to load commit diff",
description: err instanceof Error ? err.message : "An unexpected error occurred",
variant: "error",
});
} finally {
setLoadingCommitSha(null);
}
},
[activeSessionId, activeTaskId, agentctlReady, commitDiffs, sessionTaskId, toast],
);
const toggleCommit = useCallback(
(commitSha: string) => {
if (expandedCommit === commitSha) {
setExpandedCommit(null);
} else {
setExpandedCommit(commitSha);
void fetchCommitDiff(commitSha);
}
},
[expandedCommit, fetchCommitDiff],
);
return { expandedCommit, commitDiffs, loadingCommitSha, toggleCommit };
}
export function useGitStagingActions(
activeSessionId: string | null | undefined,
changedFiles: unknown[],
) {
const gitOps = useGitOperations(activeSessionId ?? null);
const [pendingStageFiles, setPendingStageFiles] = useState<Set<string>>(new Set());
useEffect(() => {
if (pendingStageFiles.size > 0) setPendingStageFiles(new Set());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedFiles]);
const handleStage = useCallback(
async (path: string) => {
setPendingStageFiles((prev) => new Set(prev).add(path));
try {
await gitOps.stage([path]);
} catch {
setPendingStageFiles((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}
},
[gitOps],
);
const handleUnstage = useCallback(
async (path: string) => {
setPendingStageFiles((prev) => new Set(prev).add(path));
try {
await gitOps.unstage([path]);
} catch {
setPendingStageFiles((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}
},
[gitOps],
);
return { pendingStageFiles, handleStage, handleUnstage };
}
export function useFilesPanelTab(
activeSessionId: string | null | undefined,
changedFilesLength: number,
commitsLength: number,
) {
const [topTab, setTopTab] = useState<"diff" | "files">("diff");
const hasInitializedTabRef = useRef<string | null>(null);
const userClickedFilesTabRef = useRef(false);
const prevChangedCountRef = useRef(0);
useEffect(() => {
if (!activeSessionId) return;
if (hasInitializedTabRef.current === activeSessionId) return;
hasInitializedTabRef.current = activeSessionId;
if (hasUserSelectedFilesPanelTab(activeSessionId)) {
const savedTab = getFilesPanelTab(activeSessionId, "diff");
userClickedFilesTabRef.current = savedTab === "files";
queueMicrotask(() => setTopTab(savedTab));
return;
}
userClickedFilesTabRef.current = false;
const nextTab = changedFilesLength > 0 || commitsLength > 0 ? "diff" : "files";
prevChangedCountRef.current = changedFilesLength;
queueMicrotask(() => setTopTab(nextTab));
}, [activeSessionId, changedFilesLength, commitsLength]);
useEffect(() => {
const prev = prevChangedCountRef.current;
prevChangedCountRef.current = changedFilesLength;
if (changedFilesLength !== prev) {
if (topTab === "files" && userClickedFilesTabRef.current) return;
queueMicrotask(() => setTopTab("diff"));
}
}, [changedFilesLength, topTab]);
const handleTabChange = useCallback(
(tab: "diff" | "files") => {
setTopTab(tab);
userClickedFilesTabRef.current = tab === "files";
if (activeSessionId) {
setFilesPanelTab(activeSessionId, tab);
setUserSelectedFilesPanelTab(activeSessionId);
}
},
[activeSessionId],
);
return { topTab, handleTabChange };
}
export function useDiscardDialog(activeSessionId: string | null | undefined) {
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
const gitOps = useGitOperations(activeSessionId ?? null);
const { toast } = useToast();
const handleDiscardClick = useCallback((filePath: string) => {
setFileToDiscard(filePath);
setShowDiscardDialog(true);
}, []);
const handleDiscardConfirm = useCallback(async () => {
if (!fileToDiscard) return;
try {
const result = await gitOps.discard([fileToDiscard]);
if (!result.success)
toast({
title: "Failed to discard changes",
description: result.error || "An unknown error occurred",
variant: "error",
});
} catch (error) {
toast({
title: "Failed to discard changes",
description: error instanceof Error ? error.message : "An unknown error occurred",
variant: "error",
});
} finally {
setShowDiscardDialog(false);
setFileToDiscard(null);
}
}, [fileToDiscard, gitOps, toast]);
return {
showDiscardDialog,
setShowDiscardDialog,
fileToDiscard,
handleDiscardClick,
handleDiscardConfirm,
};
}
export function useFilesPanelData(onOpenFile: (file: OpenFileTab) => void) {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null);
const openEditor = useOpenSessionInEditor(activeSessionId ?? null);
const {
createFile: baseCreateFile,
deleteFile: hookDeleteFile,
renameFile: hookRenameFile,
} = useFileOperations(activeSessionId ?? null);
const { reviews } = useSessionFileReviews(activeSessionId);
const { diff: cumulativeDiff } = useCumulativeDiff(activeSessionId);
const { openFile: panelOpenFile } = usePanelActions();
const changedFiles = useMemo(() => {
if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) return [];
return (Object.values(gitStatus.files) as FileInfo[]).map((file: FileInfo) => ({
path: file.path,
status: file.status,
staged: file.staged,
plus: file.additions,
minus: file.deletions,
oldPath: file.old_path,
}));
}, [gitStatus]);
const { reviewedCount, totalFileCount } = useMemo(
() =>
computeReviewProgress(
gitStatus?.files as GitStatusFiles | undefined,
cumulativeDiff?.files as CumulativeDiffFiles | undefined,
reviews as ReviewMap,
),
[gitStatus, cumulativeDiff, reviews],
);
const handleCreateFile = useCallback(
async (path: string): Promise<boolean> => {
const ok = await baseCreateFile(path);
if (ok) {
const name = path.split("/").pop() || path;
const { calculateHash } = await import("@/lib/utils/file-diff");
const hash = await calculateHash("");
onOpenFile({
path,
name,
content: "",
originalContent: "",
originalHash: hash,
isDirty: false,
});
}
return ok;
},
[baseCreateFile, onOpenFile],
);
const handleOpenFileInDocumentPanel = useCallback(
(path: string) => {
if (activeSessionId) panelOpenFile(path);
},
[activeSessionId, panelOpenFile],
);
const handleOpenInEditor = useCallback(
(path: string) => {
if (activeSessionId) openEditor.open({ filePath: path });
},
[activeSessionId, openEditor],
);
return {
activeSessionId,
gitStatus,
commits,
changedFiles,
reviewedCount,
totalFileCount,
hookDeleteFile,
hookRenameFile,
handleCreateFile,
handleOpenFileInDocumentPanel,
handleOpenInEditor,
};
}