Files
kandev/apps/web/components/task/changes-panel-file-row.tsx
T
José Almeida 54ddb0aa1e feat(web): highlight active-tab file row in Changes panel (#954)
* feat(web): track active file path for diff panels

Extend resolveFilePath so activeFilePath updates when a file-diff panel
(preview:file-diff or diff:file:<path>) becomes the active dockview
panel. Also re-subscribe onDidParametersChange for the preview file-diff
slot so swapping the preview to a different file refreshes the path.

* feat(web): highlight active-tab file row in Changes panel

When a file's diff tab is the active dockview panel, mark the matching
row in unstaged, staged, and PR Changes lists with data-active="true"
and reuse the existing bg-accent/60 highlight (same as multi-select).
PRFileRow also gains data-changes-file so it shares the existing row
selector with FileRow.

* test(web): cover active-tab highlight unit + E2E

Unit test in FileRow guards the data-active attribute and bg-accent/60
class when isActive (with and without isSelected). New E2E spec drives
the full flow: open file-a diff, switch to file-b, close the preview,
and verify highlight follows when a file moves from unstaged to staged.

* fix(web): use distinct e2e task titles for active-tab highlight spec

clickTab("Changes") fails strict-mode when the task title also contains
the word "Changes" — Playwright matches both the session tab and the
dockview Changes tab. Renames to "Active Highlight (Staged) Test".

* test(web): close pinned diff:file panels in closeFileDiffPreview

The helper previously closed only the preview slot, leaving pinned
diff:file:<path> panels (created when a preview is promoted) active.
Sweeps every diff:file: panel so the row settles to data-active=false
regardless of how the diff was opened.

* test(web): unit-cover resolveFilePath panel-id branches

Exercises every branch of the dockview-store resolveFilePath closure:
file:, diff:file:, preview:file-editor, and preview:file-diff (the
last two via params.path), plus the null-out paths. Catches regressions
in the panel-id mapping without paying for an E2E round-trip.

* fix(web): strip "changes" from active-tab spec profile names

The agent profile name becomes the dockview session tab title, so a
profile named "changes-active-highlight" makes clickTab("Changes")
match two .dv-default-tab elements (session tab + Changes tab) and
trip strict-mode. Rename to "active-highlight" / "active-highlight-
staged" to keep the locator unambiguous.

* docs(web): note multi-repo path collision in PRFilesRepoGroup

Mirrors the FileListBody comment so future authors see the same caveat
in both file-list components: activeFilePath carries the path but not
the repo, so identical paths across repos light up both rows.

* fix(web): snapshot panels before closing in closeFileDiffPreview

dockview's api.panels is a live reference; calling panel.api.close()
removes the panel in place, so iterating the live array skipped every
other diff:file:* panel when two or more were open. Filter into a
local array before closing.
2026-05-19 15:53:48 +01:00

332 lines
9.3 KiB
TypeScript

"use client";
import {
IconArrowBackUp,
IconPlus,
IconMinus,
IconCheck,
IconLoader2,
IconPencil,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { LineStat } from "@/components/diff-stat";
import type { FileInfo } from "@/lib/state/store";
import { FileStatusIcon } from "./file-status-icon";
import type { OpenDiffOptions } from "./changes-diff-target";
const splitPath = (path: string) => {
const lastSlash = path.lastIndexOf("/");
if (lastSlash === -1) return { folder: "", file: path };
return {
folder: path.slice(0, lastSlash),
file: path.slice(lastSlash + 1),
};
};
type ChangedFile = {
path: string;
status: FileInfo["status"];
staged: boolean;
plus: number | undefined;
minus: number | undefined;
oldPath: string | undefined;
/** Multi-repo: name of the repo this file lives in. Empty for single-repo. */
repositoryName?: string;
};
type FileRowProps = {
file: ChangedFile;
isPending: boolean;
isSelected?: boolean;
/** True when this file's diff/editor tab is the currently active dockview panel. */
isActive?: boolean;
onSelect?: (path: string, e: React.MouseEvent) => boolean;
onOpenDiff: (path: string, options?: OpenDiffOptions) => void;
// Multi-repo: handlers receive the file's repository_name so the per-file
// staging op routes to the right git repo. Same-named files (e.g. README.md
// in two repos) cannot be disambiguated by path alone.
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
onEditFile: (path: string) => void;
};
export function FileRow({
file,
isPending,
isSelected,
isActive,
onSelect,
onOpenDiff,
onStage,
onUnstage,
onDiscard,
onEditFile,
}: FileRowProps) {
const { folder, file: name } = splitPath(file.path);
const handleClick = (e: React.MouseEvent) => {
if (e.button === 2) return;
const consumed = onSelect?.(file.path, e);
if (!consumed) {
onOpenDiff(file.path, {
source: "uncommitted",
repositoryName: file.repositoryName,
});
}
};
return (
<li
data-testid={`file-row-${file.path.replace(/[/\\]/g, "-")}`}
data-changes-file={file.path}
data-selected={isSelected ? "true" : "false"}
data-active={isActive ? "true" : "false"}
className={cn(
"group flex items-center justify-between gap-2 text-sm rounded-md px-2 py-1.5 -mx-1 cursor-pointer",
"md:px-1 md:py-0.5",
isSelected || isActive
? "bg-accent/60 text-accent-foreground hover:bg-accent/50"
: "hover:bg-muted/60",
)}
onClick={handleClick}
>
<div className="flex items-center gap-2 min-w-0">
<StageButton
isPending={isPending}
staged={file.staged}
path={file.path}
repo={file.repositoryName}
onStage={onStage}
onUnstage={onUnstage}
/>
<button type="button" className="min-w-0 text-left cursor-pointer" title={file.path}>
<p className="flex text-foreground text-xs min-w-0">
{folder && (
<span className="text-foreground/60 truncate min-w-0 [flex-shrink:9999]">
{folder}/
</span>
)}
<span className="font-medium text-foreground truncate min-w-0">{name}</span>
</p>
</button>
</div>
<div className="flex items-center gap-2 shrink-0">
<FileRowActions
path={file.path}
repo={file.repositoryName}
onDiscard={onDiscard}
onEditFile={onEditFile}
/>
<LineStat added={file.plus} removed={file.minus} />
<FileStatusIcon status={file.status} />
</div>
</li>
);
}
function StageButton({
isPending,
staged,
path,
repo,
onStage,
onUnstage,
}: {
isPending: boolean;
staged: boolean;
path: string;
repo?: string;
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
}) {
if (isPending) {
return (
<div className="flex-shrink-0 flex items-center justify-center size-4">
<IconLoader2 className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
);
}
if (staged) {
return (
<button
type="button"
title="Unstage file"
className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onUnstage(path, repo);
}}
>
<IconCheck className="h-3 w-3 group-hover/unstage:hidden" />
<IconMinus className="h-2.5 w-2.5 hidden group-hover/unstage:block" />
</button>
);
}
return (
<button
type="button"
title="Stage file"
className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onStage(path, repo);
}}
>
<IconPlus className="h-2.5 w-2.5" />
</button>
);
}
function FileRowActions({
path,
repo,
onDiscard,
onEditFile,
}: {
path: string;
repo?: string;
onDiscard: (path: string, repo?: string) => void;
onEditFile: (path: string) => void;
}) {
return (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDiscard(path, repo);
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Discard changes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEditFile(path);
}}
>
<IconPencil className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</div>
);
}
// --- Bulk action components (used by FileListSection) ---
export function DefaultActionButtons({
actionLabel,
isActionLoading,
onAction,
secondaryActionLabel,
isSecondaryActionLoading,
onSecondaryAction,
}: {
actionLabel: string;
isActionLoading?: boolean;
onAction: () => void;
secondaryActionLabel?: string;
isSecondaryActionLoading?: boolean;
onSecondaryAction?: () => void;
}) {
return (
<>
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={onAction}
disabled={isActionLoading}
>
{isActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
{actionLabel}
</Button>
{onSecondaryAction && secondaryActionLabel && (
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={onSecondaryAction}
disabled={isSecondaryActionLoading}
>
{isSecondaryActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
{secondaryActionLabel}
</Button>
)}
</>
);
}
export function BulkActionBar({
variant,
selectionCount,
selectedPaths,
onBulkStage,
onBulkUnstage,
onBulkDiscard,
}: {
variant: "unstaged" | "staged";
selectionCount: number;
selectedPaths: Set<string>;
onBulkStage?: (paths: string[]) => void;
onBulkUnstage?: (paths: string[]) => void;
onBulkDiscard?: (paths: string[]) => void;
}) {
const paths = [...selectedPaths];
return (
<div data-testid={`bulk-actions-${variant}`} className="flex items-center gap-1.5">
<span className="text-[11px] text-muted-foreground">{selectionCount} selected</span>
{variant === "unstaged" && onBulkStage && (
<Button
data-testid="bulk-stage"
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={() => onBulkStage(paths)}
>
Stage {selectionCount}
</Button>
)}
{variant === "staged" && onBulkUnstage && (
<Button
data-testid={`bulk-unstage-${variant}`}
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={() => onBulkUnstage(paths)}
>
Unstage {selectionCount}
</Button>
)}
{onBulkDiscard && (
<Button
data-testid={`bulk-discard-${variant}`}
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer text-destructive hover:text-destructive"
onClick={() => onBulkDiscard(paths)}
>
Discard {selectionCount}
</Button>
)}
</div>
);
}