Files
kandev/apps/web/components/task/task-files-panel-hooks.ts
T
Kandev Agent 9e8079de69 feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create
Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.
2026-04-26 21:23:48 +01:00

394 lines
13 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: Array<{ path?: string; repository_name?: string } | 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]);
// Multi-repo: each agentctl git op needs the repo subpath, otherwise the
// command runs at the workspace root (the task holder dir) and silently
// no-ops. Build a path → repository_name map from changedFiles.
const repoForPath = useMemo(() => {
const m = new Map<string, string>();
for (const f of changedFiles) {
if (!f || typeof f !== "object") continue;
const path = (f as { path?: unknown }).path;
const repo = (f as { repository_name?: unknown }).repository_name;
if (typeof path === "string" && typeof repo === "string" && repo) {
m.set(path, repo);
}
}
return m;
}, [changedFiles]);
// The FileRow now passes the file's repository_name explicitly, which avoids
// path-based lookup collisions when two repos share a filename. Fall back to
// the lookup only if the caller didn't pass one (older call sites).
const handleStage = useCallback(
async (path: string, repo?: string) => {
const effectiveRepo = repo ?? repoForPath.get(path);
setPendingStageFiles((prev) => new Set(prev).add(path));
try {
await gitOps.stage([path], effectiveRepo);
} catch {
setPendingStageFiles((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}
},
[gitOps, repoForPath],
);
const handleUnstage = useCallback(
async (path: string, repo?: string) => {
const effectiveRepo = repo ?? repoForPath.get(path);
setPendingStageFiles((prev) => new Set(prev).add(path));
try {
await gitOps.unstage([path], effectiveRepo);
} catch {
setPendingStageFiles((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}
},
[gitOps, repoForPath],
);
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);
// Multi-repo: capture the file's repo at click time so the discard op runs
// against the right git repo (path-only routing collides on same-named files).
const [repoToDiscard, setRepoToDiscard] = useState<string | undefined>(undefined);
const gitOps = useGitOperations(activeSessionId ?? null);
const { toast } = useToast();
const handleDiscardClick = useCallback((filePath: string, repo?: string) => {
setFileToDiscard(filePath);
setRepoToDiscard(repo);
setShowDiscardDialog(true);
}, []);
const handleDiscardConfirm = useCallback(async () => {
if (!fileToDiscard) return;
try {
const result = await gitOps.discard([fileToDiscard], repoToDiscard);
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);
setRepoToDiscard(undefined);
}
}, [fileToDiscard, repoToDiscard, 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,
};
}