2b8513f902
* feat: add multi-select and drag-to-move for file tree and changes panel
Add Shift-click range and Ctrl/Cmd-click toggle selection to the file
tree and staged/unstaged changes sections. Includes bulk actions
(delete, stage, unstage, discard), drag-and-drop file moving into
directories, keyboard shortcuts (Escape, Ctrl+A), and E2E tests.
* fix: move bulk action components to file-row to fix max-lines lint
* style: apply prettier formatting to all changed files
* fix: bulk delete all selected items and update anchor on stale shift-click
* fix: address CodeRabbit review comments
- Keep original file-row-* data-testid for backwards compat
- Remove eager onClearSelection from bulk actions (auto-pruned by hook)
- Variant-scope bulk-discard/bulk-unstage test ids
- Remove index.lock deletion from E2E GitHelper retry loop
* fix: fix E2E tests — create files after session open, remove DnD test
- Create files AFTER opening session so WS detects git status changes
- Add proper git env vars (GIT_AUTHOR_NAME, etc.) matching existing tests
- Use element.press("Escape") instead of page keyboard for proper focus
- Remove drag-to-directory test (Playwright dragTo doesn't support HTML5
dataTransfer events needed by our DnD implementation)
* fix: rename E2E task titles to avoid ambiguous 'Changes' tab selector
* fix: make file list focusable for Escape key handler in E2E tests
* fix: improve selection styling and ignore right-clicks in selection handler
- Use text-accent-foreground on selected rows for dark mode readability
- Override child span colors with [&_span]:text-accent-foreground
- Use hover:bg-accent/80 instead of hover:bg-muted on selected items
- Skip selection handling on right-click (button === 2) so context menu
works without clearing multi-selection
* fix: plain click should not select — only Ctrl/Cmd+click and Shift+click
Plain click now clears any selection and performs the original action
(open file / toggle folder / open diff). Selection only happens with
modifier keys. handleClick returns boolean to indicate whether the
click was consumed by selection logic.
* fix: fix multi-select bugs and expand E2E test coverage
Bug fixes:
- Change isSelected from boolean to function prop so children compute
their own selection state (fixes nested file selection)
- Inline TreeNodeRow JSX into TreeNodeItem so ContextMenuTrigger asChild
can attach refs directly (fixes right-click context menu)
- Set lastClickedRef on plain click so shift-click has a valid anchor
- Add click-outside handler to clear selection
E2E tests expanded to 14 file-tree tests + 3 changes-panel tests:
- Ctrl-click select, toggle, multi-select
- Plain click clears selection
- Shift-click range after plain click and ctrl-click anchors
- Directory selection and nested file selection
- Right-click context menu (single + bulk delete)
- Click outside clears selection
- Escape clears selection
* fix: move AlertDialog outside ContextMenu to fix delete action
The AlertDialog wrapping the ContextMenu caused Radix focus management
to intercept context menu item selection, making Delete trigger Rename.
Moving AlertDialog to a sibling of ContextMenu fixes the conflict.
* fix: reorder context menu — Delete first, Rename second
Radix ContextMenu auto-focuses the first item on open, which caused
accidental Rename triggers. Putting Delete first (the more common
action for selection) avoids this.
* fix: don't clear selection when clicking context menu or dialog
The click-outside handler was clearing selection when clicking on
context menu items or confirmation dialog buttons (rendered in portals
outside the tree container). Now skips clearing for elements with
role=menu, role=alertdialog, or role=dialog.
Also extended the bulk delete E2E test to verify both files are
actually removed after confirming the dialog.
* fix: hide file count in delete dialog when folder children not loaded
When a folder hasn't been expanded, its children aren't loaded yet so
countFilesInTree returns 0. Instead of showing "0 files", hide the
count entirely and just show "delete <folder name>".
* fix: show confirmation dialog for bulk discard in changes panel
Bulk discard was calling git.discard() directly, bypassing the
confirmation dialog. Now routes through handleBulkDiscardClick which
opens the DiscardDialog with a bulk message ("discard changes to N
files") before executing.
* fix: address remaining CodeRabbit review comments
- Deduplicate filenames on move: when moving files with identical names
to the same directory, append "(1)", "(2)" etc. to avoid collisions.
Both tree update and rename API calls use the same deduplication.
- Replace void operator with .catch(() => {}) for bulk stage/unstage
to prevent unhandled promise rejections.
- Add document-level dragend listener as safety net to clear stuck drag
state if the element's onDragEnd doesn't fire (Escape, out-of-window).
* test: add unit tests for file tree move deduplication and visible paths
* fix: address all remaining PR review comments
- Move onSelectionChange out of React state updater (side effect fix)
- Stabilize keyboard listener deps (pass callbacks not object)
- Remove unnecessary useMemo in BulkActionBar
- Fix ghost-restore on partial bulk delete (remove all at once)
- Fix blank filename on single-file bulk discard
- Add aria-selected and role=treeitem for accessibility
- Extract computeNextSelection params into object (max-params lint)
* fix: add missing bulk action props to WorkingTreeProps type
WorkingTreeSections referenced onBulkStage, onBulkUnstage, and
onBulkDiscard but WorkingTreeProps Pick didn't include them, causing
a TypeScript build failure.
* fix: address final review comments
- Add unit tests for computeNextSelection (8 tests covering ctrl-click,
shift-click range, stale anchor, reverse range, shift+ctrl extend)
- Extract shared E2E helpers (GitHelper, makeGitEnv, openTaskSession,
createStandardProfile) to e2e/helpers/git-helper.ts
- Fix misleading comment in bulk delete (says "re-insert only failed"
but implementation restores full snapshot)
* fix: use Promise.all for bulk delete to prevent partial rollback
Individual .catch() handlers could restore already-deleted files
if a later delete failed. Now uses Promise.all so rollback only
happens if any delete rejects.
* test: add renderHook tests for useMultiSelect hook behaviors
- Stale-path pruning: items shrink → selected paths auto-pruned
- Plain click: returns false, clears existing selection
- selectAll/clearSelection: work correctly, fire onSelectionChange
- Install @testing-library/react for renderHook support
* fix: handle false returns from onDeleteFile in bulk delete Promise.all
* chore: remove unused @testing-library/react-hooks dependency
645 lines
20 KiB
TypeScript
645 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useMemo } from "react";
|
|
import { PanelRoot, PanelBody } from "./panel-primitives";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
|
|
import { useSessionFileReviews } from "@/hooks/use-session-file-reviews";
|
|
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
|
|
import { hashDiff, normalizeDiffContent } from "@/components/review/types";
|
|
import type { FileInfo } from "@/lib/state/store";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context";
|
|
import { DiscardDialog, AmendDialog, ResetDialog } from "./changes-panel-dialogs";
|
|
import { useVcsDialogs } from "@/components/vcs/vcs-dialogs";
|
|
import { ChangesPanelHeader } from "./changes-panel-header";
|
|
import {
|
|
FileListSection,
|
|
CommitsSection,
|
|
ActionButtonsSection,
|
|
ReviewProgressBar,
|
|
PRFilesSection,
|
|
} from "./changes-panel-timeline";
|
|
import type { PRChangedFile } from "./changes-panel-timeline";
|
|
import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks";
|
|
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
|
|
import { usePRDiff } from "@/hooks/domains/github/use-pr-diff";
|
|
import { usePRCommits } from "@/hooks/domains/github/use-pr-commits";
|
|
import type { PRDiffFile } from "@/lib/types/github";
|
|
|
|
type ChangesPanelProps = {
|
|
onOpenDiffFile: (path: string) => void;
|
|
onEditFile: (path: string) => void;
|
|
onOpenCommitDetail?: (sha: string) => void;
|
|
onOpenDiffAll?: () => void;
|
|
onOpenReview?: () => void;
|
|
};
|
|
|
|
// Maps FileInfo (store) to the display format expected by FileListSection
|
|
type ChangedFile = {
|
|
path: string;
|
|
status: FileInfo["status"];
|
|
staged: boolean;
|
|
plus: number | undefined;
|
|
minus: number | undefined;
|
|
oldPath: string | undefined;
|
|
};
|
|
|
|
function mapToChangedFiles(files: FileInfo[]): ChangedFile[] {
|
|
return files.map((file) => ({
|
|
path: file.path,
|
|
status: file.status,
|
|
staged: file.staged,
|
|
plus: file.additions,
|
|
minus: file.deletions,
|
|
oldPath: file.old_path,
|
|
}));
|
|
}
|
|
|
|
type CumulativeDiffFiles = Record<
|
|
string,
|
|
{ diff?: string; status?: string; additions?: number; deletions?: number }
|
|
>;
|
|
|
|
function collectReviewPaths(
|
|
uncommittedFiles: FileInfo[],
|
|
cumulativeDiffFiles: CumulativeDiffFiles | undefined,
|
|
prFiles?: PRDiffFile[],
|
|
): Set<string> {
|
|
const paths = new Set<string>();
|
|
for (const file of uncommittedFiles) {
|
|
if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path);
|
|
}
|
|
if (cumulativeDiffFiles) {
|
|
for (const [path, file] of Object.entries(cumulativeDiffFiles)) {
|
|
if (!paths.has(path) && file.diff && normalizeDiffContent(file.diff)) paths.add(path);
|
|
}
|
|
}
|
|
if (prFiles) {
|
|
for (const file of prFiles) {
|
|
if (!paths.has(file.filename) && file.patch) paths.add(file.filename);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function getDiffForPath(
|
|
path: string,
|
|
uncommittedFiles: FileInfo[],
|
|
cumulativeDiffFiles: CumulativeDiffFiles | undefined,
|
|
prFiles?: PRDiffFile[],
|
|
): string {
|
|
const uncommitted = uncommittedFiles.find((f) => f.path === path);
|
|
if (uncommitted?.diff) return normalizeDiffContent(uncommitted.diff);
|
|
const cumDiff = cumulativeDiffFiles?.[path]?.diff;
|
|
if (cumDiff) return normalizeDiffContent(cumDiff);
|
|
if (prFiles) {
|
|
const prFile = prFiles.find((f) => f.filename === path);
|
|
if (prFile?.patch) return normalizeDiffContent(prFile.patch);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function computeReviewProgress(
|
|
uncommittedFiles: FileInfo[],
|
|
cumulativeDiff: { files?: CumulativeDiffFiles } | null,
|
|
reviews: Map<string, { reviewed: boolean; diffHash?: string }>,
|
|
prFiles?: PRDiffFile[],
|
|
) {
|
|
const cumulativeDiffFiles = cumulativeDiff?.files;
|
|
const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles, prFiles);
|
|
let reviewed = 0;
|
|
for (const path of paths) {
|
|
const state = reviews.get(path);
|
|
if (!state?.reviewed) continue;
|
|
const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles, prFiles);
|
|
if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue;
|
|
reviewed++;
|
|
}
|
|
return { reviewedCount: reviewed, totalFileCount: paths.size };
|
|
}
|
|
|
|
function computeStagedStats(stagedFiles: FileInfo[]) {
|
|
const adds = stagedFiles.reduce((sum, f) => sum + (f.additions || 0), 0);
|
|
const dels = stagedFiles.reduce((sum, f) => sum + (f.deletions || 0), 0);
|
|
return { stagedFileCount: stagedFiles.length, stagedAdditions: adds, stagedDeletions: dels };
|
|
}
|
|
|
|
function mapPRFilesToChangedFiles(files: PRDiffFile[]): PRChangedFile[] {
|
|
return files.map((file) => {
|
|
let status: FileInfo["status"];
|
|
switch (file.status) {
|
|
case "added":
|
|
status = "added";
|
|
break;
|
|
case "removed":
|
|
status = "deleted";
|
|
break;
|
|
case "renamed":
|
|
status = "renamed";
|
|
break;
|
|
default:
|
|
status = "modified";
|
|
}
|
|
return {
|
|
path: file.filename,
|
|
status,
|
|
plus: file.additions,
|
|
minus: file.deletions,
|
|
oldPath: file.old_path,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function filterUnpushedCommits<T extends { commit_sha: string }>(
|
|
localCommits: T[],
|
|
prCommits: { sha: string }[],
|
|
): T[] {
|
|
if (prCommits.length === 0) return localCommits;
|
|
return localCommits.filter(
|
|
(c) =>
|
|
!prCommits.some((pr) => pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)),
|
|
);
|
|
}
|
|
|
|
type MergedCommit = {
|
|
commit_sha: string;
|
|
commit_message: string;
|
|
insertions: number;
|
|
deletions: number;
|
|
pushed: boolean;
|
|
};
|
|
|
|
/**
|
|
* Merge local session commits and PR commits into a single list.
|
|
* Local commits matched to a PR commit are marked pushed; unmatched are unpushed.
|
|
* PR-only commits (e.g. from other contributors) are appended as pushed.
|
|
* Order: unpushed first, then pushed.
|
|
*/
|
|
export function mergeCommits(
|
|
localCommits: {
|
|
commit_sha: string;
|
|
commit_message: string;
|
|
insertions: number;
|
|
deletions: number;
|
|
}[],
|
|
prCommits: { sha: string; message: string; additions: number; deletions: number }[],
|
|
): MergedCommit[] {
|
|
const matchesPR = (localSha: string) =>
|
|
prCommits.some((pr) => pr.sha.startsWith(localSha) || localSha.startsWith(pr.sha));
|
|
|
|
const unpushed: MergedCommit[] = [];
|
|
const pushed: MergedCommit[] = [];
|
|
const matchedPRShas = new Set<string>();
|
|
|
|
for (const c of localCommits) {
|
|
if (matchesPR(c.commit_sha)) {
|
|
pushed.push({ ...c, pushed: true });
|
|
for (const pr of prCommits) {
|
|
if (pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)) {
|
|
matchedPRShas.add(pr.sha);
|
|
}
|
|
}
|
|
} else {
|
|
unpushed.push({ ...c, pushed: false });
|
|
}
|
|
}
|
|
|
|
// Add PR-only commits (not matched to any local commit)
|
|
for (const pr of prCommits) {
|
|
if (!matchedPRShas.has(pr.sha)) {
|
|
pushed.push({
|
|
commit_sha: pr.sha,
|
|
commit_message: pr.message,
|
|
insertions: pr.additions,
|
|
deletions: pr.deletions,
|
|
pushed: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return [...unpushed, ...pushed];
|
|
}
|
|
|
|
function getBaseBranchDisplay(baseBranch: string | undefined): string {
|
|
return baseBranch ? baseBranch.replace(/^origin\//, "") : "main";
|
|
}
|
|
|
|
function useChangesPanelStoreData() {
|
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
// Use environment-stable sessionId so git hooks (commits, cumulative diff)
|
|
// don't re-fetch when switching between sessions in the same environment.
|
|
const activeSessionId = useEnvironmentSessionId();
|
|
const taskTitle = useAppStore((state) => {
|
|
if (!state.tasks.activeTaskId) return undefined;
|
|
return state.kanban.tasks.find((t: { id: string }) => t.id === state.tasks.activeTaskId)?.title;
|
|
});
|
|
const baseBranch = useAppStore((state) =>
|
|
activeSessionId ? state.taskSessions.items[activeSessionId]?.base_branch : undefined,
|
|
);
|
|
const existingPrUrl = useAppStore((state) => {
|
|
const taskId = state.tasks.activeTaskId;
|
|
if (!taskId) return undefined;
|
|
return state.taskPRs.byTaskId[taskId]?.pr_url ?? undefined;
|
|
});
|
|
return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl };
|
|
}
|
|
|
|
type DialogsType = ReturnType<typeof useChangesDialogHandlers> & ReturnType<typeof useVcsDialogs>;
|
|
|
|
type ChangesPanelBodyProps = {
|
|
hasAnything: boolean;
|
|
hasUnstaged: boolean;
|
|
hasStaged: boolean;
|
|
hasCommits: boolean;
|
|
hasPRFiles: boolean;
|
|
hasPRCommits: boolean;
|
|
canPush: boolean;
|
|
canCreatePR: boolean;
|
|
existingPrUrl: string | undefined;
|
|
unstagedFiles: ChangedFile[];
|
|
stagedFiles: ChangedFile[];
|
|
prFiles: PRChangedFile[];
|
|
prCommits: {
|
|
sha: string;
|
|
message: string;
|
|
author_login: string;
|
|
author_date: string;
|
|
additions: number;
|
|
deletions: number;
|
|
}[];
|
|
commits: {
|
|
commit_sha: string;
|
|
commit_message: string;
|
|
insertions: number;
|
|
deletions: number;
|
|
pushed?: boolean;
|
|
}[];
|
|
pendingStageFiles: Set<string>;
|
|
reviewedCount: number;
|
|
totalFileCount: number;
|
|
aheadCount: number;
|
|
isLoading: boolean;
|
|
loadingOperation: string | null;
|
|
dialogs: DialogsType;
|
|
onOpenDiffFile: (path: string) => void;
|
|
onEditFile: (path: string) => void;
|
|
onOpenCommitDetail?: (sha: string) => void;
|
|
onOpenReview?: () => void;
|
|
onRevertCommit?: (sha: string) => void;
|
|
onStageAll: () => void;
|
|
onUnstageAll: () => void;
|
|
onStage: (path: string) => Promise<void>;
|
|
onUnstage: (path: string) => Promise<void>;
|
|
onBulkStage: (paths: string[]) => void;
|
|
onBulkUnstage: (paths: string[]) => void;
|
|
onBulkDiscard: (paths: string[]) => void;
|
|
onPush: () => void;
|
|
onForcePush: () => void;
|
|
stagedFileCount: number;
|
|
stagedAdditions: number;
|
|
stagedDeletions: number;
|
|
};
|
|
|
|
function ChangesPanelDialogsSection({
|
|
dialogs,
|
|
isLoading,
|
|
}: Pick<ChangesPanelBodyProps, "dialogs" | "isLoading">) {
|
|
return (
|
|
<>
|
|
<DiscardDialog
|
|
open={dialogs.showDiscardDialog}
|
|
onOpenChange={dialogs.setShowDiscardDialog}
|
|
fileToDiscard={dialogs.fileToDiscard}
|
|
filesToDiscard={dialogs.filesToDiscard}
|
|
onConfirm={dialogs.handleDiscardConfirm}
|
|
/>
|
|
<AmendDialog
|
|
open={dialogs.amendDialogOpen}
|
|
onOpenChange={dialogs.setAmendDialogOpen}
|
|
amendMessage={dialogs.amendMessage}
|
|
onAmendMessageChange={dialogs.setAmendMessage}
|
|
onAmend={dialogs.handleAmend}
|
|
isLoading={isLoading}
|
|
/>
|
|
<ResetDialog
|
|
open={dialogs.resetDialogOpen}
|
|
onOpenChange={dialogs.setResetDialogOpen}
|
|
commitSha={dialogs.resetCommitSha}
|
|
onReset={dialogs.handleReset}
|
|
isLoading={isLoading}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type TimelineProps = Pick<
|
|
ChangesPanelBodyProps,
|
|
| "hasAnything"
|
|
| "hasUnstaged"
|
|
| "hasStaged"
|
|
| "hasCommits"
|
|
| "hasPRFiles"
|
|
| "canPush"
|
|
| "canCreatePR"
|
|
| "existingPrUrl"
|
|
| "unstagedFiles"
|
|
| "stagedFiles"
|
|
| "prFiles"
|
|
| "prCommits"
|
|
| "commits"
|
|
| "pendingStageFiles"
|
|
| "aheadCount"
|
|
| "isLoading"
|
|
| "loadingOperation"
|
|
| "dialogs"
|
|
| "onOpenDiffFile"
|
|
| "onEditFile"
|
|
| "onOpenCommitDetail"
|
|
| "onRevertCommit"
|
|
| "onStageAll"
|
|
| "onUnstageAll"
|
|
| "onStage"
|
|
| "onUnstage"
|
|
| "onBulkStage"
|
|
| "onBulkUnstage"
|
|
| "onBulkDiscard"
|
|
| "onPush"
|
|
| "onForcePush"
|
|
>;
|
|
|
|
type WorkingTreeProps = Pick<
|
|
TimelineProps,
|
|
| "hasUnstaged"
|
|
| "hasStaged"
|
|
| "unstagedFiles"
|
|
| "stagedFiles"
|
|
| "pendingStageFiles"
|
|
| "loadingOperation"
|
|
| "dialogs"
|
|
| "onOpenDiffFile"
|
|
| "onEditFile"
|
|
| "onStageAll"
|
|
| "onUnstageAll"
|
|
| "onStage"
|
|
| "onUnstage"
|
|
| "onBulkStage"
|
|
| "onBulkUnstage"
|
|
| "onBulkDiscard"
|
|
> & { isLastUnstaged: boolean; isLastStaged: boolean };
|
|
|
|
function WorkingTreeSections(props: WorkingTreeProps) {
|
|
const isBulkOp = props.pendingStageFiles.size === 0;
|
|
return (
|
|
<>
|
|
{props.hasUnstaged && (
|
|
<FileListSection
|
|
variant="unstaged"
|
|
files={props.unstagedFiles}
|
|
pendingStageFiles={props.pendingStageFiles}
|
|
isLast={props.isLastUnstaged}
|
|
actionLabel="Stage all"
|
|
isActionLoading={isBulkOp && props.loadingOperation === "stage"}
|
|
onAction={props.onStageAll}
|
|
onOpenDiff={props.onOpenDiffFile}
|
|
onEditFile={props.onEditFile}
|
|
onStage={props.onStage}
|
|
onUnstage={props.onUnstage}
|
|
onDiscard={props.dialogs.handleDiscardClick}
|
|
onBulkStage={props.onBulkStage}
|
|
onBulkDiscard={props.onBulkDiscard}
|
|
/>
|
|
)}
|
|
{props.hasStaged && (
|
|
<FileListSection
|
|
variant="staged"
|
|
files={props.stagedFiles}
|
|
pendingStageFiles={props.pendingStageFiles}
|
|
isLast={props.isLastStaged}
|
|
actionLabel="Commit"
|
|
isActionLoading={props.loadingOperation === "commit"}
|
|
onAction={props.dialogs.openCommitDialog}
|
|
secondaryActionLabel="Unstage all"
|
|
isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"}
|
|
onSecondaryAction={props.onUnstageAll}
|
|
onOpenDiff={props.onOpenDiffFile}
|
|
onEditFile={props.onEditFile}
|
|
onStage={props.onStage}
|
|
onUnstage={props.onUnstage}
|
|
onDiscard={props.dialogs.handleDiscardClick}
|
|
onBulkUnstage={props.onBulkUnstage}
|
|
onBulkDiscard={props.onBulkDiscard}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ChangesPanelTimeline(props: TimelineProps) {
|
|
if (!props.hasAnything) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
|
Your changed files will appear here
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const mergedCommits = mergeCommits(props.commits, props.prCommits);
|
|
const hasMergedCommits = mergedCommits.length > 0;
|
|
const hasLocalChanges = props.hasUnstaged || props.hasStaged;
|
|
const showCommits = props.hasStaged || props.hasCommits;
|
|
const showCommitsList = props.hasStaged || hasMergedCommits;
|
|
const hasSomethingAfterStaged = (props.hasPRFiles && hasLocalChanges) || showCommitsList;
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
{/* PR files first when no local working-tree changes */}
|
|
{props.hasPRFiles && !hasLocalChanges && (
|
|
<div data-testid="pr-files-section">
|
|
<PRFilesSection
|
|
files={props.prFiles}
|
|
isLast={!showCommitsList}
|
|
onOpenDiff={props.onOpenDiffFile}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<WorkingTreeSections
|
|
{...props}
|
|
isLastUnstaged={!props.hasStaged && !hasSomethingAfterStaged}
|
|
isLastStaged={!hasSomethingAfterStaged}
|
|
/>
|
|
|
|
{/* PR files after local changes when both exist */}
|
|
{props.hasPRFiles && hasLocalChanges && (
|
|
<div data-testid="pr-files-section">
|
|
<PRFilesSection
|
|
files={props.prFiles}
|
|
isLast={!showCommitsList}
|
|
onOpenDiff={props.onOpenDiffFile}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{showCommitsList && (
|
|
<CommitsSection
|
|
commits={mergedCommits}
|
|
isLast={!showCommits}
|
|
onOpenCommitDetail={props.onOpenCommitDetail}
|
|
onRevertCommit={props.onRevertCommit}
|
|
onAmendCommit={props.dialogs.handleOpenAmendDialog}
|
|
onResetToCommit={props.dialogs.handleOpenResetDialog}
|
|
/>
|
|
)}
|
|
{showCommits && (
|
|
<ActionButtonsSection
|
|
onOpenPRDialog={props.dialogs.openPRDialog}
|
|
onPush={props.onPush}
|
|
isLoading={props.isLoading}
|
|
loadingOperation={props.loadingOperation}
|
|
aheadCount={props.aheadCount}
|
|
canPush={props.canPush}
|
|
canCreatePR={props.canCreatePR}
|
|
existingPrUrl={props.existingPrUrl}
|
|
onForcePush={props.onForcePush}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChangesPanelBody(props: ChangesPanelBodyProps) {
|
|
return (
|
|
<PanelBody className="flex flex-col">
|
|
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
|
<ChangesPanelTimeline {...props} />
|
|
</div>
|
|
<ReviewProgressBar
|
|
reviewedCount={props.reviewedCount}
|
|
totalFileCount={props.totalFileCount}
|
|
onOpenReview={props.onOpenReview}
|
|
/>
|
|
<ChangesPanelDialogsSection dialogs={props.dialogs} isLoading={props.isLoading} />
|
|
</PanelBody>
|
|
);
|
|
}
|
|
|
|
function useChangesPanelPRData() {
|
|
const taskPR = useActiveTaskPR();
|
|
const refreshKey = taskPR?.last_synced_at ?? null;
|
|
const { files: prDiffFiles } = usePRDiff(
|
|
taskPR?.owner ?? null,
|
|
taskPR?.repo ?? null,
|
|
taskPR?.pr_number ?? null,
|
|
refreshKey,
|
|
);
|
|
const { commits: prCommitsList } = usePRCommits(
|
|
taskPR?.owner ?? null,
|
|
taskPR?.repo ?? null,
|
|
taskPR?.pr_number ?? null,
|
|
refreshKey,
|
|
);
|
|
const hasPRFiles = prDiffFiles.length > 0;
|
|
const hasPRCommits = prCommitsList.length > 0;
|
|
const prFiles = useMemo(() => mapPRFilesToChangedFiles(prDiffFiles), [prDiffFiles]);
|
|
return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles };
|
|
}
|
|
|
|
const ChangesPanel = memo(function ChangesPanel({
|
|
onOpenDiffFile,
|
|
onEditFile,
|
|
onOpenCommitDetail,
|
|
onOpenDiffAll,
|
|
onOpenReview,
|
|
}: ChangesPanelProps) {
|
|
const isArchived = useIsTaskArchived();
|
|
const { activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData();
|
|
|
|
const git = useSessionGit(activeSessionId);
|
|
const { toast } = useToast();
|
|
const { reviews } = useSessionFileReviews(activeSessionId);
|
|
const { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles } = useChangesPanelPRData();
|
|
const vcsDialogs = useVcsDialogs();
|
|
|
|
const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]);
|
|
const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]);
|
|
const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]);
|
|
|
|
const { reviewedCount, totalFileCount } = useMemo(
|
|
() => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prDiffFiles),
|
|
[git.allFiles, git.cumulativeDiff, reviews, prDiffFiles],
|
|
);
|
|
const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]);
|
|
|
|
const gitHandlers = useChangesGitHandlers(git, toast, baseBranch);
|
|
const localDialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation);
|
|
const dialogs = { ...localDialogs, ...vcsDialogs };
|
|
|
|
if (isArchived) return <ArchivedPanelPlaceholder />;
|
|
|
|
return (
|
|
<PanelRoot data-testid="changes-panel">
|
|
<ChangesPanelHeader
|
|
hasChanges={git.hasChanges}
|
|
hasCommits={git.hasCommits}
|
|
hasPRFiles={hasPRFiles}
|
|
displayBranch={git.branch}
|
|
baseBranchDisplay={baseBranchDisplay}
|
|
behindCount={git.behind}
|
|
isLoading={git.isLoading}
|
|
loadingOperation={git.loadingOperation}
|
|
onOpenDiffAll={onOpenDiffAll}
|
|
onOpenReview={onOpenReview}
|
|
onPull={gitHandlers.handlePull}
|
|
onRebase={gitHandlers.handleRebase}
|
|
/>
|
|
<ChangesPanelBody
|
|
hasAnything={git.hasAnything || hasPRFiles || hasPRCommits}
|
|
hasUnstaged={git.hasUnstaged}
|
|
hasStaged={git.hasStaged}
|
|
hasCommits={git.hasCommits}
|
|
hasPRFiles={hasPRFiles}
|
|
hasPRCommits={hasPRCommits}
|
|
canPush={git.canPush}
|
|
canCreatePR={git.canCreatePR}
|
|
existingPrUrl={existingPrUrl}
|
|
unstagedFiles={unstagedFiles}
|
|
stagedFiles={stagedFiles}
|
|
prFiles={prFiles}
|
|
prCommits={prCommitsList}
|
|
commits={git.commits}
|
|
pendingStageFiles={git.pendingStageFiles}
|
|
reviewedCount={reviewedCount}
|
|
totalFileCount={totalFileCount}
|
|
aheadCount={git.ahead}
|
|
isLoading={git.isLoading}
|
|
loadingOperation={git.loadingOperation}
|
|
dialogs={dialogs}
|
|
onOpenDiffFile={onOpenDiffFile}
|
|
onEditFile={onEditFile}
|
|
onOpenCommitDetail={onOpenCommitDetail}
|
|
onRevertCommit={gitHandlers.handleRevertCommit}
|
|
onOpenReview={onOpenReview}
|
|
onStageAll={git.stageAll}
|
|
onUnstageAll={git.unstageAll}
|
|
onStage={(path) => git.stageFile([path]).then(() => undefined)}
|
|
onUnstage={(path) => git.unstageFile([path]).then(() => undefined)}
|
|
onBulkStage={(paths) => {
|
|
git.stageFile(paths).catch(() => {});
|
|
}}
|
|
onBulkUnstage={(paths) => {
|
|
git.unstageFile(paths).catch(() => {});
|
|
}}
|
|
onBulkDiscard={localDialogs.handleBulkDiscardClick}
|
|
onPush={gitHandlers.handlePush}
|
|
onForcePush={gitHandlers.handleForcePush}
|
|
stagedFileCount={staged.stagedFileCount}
|
|
stagedAdditions={staged.stagedAdditions}
|
|
stagedDeletions={staged.stagedDeletions}
|
|
/>
|
|
</PanelRoot>
|
|
);
|
|
});
|
|
|
|
export { ChangesPanel };
|