fix(web): localize single-file confirmations (#2894)

This commit is contained in:
José Almeida
2026-08-22 20:45:13 +00:00
committed by GitHub
parent 59452fc890
commit 779e3eff28
29 changed files with 910 additions and 205 deletions
@@ -137,43 +137,50 @@ describe("ActionConfirmPopover anchor lifecycle", () => {
});
});
function BoundaryHarness() {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
return (
<>
<button ref={anchorRef} type="button">
Delete
</button>
<div ref={menuRef} data-testid="menu-boundary" tabIndex={0}>
Menu
</div>
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
focusBoundaryRef={menuRef}
title={deleteTitle}
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onOpenChange={setOpen}
onConfirm={vi.fn()}
/>
</>
);
}
describe("ActionConfirmPopover focus boundaries", () => {
afterEach(cleanup);
it("keeps the popover open while focus moves inside the optional menu boundary", async () => {
function MenuHarness() {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
return (
<>
<button ref={anchorRef} type="button">
Delete
</button>
<div ref={menuRef} data-testid="menu-boundary" tabIndex={0}>
Menu
</div>
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
focusBoundaryRef={menuRef}
title={deleteTitle}
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onOpenChange={setOpen}
onConfirm={vi.fn()}
/>
</>
);
}
render(<MenuHarness />);
render(<BoundaryHarness />);
screen.getByTestId("menu-boundary").focus();
await waitFor(() => expect(screen.getByRole("dialog", { name: deleteTitle })).toBeTruthy());
});
it("keeps the popover open while pointer interaction stays inside the optional boundary", async () => {
render(<BoundaryHarness />);
fireEvent.pointerDown(screen.getByTestId("menu-boundary"));
await waitFor(() => expect(screen.getByRole("dialog", { name: deleteTitle })).toBeTruthy());
});
it("dismisses when focus moves outside the optional menu boundary", async () => {
function MenuHarness() {
const [open, setOpen] = useState(true);
@@ -173,6 +173,12 @@ function ActionConfirmPopoverContent({
onFocusOutside={(event) => {
if (focusBoundaryRef?.current?.contains(event.target as Node)) event.preventDefault();
}}
onInteractOutside={(event) => {
const target = event.target as Node;
if (anchorRef.current?.contains(target) || focusBoundaryRef?.current?.contains(target)) {
event.preventDefault();
}
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
if (!confirmedRef.current && isConnected(anchorRef.current)) anchorRef.current.focus();
@@ -66,7 +66,7 @@ export function InlineConfirmActions({
data-testid={testId}
className={
description
? "flex w-full min-w-0 flex-col items-stretch gap-2"
? "flex min-w-0 flex-1 basis-full flex-col items-stretch gap-2"
: `flex shrink-0 items-center justify-end gap-1 ${touch ? "min-h-11" : "w-full min-h-10"}`
}
onPointerDown={(event) => event.stopPropagation()}
@@ -78,7 +78,11 @@ export function InlineConfirmActions({
onCancel();
}}
>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
{description ? (
<p className="min-w-0 max-w-full text-xs text-muted-foreground [overflow-wrap:anywhere]">
{description}
</p>
) : null}
<div className="flex items-center justify-end gap-1">
<Button
ref={cancelRef}
@@ -0,0 +1,25 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DiscardLocalChangesDialog } from "./discard-local-changes-dialog";
afterEach(cleanup);
describe("DiscardLocalChangesDialog", () => {
it("keeps fresh-branch dirty-file consent in a scope-explaining modal", () => {
render(
<DiscardLocalChangesDialog
open
onOpenChange={vi.fn()}
dirtyFiles={["src/one.ts"]}
repoPath="/tmp/repo"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByRole("alertdialog")).toBeTruthy();
expect(screen.getByTestId("discard-local-changes-files").textContent).toContain("src/one.ts");
expect(screen.getByTestId("discard-local-changes-confirm")).toBeTruthy();
});
});
@@ -52,9 +52,10 @@ function ChangesPanelDialogsSection({
<>
<DiscardDialog
open={dialogs.showDiscardDialog}
onOpenChange={dialogs.setShowDiscardDialog}
onOpenChange={dialogs.handleDiscardOpenChange}
fileToDiscard={dialogs.fileToDiscard}
filesToDiscard={dialogs.filesToDiscard}
anchorRef={dialogs.discardAnchorRef}
onConfirm={dialogs.handleDiscardConfirm}
/>
<AmendDialog
@@ -137,7 +137,7 @@ export type ChangesPanelBodyProps = {
onUnstage: (path: string, repo?: string) => Promise<void>;
onBulkStage: (paths: string[]) => void;
onBulkUnstage: (paths: string[]) => void;
onBulkDiscard: (paths: string[]) => void;
onBulkDiscard: (paths: string[], anchor?: HTMLElement) => void;
onPush: () => void;
onForcePush: () => void;
stagedFileCount: number;
@@ -0,0 +1,95 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useRef, useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/confirmation/action-confirm-popover", () => ({
ActionConfirmPopover: ({ onConfirm }: { onConfirm: () => void }) => (
<div data-testid="discard-local-confirmation">
<button type="button" onClick={onConfirm}>
confirm
</button>
</div>
),
}));
import { DiscardDialog } from "./changes-panel-dialogs";
afterEach(cleanup);
function DiscardHarness({
fileToDiscard,
filesToDiscard,
}: {
fileToDiscard: string | null;
filesToDiscard: string[] | null;
}) {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={anchorRef} type="button">
discard
</button>
<DiscardDialog
open={open}
onOpenChange={setOpen}
fileToDiscard={fileToDiscard}
filesToDiscard={filesToDiscard}
anchorRef={anchorRef}
onConfirm={vi.fn()}
/>
</>
);
}
describe("DiscardDialog target scope", () => {
it.each([
["direct file", "src/one.ts", null],
["one selected file", null, ["src/one.ts"]],
] as const)("uses local confirmation for a %s", (_kind, fileToDiscard, filesToDiscard) => {
render(
<DiscardHarness
fileToDiscard={fileToDiscard}
filesToDiscard={filesToDiscard ? [...filesToDiscard] : null}
/>,
);
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(screen.getByTestId("discard-local-confirmation")).toBeTruthy();
});
it("keeps discard-all scope in the existing modal", () => {
render(<DiscardHarness fileToDiscard={null} filesToDiscard={["src/one.ts", "src/two.ts"]} />);
expect(screen.getByRole("alertdialog")).toBeTruthy();
expect(screen.queryByTestId("discard-local-confirmation")).toBeNull();
});
it("does not invoke confirmation mutation before local confirm", () => {
const onConfirm = vi.fn();
function Harness() {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={anchorRef} type="button">
discard
</button>
<DiscardDialog
open={open}
onOpenChange={setOpen}
fileToDiscard="src/one.ts"
filesToDiscard={null}
anchorRef={anchorRef}
onConfirm={onConfirm}
/>
</>
);
}
render(<Harness />);
expect(onConfirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "confirm" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, type RefObject } from "react";
import { IconGitCommit, IconLoader2, IconCheck } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -27,6 +27,7 @@ import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group";
import { Trans, useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
// --- Discard Confirmation Dialog ---
@@ -35,45 +36,71 @@ type DiscardDialogProps = {
onOpenChange: (open: boolean) => void;
fileToDiscard: string | null;
filesToDiscard: string[] | null;
anchorRef: RefObject<HTMLElement | null>;
onConfirm: () => void;
};
function DiscardFileDescription({ file }: { file: string | null }) {
return (
<Trans i18nKey="task:discardChangesToFile" values={{ file }}>
This will permanently discard all changes to{" "}
<span className="font-semibold [overflow-wrap:anywhere]">{file}</span>. This action cannot be
undone.
</Trans>
);
}
export function DiscardDialog({
open,
onOpenChange,
fileToDiscard,
filesToDiscard,
anchorRef,
onConfirm,
}: DiscardDialogProps) {
const { t } = useTranslation();
const isBulk = filesToDiscard && filesToDiscard.length > 1;
const bulkCount = filesToDiscard?.length ?? 0;
const isBulk = bulkCount > 1;
const displayFile = fileToDiscard ?? (filesToDiscard?.length === 1 ? filesToDiscard[0] : null);
const description = isBulk
? t("task:discardAllChangesToFiles", { count: filesToDiscard.length })
: null;
const description = isBulk ? t("task:discardAllChangesToFiles", { count: bulkCount }) : null;
const title = t("task:discardChanges");
const cancelLabel = t("common:cancel");
const confirmLabel = t("task:discard");
if (!isBulk) {
return (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={title}
description={<DiscardFileDescription file={displayFile} />}
cancelLabel={cancelLabel}
confirmLabel={confirmLabel}
confirmTestId="discard-local-changes-confirm"
testId="discard-local-changes-confirm-popover"
onOpenChange={onOpenChange}
onCancel={() => onOpenChange(false)}
onConfirm={onConfirm}
/>
);
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("task:discardChanges")}</AlertDialogTitle>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription className="break-words">
{description ?? (
<Trans i18nKey="task:discardChangesToFile" values={{ file: displayFile }}>
This will permanently discard all changes to{" "}
<span className="font-semibold [overflow-wrap:anywhere]">{displayFile}</span>. This
action cannot be undone.
</Trans>
)}
{description ?? <DiscardFileDescription file={displayFile} />}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common:cancel")}</AlertDialogCancel>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("task:discard")}
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,8 +1,18 @@
import { describe, it, expect, vi } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { FileRow } from "./changes-panel-file-row";
const responsive = vi.hoisted(() => ({ isFinePointer: true }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsive,
}));
afterEach(() => {
responsive.isFinePointer = true;
});
const noop = () => {};
const noopSelect = () => false;
@@ -216,6 +226,18 @@ describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
expect(gridWrapper.className).toContain("col-start-1");
expect(gridWrapper.className).toContain("row-start-1");
});
it("hides statistics behind always-visible actions for coarse pointers", () => {
responsive.isFinePointer = false;
const { container } = renderRow("file.go");
const li = container.querySelector("li") as HTMLElement;
const gridWrapper = li.children[1] as HTMLElement;
const statsLayer = gridWrapper.children[0] as HTMLElement;
const actionsLayer = gridWrapper.children[1] as HTMLElement;
expect(statsLayer.className.split(/\s+/)).toContain("opacity-0");
expect(actionsLayer.className.split(/\s+/)).toContain("opacity-100");
});
});
describe("FileRow tree-mode hover stage action", () => {
@@ -19,6 +19,7 @@ import { getFileCategory } from "@/lib/utils/file-types";
import type { ChangedFile } from "./changes-panel-helpers";
import type { OpenDiffOptions } from "./changes-diff-target";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
const splitPath = (path: string) => {
const lastSlash = path.lastIndexOf("/");
@@ -42,7 +43,7 @@ type FileRowProps = {
// 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;
onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void;
onEditFile: (path: string, repo?: string) => void;
/**
* Tree mode: skip the folder prefix, swap the left-side stage button for a
@@ -69,6 +70,7 @@ export function FileRow({
treeMode,
indentPx,
}: FileRowProps) {
const { isFinePointer } = useResponsiveBreakpoint();
const { folder, file: name } = splitPath(file.path);
const showFolder = !treeMode && folder;
@@ -138,13 +140,11 @@ export function FileRow({
</button>
</div>
<div className="grid items-center shrink-0 [&>*]:col-start-1 [&>*]:row-start-1">
<div className="flex items-center gap-2 justify-end transition-opacity group-hover:opacity-0 pointer-events-none">
<LineStat added={file.plus} removed={file.minus} />
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
</div>
<FileRowStats file={file} isFinePointer={isFinePointer} />
<FileRowActions
path={file.path}
repo={file.repositoryName}
isFinePointer={isFinePointer}
onDiscard={onDiscard}
onEditFile={onEditFile}
/>
@@ -153,6 +153,20 @@ export function FileRow({
);
}
function FileRowStats({ file, isFinePointer }: { file: ChangedFile; isFinePointer: boolean }) {
return (
<div
className={cn(
"flex items-center gap-2 justify-end transition-opacity pointer-events-none",
isFinePointer ? "group-hover:opacity-0" : "opacity-0",
)}
>
<LineStat added={file.plus} removed={file.minus} />
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
</div>
);
}
function TreeModeFileActionSlot({
name,
isPending,
@@ -250,28 +264,39 @@ function StageButton({
function FileRowActions({
path,
repo,
isFinePointer,
onDiscard,
onEditFile,
}: {
path: string;
repo?: string;
onDiscard: (path: string, repo?: string) => void;
isFinePointer: boolean;
onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void;
onEditFile: (path: string, repo?: string) => void;
}) {
const { t } = useTranslation();
return (
<div
data-testid="file-row-hover-actions"
className="flex items-center gap-1 justify-end opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto"
className={cn(
"flex items-center gap-1 justify-end transition-opacity",
isFinePointer
? "opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto"
: "opacity-100 pointer-events-auto",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
aria-label={t("task:discardChanges2")}
className={cn(
"text-muted-foreground hover:text-foreground cursor-pointer",
!isFinePointer && "min-h-11 min-w-11",
)}
onClick={(e) => {
e.stopPropagation();
onDiscard(path, repo);
onDiscard(path, repo, e.currentTarget);
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
@@ -283,7 +308,11 @@ function FileRowActions({
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
aria-label={t("common:edit")}
className={cn(
"text-muted-foreground hover:text-foreground cursor-pointer",
!isFinePointer && "min-h-11 min-w-11",
)}
onClick={(e) => {
e.stopPropagation();
onEditFile(path, repo);
@@ -356,7 +385,7 @@ export function BulkActionBar({
selectedPaths: Set<string>;
onBulkStage?: (paths: string[]) => void;
onBulkUnstage?: (paths: string[]) => void;
onBulkDiscard?: (paths: string[]) => void;
onBulkDiscard?: (paths: string[], anchor?: HTMLElement) => void;
}) {
const { t } = useTranslation();
const paths = [...selectedPaths];
@@ -392,7 +421,7 @@ export function BulkActionBar({
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer text-destructive hover:text-destructive"
onClick={() => onBulkDiscard(paths)}
onClick={(e) => onBulkDiscard(paths, e.currentTarget)}
>
{t("task:discardCount", { selectionCount })}
</Button>
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useRef, useState, useCallback } from "react";
import { t } from "@/lib/i18n";
import { gitOperationLabel } from "@/hooks/use-git-with-feedback";
import type { useToast } from "@/components/toast-provider";
@@ -202,22 +202,33 @@ function useChangesDiscardAmendHandlers(
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
const [filesToDiscard, setFilesToDiscard] = useState<string[] | null>(null);
const discardAnchorRef = useRef<HTMLElement | null>(null);
// Multi-repo: remember the clicked file's repo so the discard op routes to
// the right git repo. Path alone is ambiguous when two repos share a name.
const [repoToDiscard, setRepoToDiscard] = useState<string | undefined>(undefined);
const handleDiscardClick = useCallback((filePath: string, repo?: string) => {
setFileToDiscard(filePath);
setRepoToDiscard(repo);
setFilesToDiscard(null);
setShowDiscardDialog(true);
}, []);
const handleBulkDiscardClick = useCallback((paths: string[]) => {
const handleDiscardClick = useCallback(
(filePath: string, repo?: string, anchor?: HTMLElement) => {
discardAnchorRef.current = anchor ?? null;
setFileToDiscard(filePath);
setRepoToDiscard(repo);
setFilesToDiscard(null);
setShowDiscardDialog(true);
},
[],
);
const handleBulkDiscardClick = useCallback((paths: string[], anchor?: HTMLElement) => {
discardAnchorRef.current = anchor ?? null;
setFilesToDiscard(paths);
setFileToDiscard(null);
setRepoToDiscard(undefined);
setShowDiscardDialog(true);
}, []);
const handleDiscardOpenChange = useCallback((open: boolean) => {
setShowDiscardDialog(open);
// Keep the anchor through the popover's close autofocus. A later discard
// replaces it, while confirmation clears it after the mutation settles.
}, []);
const handleDiscardConfirm = useCallback(async () => {
const paths = filesToDiscard ?? (fileToDiscard ? [fileToDiscard] : null);
if (!paths) return;
@@ -237,6 +248,7 @@ function useChangesDiscardAmendHandlers(
});
} finally {
setShowDiscardDialog(false);
discardAnchorRef.current = null;
setFileToDiscard(null);
setFilesToDiscard(null);
setRepoToDiscard(undefined);
@@ -271,6 +283,8 @@ function useChangesDiscardAmendHandlers(
return {
showDiscardDialog,
setShowDiscardDialog,
discardAnchorRef,
handleDiscardOpenChange,
fileToDiscard,
filesToDiscard,
handleDiscardClick,
@@ -249,10 +249,10 @@ type FileListSectionProps = {
// hits the right git repo. Same-named files across repos collide by path.
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void;
onBulkStage?: (paths: string[]) => void;
onBulkUnstage?: (paths: string[]) => void;
onBulkDiscard?: (paths: string[]) => void;
onBulkDiscard?: (paths: string[], anchor?: HTMLElement) => void;
// Per-repo action handlers shown inline with each repo group header. Always
// rendered, including single-repo (with one entry pre-selected) — the empty
// `repo` argument routes ops to the workspace root in that case.
@@ -280,7 +280,7 @@ type FileListBodyProps = {
onEditFile: (path: string, repo?: string) => void;
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void;
/** Per-repo group header actions; primary = Stage all (unstaged) / Commit (staged). */
onRepoAction?: (repo: string) => void;
onRepoSecondaryAction?: (repo: string) => void;
@@ -86,7 +86,7 @@ type ChangesTreeProps = {
onEditFile: (path: string, repo?: string) => void;
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void;
variant: "unstaged" | "staged";
/** Shared with the parent FileListSection so the BulkActionBar reflects
* selections made in tree mode. */
@@ -181,7 +181,7 @@ type RepoTreeGroupProps = {
onEditFile: (path: string, repo?: string) => void;
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void;
primaryLabel: string;
secondaryLabel?: string;
onRepoAction?: (repo: string) => void;
@@ -6,6 +6,7 @@ import type { FileBrowserRow } from "./file-browser-hooks";
vi.mock("./file-context-menu", () => ({
FileContextMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
useFileDeleteAction: () => null,
useFileRename: () => ({
isRenaming: false,
renameValue: "",
+52 -16
View File
@@ -8,6 +8,7 @@ import {
IconFolderOpen,
IconRefresh,
IconDotsVertical,
IconTrash,
} from "@tabler/icons-react";
import {
DropdownMenu,
@@ -18,6 +19,7 @@ import {
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { FileIcon } from "@/components/ui/file-icon";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import type { FileTreeNode } from "@/lib/types/backend";
import type { FileInfo } from "@/lib/state/store";
import type { FileBrowserRow } from "./file-browser-hooks";
@@ -25,6 +27,7 @@ import { InlineFileInput } from "./inline-file-input";
import { renderSessionOrLoadState } from "./file-browser-load-state";
import {
FileContextMenu,
useFileDeleteAction,
useFileRename,
TreeNodeName,
getGitStatusTextClass,
@@ -151,11 +154,29 @@ export function FileTreeNodeTouchActions({
showTouchActions?: boolean;
onAddToChatContext?: (node: FileTreeNode) => void;
}) {
const { t } = useTranslation("chat");
if (!showTouchActions || !onAddToChatContext) return null;
const { t } = useTranslation();
const deleteAction = useFileDeleteAction();
if (!showTouchActions || (!onAddToChatContext && !deleteAction)) return null;
const stopRowInteraction = (event: React.SyntheticEvent) => event.stopPropagation();
if (deleteAction?.confirming && !deleteAction.isBulk) {
return (
<InlineConfirmActions
density="touch"
testId="file-delete-inline-confirmation"
ariaLabel={deleteAction.title}
description={deleteAction.description}
cancelLabel={deleteAction.cancelLabel}
confirmLabel={deleteAction.label}
confirmAriaLabel={deleteAction.title}
confirmTestId="file-delete-confirm"
onCancel={deleteAction.onCancel}
onConfirm={deleteAction.onConfirm}
/>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -178,13 +199,28 @@ export function FileTreeNodeTouchActions({
data-testid="file-tree-touch-menu"
onClick={stopRowInteraction}
>
<DropdownMenuItem
data-testid="file-tree-touch-add-to-chat"
className="min-h-11 cursor-pointer"
onSelect={() => onAddToChatContext(node)}
>
{t("chat:addToChatContext")}
</DropdownMenuItem>
{onAddToChatContext && (
<DropdownMenuItem
data-testid="file-tree-touch-add-to-chat"
className="min-h-11 cursor-pointer"
onSelect={() => onAddToChatContext(node)}
>
{t("chat:addToChatContext")}
</DropdownMenuItem>
)}
{deleteAction && (
<DropdownMenuItem
data-testid="file-tree-touch-delete"
variant="destructive"
className="min-h-11 cursor-pointer"
onSelect={deleteAction.onDelete}
>
<IconTrash className="h-3.5 w-3.5" />
{deleteAction.isBulk
? t("task:deleteItemsLabel", { count: deleteAction.selectedCount })
: t("task:delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -213,6 +249,7 @@ export function TreeNodeItem(props: TreeNodeRowProps) {
const rename = useFileRename(node, tree, setTree, onRenameFile);
const isSelected = props.isSelectedFn?.(node.path) ?? false;
const isDropTarget = node.is_dir && props.dragOverPath === node.path;
const rowAnchorRef = React.useRef<HTMLDivElement>(null);
const handleClick = (e: React.MouseEvent) => {
if (e.button === 2) return;
@@ -225,6 +262,7 @@ export function TreeNodeItem(props: TreeNodeRowProps) {
// Inline the row JSX so ContextMenuTrigger asChild can attach directly to the DOM div
const rowContent = (
<div
ref={rowAnchorRef}
data-testid="file-tree-node"
data-path={node.path}
data-is-dir={node.is_dir ? "true" : "false"}
@@ -232,12 +270,9 @@ export function TreeNodeItem(props: TreeNodeRowProps) {
data-selected={isSelected ? "true" : "false"}
aria-selected={isSelected}
role="treeitem"
className={getTreeNodeRowClass(
isActive,
isActiveFolder,
isSelected,
props.isDragging,
isDropTarget,
className={cn(
getTreeNodeRowClass(isActive, isActiveFolder, isSelected, props.isDragging, isDropTarget),
props.showTouchActions && "flex-wrap",
)}
style={{ paddingLeft: treeNodePaddingLeft(row.depth, node.is_dir) }}
onClick={handleClick}
@@ -282,6 +317,7 @@ export function TreeNodeItem(props: TreeNodeRowProps) {
onAddToChatContext={onAddToChatContext}
selectedCount={props.selectedCount}
selectedPaths={props.selectedPaths}
anchorRef={rowAnchorRef}
>
{rowContent}
</FileContextMenu>
@@ -453,7 +489,7 @@ function FileTreeView(props: FileBrowserContentAreaProps) {
const { tree, visibleRows, creatingInPath, onCreateFileSubmit, onCancelCreate } = props;
if (!tree) return null;
return (
<div className="pb-2">
<div className="w-full min-w-0 max-w-full pb-2">
{creatingInPath === "" && (
<InlineFileInput
depth={0}
@@ -23,6 +23,7 @@ vi.mock("./file-context-menu", () => ({
{children}
</div>
),
useFileDeleteAction: () => null,
useFileRename: () => ({
isRenaming: false,
renameValue: "",
@@ -1,8 +1,14 @@
import React, { act } from "react";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, cleanup, fireEvent, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FileTreeNode } from "@/lib/types/backend";
const responsive = vi.hoisted(() => ({ isFinePointer: true }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsive,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
@@ -13,9 +19,11 @@ vi.mock("@/components/toast-provider", () => ({
// raw `ns:key` and the assertions then only prove the stub works.
import { FileContextMenu, TreeNodeName, useFileRename } from "./file-context-menu";
import { FileTreeNodeTouchActions } from "./file-browser-parts";
const FILE_NODE: FileTreeNode = { name: "README.md", path: "README.md", is_dir: false, size: 0 };
const DIR_NODE: FileTreeNode = { name: "src", path: "src", is_dir: true, size: 0 };
const DELETE_CONFIRM_POPOVER_ID = "file-delete-confirm-popover";
const RENAME_ROW = "rename-row";
const FOCUS_ANCHOR = "focus-anchor";
const BULK_TREE: FileTreeNode = {
@@ -31,6 +39,7 @@ const BULK_TREE: FileTreeNode = {
afterEach(() => {
cleanup();
responsive.isFinePointer = true;
vi.useRealTimers();
});
@@ -323,6 +332,60 @@ describe("FileContextMenu chat context item", () => {
});
describe("FileContextMenu bulk deletion", () => {
it.each([
["file", FILE_NODE],
["folder", DIR_NODE],
] as const)("uses local confirmation for one %s", async (_kind, node) => {
const onDeleteFile = vi.fn().mockResolvedValue(true);
render(
<FileContextMenu
node={node}
tree={node}
setTree={() => {}}
onDeleteFile={onDeleteFile}
onStartRename={() => {}}
>
<div data-testid="single-delete-row">row</div>
</FileContextMenu>,
);
openMenu("single-delete-row");
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(onDeleteFile).not.toHaveBeenCalled();
expect(screen.queryByRole("alertdialog")).toBeNull();
await waitFor(() => expect(screen.getByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeTruthy());
const confirmation = screen.getByTestId(DELETE_CONFIRM_POPOVER_ID);
if (node.is_dir) {
expect(confirmation.textContent).toContain("This will permanently delete src");
} else {
expect(confirmation.textContent).toContain("This will permanently delete README.md");
expect(confirmation.textContent).not.toContain("file inside it");
}
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull());
expect(onDeleteFile).not.toHaveBeenCalled();
openMenu("single-delete-row");
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => expect(screen.getByTestId("file-delete-confirm")).toBeTruthy());
fireEvent.click(screen.getByTestId("file-delete-confirm"));
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(node.path));
});
it("keeps a multi-selection in the scope-explaining modal", () => {
const onDeleteFile = vi.fn().mockResolvedValue(true);
render(<BulkDeleteHarness onDeleteFile={onDeleteFile} />);
openMenu("bulk-row");
fireEvent.click(screen.getByRole("menuitem", { name: "Delete 2 items" }));
expect(screen.getByRole("alertdialog")).toBeTruthy();
expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull();
});
it("keeps failed paths visible after a partial deletion failure", async () => {
const onDeleteFile = vi.fn(async (path: string) => path === "a.txt");
render(<BulkDeleteHarness onDeleteFile={onDeleteFile} />);
@@ -336,3 +399,40 @@ describe("FileContextMenu bulk deletion", () => {
expect(screen.getByTestId("tree-paths").textContent).not.toContain("a.txt");
});
});
describe("FileContextMenu touch actions", () => {
it("exposes Delete in the touch menu and replaces it with 44px inline actions", async () => {
responsive.isFinePointer = false;
const onDeleteFile = vi.fn().mockResolvedValue(true);
render(
<FileContextMenu
node={FILE_NODE}
tree={FILE_NODE}
setTree={() => {}}
onDeleteFile={onDeleteFile}
onStartRename={() => {}}
>
<div data-testid="touch-delete-row">
<FileTreeNodeTouchActions node={FILE_NODE} showTouchActions />
</div>
</FileContextMenu>,
);
fireEvent.pointerDown(screen.getByTestId("file-tree-node-actions"));
fireEvent.click(screen.getByTestId("file-tree-touch-delete"));
await waitFor(() => expect(screen.getByTestId("file-delete-inline-confirmation")).toBeTruthy());
expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull();
expect(onDeleteFile).not.toHaveBeenCalled();
expect(
screen.getByTestId("file-delete-inline-confirmation").querySelectorAll("button"),
).toHaveLength(2);
fireEvent.click(
within(screen.getByTestId("file-delete-inline-confirmation")).getByTestId(
"file-delete-confirm",
),
);
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(FILE_NODE.path));
});
});
+158 -111
View File
@@ -3,16 +3,7 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Input } from "@kandev/ui/input";
import { IconDownload, IconMessageDots, IconPencil, IconTrash } from "@tabler/icons-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { AlertDialog } from "@kandev/ui/alert-dialog";
import {
ContextMenu,
ContextMenuContent,
@@ -22,7 +13,9 @@ import {
} from "@kandev/ui/context-menu";
import { cn } from "@/lib/utils";
import { useToast } from "@/components/toast-provider";
import { Trans, useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useTranslation } from "react-i18next";
import type { FileTreeNode } from "@/lib/types/backend";
import type { FileInfo } from "@/lib/state/store";
import {
@@ -36,32 +29,33 @@ import {
canOpenNodeInEditor,
useFileTreeEditorActions,
} from "./file-tree-editor-menu";
import {
DeleteConfirmDialog,
DeleteFileDescription,
DeleteFolderDescription,
} from "./file-delete-confirmation";
type GitFileStatus = FileInfo["status"] | undefined;
/** Folder-delete copy. Split out so the two `<Trans>` bodies do not become a
* nested ternary inside the dialog. */
function DeleteFolderDescription({ name, fileCount }: { name: string; fileCount: number }) {
if (fileCount > 0) {
return (
<Trans
i18nKey="task:deleteFolderWithFilesConfirm"
count={fileCount}
values={{ name, count: fileCount }}
>
This will permanently delete <span className="font-semibold">{name}</span> and{" "}
<span className="font-semibold">{fileCount}</span> files inside it. This action cannot be
undone.
</Trans>
);
}
return (
<Trans i18nKey="task:deleteFolderConfirm" values={{ name }}>
This will permanently delete <span className="font-semibold">{name}</span>. This action cannot
be undone.
</Trans>
);
export type FileDeleteAction = {
isBulk: boolean;
selectedCount: number;
confirming: boolean;
title: string;
label: string;
cancelLabel: string;
description: React.ReactNode;
onDelete: () => void;
onCancel: () => void;
onConfirm: () => void;
};
const FileDeleteActionContext = React.createContext<FileDeleteAction | null>(null);
export function useFileDeleteAction() {
return React.useContext(FileDeleteActionContext);
}
function deleteNodeOptimistically(
tree: FileTreeNode | null,
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>,
@@ -91,45 +85,6 @@ function removeSuccessfullyDeletedPaths(
return nextTree;
}
function DeleteConfirmDialog({
isBulk,
selectedCount,
node,
fileCount,
onConfirm,
}: {
isBulk: boolean;
selectedCount: number;
node: FileTreeNode;
fileCount: number;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const title = isBulk
? t("task:deleteItemsTitle", { count: selectedCount })
: t("task:deleteFolderTitle");
return (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>
{isBulk ? (
t("task:thisWillPermanentlyDeleteSelectedItems", { selectedCount })
) : (
<DeleteFolderDescription name={node.name} fileCount={fileCount} />
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} variant="destructive" className="cursor-pointer">
{t("task:delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
);
}
type FileContextMenuItemsProps = {
node: FileTreeNode;
isBulk: boolean;
@@ -138,7 +93,7 @@ type FileContextMenuItemsProps = {
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
onDownloadFile?: (path: string) => Promise<boolean>;
onStartRename: () => void;
onDelete: () => void;
onDelete: (event: Event) => void;
};
function FileContextMenuItems({
@@ -203,12 +158,23 @@ function ChatContextMenuItem({
);
}
function attachAnchorRef(
children: React.ReactNode,
anchorRef: React.RefObject<HTMLElement | null>,
): React.ReactNode {
if (!React.isValidElement(children)) {
return <span ref={anchorRef}>{children}</span>;
}
return React.cloneElement(children as React.ReactElement<{ ref?: React.Ref<HTMLElement> }>, {
ref: anchorRef,
});
}
function useFileContextMenuDelete({
tree,
setTree,
node,
isBulk,
needsConfirmation,
onDeleteFile,
selectedPaths,
}: {
@@ -216,14 +182,13 @@ function useFileContextMenuDelete({
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
node: FileTreeNode;
isBulk: boolean;
needsConfirmation: boolean;
onDeleteFile?: (path: string) => Promise<boolean>;
selectedPaths?: Set<string>;
}) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
const handleConfirmDelete = useCallback(() => {
setDeleteDialogOpen(false);
setDeleteConfirmationOpen(false);
if (!onDeleteFile) return;
if (isBulk && selectedPaths) {
const snapshot = tree;
@@ -245,14 +210,44 @@ function useFileContextMenuDelete({
const handleDelete = useCallback(() => {
if (!onDeleteFile) return;
if (needsConfirmation) {
setDeleteDialogOpen(true);
return;
}
deleteNodeOptimistically(tree, setTree, node.path, onDeleteFile);
}, [tree, setTree, node.path, onDeleteFile, needsConfirmation]);
setDeleteConfirmationOpen(true);
}, [onDeleteFile]);
return { deleteDialogOpen, setDeleteDialogOpen, handleConfirmDelete, handleDelete };
return {
deleteConfirmationOpen,
setDeleteConfirmationOpen,
handleConfirmDelete,
handleDelete,
};
}
function useFileMenuDeleteHandler({
handleDelete,
isBulk,
isFinePointer,
contextMenuRef,
}: {
handleDelete: () => void;
isBulk: boolean;
isFinePointer: boolean;
contextMenuRef: React.RefObject<HTMLElement | null>;
}) {
return useCallback(
(event: Event) => {
const item = event.currentTarget;
contextMenuRef.current =
item instanceof HTMLElement
? (item.closest('[data-slot="context-menu-content"]') as HTMLElement | null)
: null;
if (isBulk || !isFinePointer) {
handleDelete();
} else {
// Let the 100 ms context-menu exit animation finish before anchoring the popover.
setTimeout(handleDelete, 150);
}
},
[handleDelete, isBulk, isFinePointer, contextMenuRef],
);
}
type FileContextMenuSurfaceProps = {
@@ -265,14 +260,17 @@ type FileContextMenuSurfaceProps = {
onAddToChatContext?: (node: FileTreeNode) => void;
selectedCount: number;
isBulk: boolean;
needsConfirmation: boolean;
isFinePointer: boolean;
showOpenInEditor: boolean;
hasFileActions: boolean;
showAddToChatContext: boolean;
deleteDialogOpen: boolean;
setDeleteDialogOpen: (open: boolean) => void;
deleteConfirmationOpen: boolean;
setDeleteConfirmationOpen: (open: boolean) => void;
anchorRef: React.RefObject<HTMLElement | null>;
focusBoundaryRef: React.RefObject<HTMLElement | null>;
deleteAction: FileDeleteAction | null;
onConfirmDelete: () => void;
onDelete: () => void;
onDelete: (event: Event) => void;
};
function FileContextMenuSurface({
@@ -285,12 +283,15 @@ function FileContextMenuSurface({
onAddToChatContext,
selectedCount,
isBulk,
needsConfirmation,
isFinePointer,
showOpenInEditor,
hasFileActions,
showAddToChatContext,
deleteDialogOpen,
setDeleteDialogOpen,
deleteConfirmationOpen,
setDeleteConfirmationOpen,
anchorRef,
focusBoundaryRef,
deleteAction,
onConfirmDelete,
onDelete,
}: FileContextMenuSurfaceProps) {
@@ -311,7 +312,7 @@ function FileContextMenuSurface({
);
return (
<>
<FileDeleteActionContext.Provider value={deleteAction}>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
@@ -333,18 +334,29 @@ function FileContextMenuSurface({
/>
</ContextMenuContent>
</ContextMenu>
{needsConfirmation && (
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DeleteConfirmDialog
isBulk={isBulk}
selectedCount={selectedCount}
node={node}
fileCount={countFilesInTree(node)}
onConfirm={onConfirmDelete}
/>
{isBulk && (
<AlertDialog open={deleteConfirmationOpen} onOpenChange={setDeleteConfirmationOpen}>
<DeleteConfirmDialog selectedCount={selectedCount} onConfirm={onConfirmDelete} />
</AlertDialog>
)}
</>
{!isBulk && isFinePointer && deleteAction && (
<ActionConfirmPopover
open={deleteConfirmationOpen}
anchorRef={anchorRef}
focusBoundaryRef={focusBoundaryRef}
title={deleteAction.title}
description={deleteAction.description}
cancelLabel={deleteAction.cancelLabel}
confirmLabel={deleteAction.label}
confirmAriaLabel={deleteAction.title}
confirmTestId="file-delete-confirm"
testId="file-delete-confirm-popover"
onOpenChange={setDeleteConfirmationOpen}
onCancel={() => setDeleteConfirmationOpen(false)}
onConfirm={onConfirmDelete}
/>
)}
</FileDeleteActionContext.Provider>
);
}
@@ -361,6 +373,7 @@ export function FileContextMenu({
onAddToChatContext,
selectedCount = 0,
selectedPaths,
anchorRef: providedAnchorRef,
}: {
children: React.ReactNode;
node: FileTreeNode;
@@ -373,24 +386,55 @@ export function FileContextMenu({
onAddToChatContext?: (node: FileTreeNode) => void;
selectedCount?: number;
selectedPaths?: Set<string>;
anchorRef?: React.RefObject<HTMLElement | null>;
}) {
const editorActions = useFileTreeEditorActions();
const { isFinePointer } = useResponsiveBreakpoint();
const { t } = useTranslation();
const isBulk = selectedCount > 1;
const needsConfirmation = node.is_dir || isBulk;
// A bulk selection would make a single-node "Open in <editor>" ambiguous.
const showOpenInEditor = !isBulk && canOpenNodeInEditor(editorActions, node);
const hasFileActions = !!onDeleteFile || !!onRenameFile || !!onDownloadFile;
const showAddToChatContext = !isBulk && !!onAddToChatContext;
const { deleteDialogOpen, setDeleteDialogOpen, handleConfirmDelete, handleDelete } =
const fallbackAnchorRef = useRef<HTMLElement>(null);
const anchorRef = providedAnchorRef ?? fallbackAnchorRef;
const contextMenuRef = useRef<HTMLElement>(null);
const { deleteConfirmationOpen, setDeleteConfirmationOpen, handleConfirmDelete, handleDelete } =
useFileContextMenuDelete({
tree,
setTree,
node,
isBulk,
needsConfirmation,
onDeleteFile,
selectedPaths,
});
const handleMenuDelete = useFileMenuDeleteHandler({
handleDelete,
isBulk,
isFinePointer,
contextMenuRef,
});
const deleteAction = onDeleteFile
? {
isBulk,
selectedCount,
confirming: deleteConfirmationOpen,
title: isBulk
? t("task:deleteItemsTitle", { count: selectedCount })
: t("task:delete2", { name: node.name }),
label: isBulk ? t("task:deleteItemsLabel", { count: selectedCount }) : t("task:delete"),
cancelLabel: t("common:cancel"),
description: node.is_dir ? (
<DeleteFolderDescription name={node.name} fileCount={countFilesInTree(node)} />
) : (
<DeleteFileDescription name={node.name} />
),
onDelete: handleDelete,
onCancel: () => setDeleteConfirmationOpen(false),
onConfirm: handleConfirmDelete,
}
: null;
if (!hasFileActions && !showOpenInEditor && !showAddToChatContext) return <>{children}</>;
@@ -404,16 +448,19 @@ export function FileContextMenu({
onAddToChatContext={onAddToChatContext}
selectedCount={selectedCount}
isBulk={isBulk}
needsConfirmation={needsConfirmation}
isFinePointer={isFinePointer}
showOpenInEditor={showOpenInEditor}
hasFileActions={hasFileActions}
showAddToChatContext={showAddToChatContext}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
deleteConfirmationOpen={deleteConfirmationOpen}
setDeleteConfirmationOpen={setDeleteConfirmationOpen}
anchorRef={anchorRef}
focusBoundaryRef={contextMenuRef}
deleteAction={deleteAction}
onConfirmDelete={handleConfirmDelete}
onDelete={handleDelete}
onDelete={handleMenuDelete}
>
{children}
{providedAnchorRef ? children : attachAnchorRef(children, anchorRef)}
</FileContextMenuSurface>
);
}
@@ -553,7 +600,7 @@ export function TreeNodeName({
return (
<span
className={cn(
"flex-1 truncate group-hover:text-foreground",
"min-w-0 flex-1 truncate group-hover:text-foreground",
isActive ? "text-foreground" : "text-muted-foreground",
node.is_dir ? "font-medium" : getGitStatusTextClass(gitStatus),
)}
@@ -0,0 +1,69 @@
"use client";
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { Trans, useTranslation } from "react-i18next";
export function DeleteFileDescription({ name }: { name: string }) {
return (
<Trans i18nKey="task:deleteFileConfirm" values={{ name }}>
This will permanently delete <span className="font-semibold">{name}</span>. This action cannot
be undone.
</Trans>
);
}
export function DeleteFolderDescription({ name, fileCount }: { name: string; fileCount: number }) {
if (fileCount > 0) {
return (
<Trans
i18nKey="task:deleteFolderWithFilesConfirm"
count={fileCount}
values={{ name, count: fileCount }}
>
This will permanently delete <span className="font-semibold">{name}</span> and{" "}
<span className="font-semibold">{fileCount}</span> files inside it. This action cannot be
undone.
</Trans>
);
}
return (
<Trans i18nKey="task:deleteFolderConfirm" values={{ name }}>
This will permanently delete <span className="font-semibold">{name}</span>. This action cannot
be undone.
</Trans>
);
}
export function DeleteConfirmDialog({
selectedCount,
onConfirm,
}: {
selectedCount: number;
onConfirm: () => void;
}) {
const { t } = useTranslation();
return (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("task:deleteItemsTitle", { count: selectedCount })}</AlertDialogTitle>
<AlertDialogDescription>
{t("task:thisWillPermanentlyDeleteSelectedItems", { selectedCount })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} variant="destructive" className="cursor-pointer">
{t("task:delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
);
}
@@ -287,11 +287,12 @@ test.describe("File Tree Multi-Select", () => {
// --- Right-Click Context Menu ---
test("right-click single file shows context menu with delete", async ({
test("right-click single file uses local delete confirmation", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
@@ -312,9 +313,19 @@ test.describe("File Tree Multi-Select", () => {
await expect(file).toBeVisible({ timeout: 15_000 });
await file.click({ button: "right" });
await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible({
const deleteItem = testPage.getByRole("menuitem", { name: "Delete" });
await expect(deleteItem).toBeVisible({
timeout: 5_000,
});
await deleteItem.click();
await expect(testPage.getByTestId("file-delete-confirm-popover")).toBeVisible();
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
await prCapture.screenshot("desktop-file-delete-confirmation", {
caption: "Desktop file context menu with one-file delete confirmation",
});
await testPage.getByTestId("file-delete-confirm").click();
await expect(file).not.toBeVisible({ timeout: 10_000 });
});
test("right-click on multi-selection shows bulk delete", async ({
+6 -2
View File
@@ -170,7 +170,9 @@ test.describe("File editor auto-update on file change", () => {
await openChangesTab(testPage);
await openFileDiff(testPage, "diff_update_test.txt");
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
const editFile = testPage.getByRole("button", { name: "Edit", exact: true });
const editFile = testPage
.getByTestId("review-file-actions")
.getByRole("button", { name: "Edit", exact: true });
await expect(editFile).toBeVisible();
await editFile.click();
@@ -226,7 +228,9 @@ test.describe("File editor auto-update on file change", () => {
// Open the editor from the already-loaded diff. A late git-status update is
// allowed to focus Changes, so routing through the Files tab would race that
// intentional focus transition instead of testing editor refresh behavior.
const editFile = testPage.getByRole("button", { name: "Edit", exact: true });
const editFile = testPage
.getByTestId("review-file-actions")
.getByRole("button", { name: "Edit", exact: true });
await expect(editFile).toBeVisible();
await editFile.click();
const editorTab = testPage.locator(".dv-default-tab[type='file-editor']", {
@@ -243,6 +243,60 @@ test.describe("Changes panel focus behavior", () => {
await expect(session.chat).toBeVisible({ timeout: 5_000 });
});
test("discarding one changed file uses local confirmation", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
test.setTimeout(90_000);
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Single file discard confirmation",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
await session.clickTab("Changes");
git.createFile("single-discard.txt", "discard me");
const file = session.changesFileRow("single-discard.txt");
await expect(file).toBeVisible({ timeout: 15_000 });
await file.hover();
const discard = file.getByRole("button", { name: "Discard changes" });
await expect(discard).toBeVisible();
await discard.click();
const confirmation = testPage.getByTestId("discard-local-changes-confirm-popover");
await expect(confirmation).toBeVisible();
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
await expect(file).toBeVisible();
await prCapture.screenshot("desktop-discard-file-confirmation", {
caption: "Desktop Changes panel with one-file discard confirmation",
});
await testPage.keyboard.press("Escape");
await expect(confirmation).not.toBeVisible();
await expect(discard).toBeFocused();
await discard.click();
await expect(confirmation).toBeVisible();
await testPage.getByTestId("discard-local-changes-confirm").click();
await expect(file).not.toBeVisible({ timeout: 15_000 });
});
test("changes panel does not auto-focus when grouped with agent session panels", async ({
testPage,
apiClient,
@@ -0,0 +1,146 @@
import path from "node:path";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { BackendContext } from "../../fixtures/backend";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
async function setupMobileFileTask(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
backend: BackendContext,
) {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const filePath = `mobile-delete-file-${suffix}.md`;
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
const originalSha = git.getCurrentSha();
const restoreRepository = () => git.exec(`git reset --hard ${originalSha}`);
try {
git.createFile(filePath, "mobile delete confirmation\n");
git.stageAll();
git.commit(`add mobile delete fixture ${suffix}`);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile file confirmations",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
return { session, filePath, restoreRepository };
} catch (error) {
restoreRepository();
throw error;
}
}
let restoreRepository: (() => void) | undefined;
test.describe("Mobile file confirmations", () => {
test.afterEach(() => {
restoreRepository?.();
restoreRepository = undefined;
});
test("confirms one file deletion inline without horizontal overflow", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
test.setTimeout(90_000);
const fixture = await setupMobileFileTask(testPage, apiClient, seedData, backend);
const { session, filePath } = fixture;
restoreRepository = fixture.restoreRepository;
expect(await testPage.evaluate(() => matchMedia("(any-pointer: coarse)").matches)).toBe(true);
await testPage.getByRole("button", { name: "Files" }).tap();
const file = session.fileTreeNode(filePath);
await expect(file).toBeVisible({ timeout: 15_000 });
const trigger = session.fileTreeNodeActions(filePath);
await expect(trigger).toBeVisible();
const triggerBox = await trigger.boundingBox();
expect(triggerBox).not.toBeNull();
expect(triggerBox!.width).toBeGreaterThanOrEqual(44);
expect(triggerBox!.height).toBeGreaterThanOrEqual(44);
await trigger.tap();
const menu = session.fileTreeTouchMenu();
await expect(menu).toBeVisible();
const deleteItem = testPage.getByTestId("file-tree-touch-delete");
await expect(deleteItem).toBeVisible();
await expect
.poll(async () => (await deleteItem.boundingBox())?.height ?? 0, { timeout: 2_000 })
.toBeGreaterThanOrEqual(44);
const viewport = await testPage.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
const menuBox = await menu.boundingBox();
expect(menuBox).not.toBeNull();
expect(menuBox!.x).toBeGreaterThanOrEqual(0);
expect(menuBox!.x + menuBox!.width).toBeLessThanOrEqual(viewport.width);
expect(menuBox!.y).toBeGreaterThanOrEqual(0);
expect(menuBox!.y + menuBox!.height).toBeLessThanOrEqual(viewport.height);
await deleteItem.tap();
const inline = testPage.getByTestId("file-delete-inline-confirmation");
await expect(inline).toBeVisible();
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
const confirm = inline.getByTestId("file-delete-confirm");
const cancel = inline.getByRole("button", { name: "Cancel" });
await expect(confirm).toBeVisible();
await expect(cancel).toBeVisible();
const inlineBox = await inline.boundingBox();
expect(inlineBox).not.toBeNull();
expect(
inlineBox!.x,
`inline box: ${JSON.stringify(inlineBox)}, viewport: ${viewport.width}`,
).toBeGreaterThanOrEqual(0);
expect(
inlineBox!.x + inlineBox!.width,
`inline box: ${JSON.stringify(inlineBox)}, viewport: ${viewport.width}`,
).toBeLessThanOrEqual(viewport.width);
for (const button of [cancel, confirm]) {
const buttonBox = await button.boundingBox();
expect(buttonBox).not.toBeNull();
expect(buttonBox!.x).toBeGreaterThanOrEqual(0);
expect(buttonBox!.x + buttonBox!.width).toBeLessThanOrEqual(viewport.width);
}
await expect
.poll(async () => (await confirm.boundingBox())?.height ?? 0, { timeout: 2_000 })
.toBeGreaterThanOrEqual(44);
await expect
.poll(async () => (await cancel.boundingBox())?.height ?? 0, { timeout: 2_000 })
.toBeGreaterThanOrEqual(44);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await prCapture.screenshot("mobile-file-delete-confirmation", {
caption: "Pixel 5 file actions keep one-file deletion confirmation inline and touch-safe",
});
await confirm.tap();
await expect(file).not.toBeVisible({ timeout: 15_000 });
});
});
+1
View File
@@ -317,6 +317,7 @@
"delete2": "Delete {{name}}",
"deleteAgent": "Delete agent",
"deleteComment": "Delete comment",
"deleteFileConfirm": "This will permanently delete <1>{{name}}</1>. This action cannot be undone.",
"deleteFolderConfirm": "This will permanently delete <1>{{name}}</1>. This action cannot be undone.",
"deleteFolderTitle": "Delete folder?",
"deleteFolderWithFilesConfirm_one": "This will permanently delete <1>{{name}}</1> and <4>{{count}}</4> file inside it. This action cannot be undone.",
+1
View File
@@ -317,6 +317,7 @@
"delete2": "Ďēĺēţē {{name}}",
"deleteAgent": "Ďēĺēţē àĝēńţ",
"deleteComment": "Ďēĺēţē ćōḿḿēńţ",
"deleteFileConfirm": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē <1>{{name}}</1>. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"deleteFolderConfirm": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē <1>{{name}}</1>. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"deleteFolderTitle": "Ďēĺēţē ƒōĺďēŕ?",
"deleteFolderWithFilesConfirm_one": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē <1>{{name}}</1> àńď <4>{{count}}</4> ƒĩĺē ĩńśĩďē ĩţ. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
+1
View File
@@ -316,6 +316,7 @@
"delete2": "Eliminar {{name}}",
"deleteAgent": "Eliminar agente",
"deleteComment": "Eliminar comentário",
"deleteFileConfirm": "Isto elimina permanentemente <1>{{name}}</1>. Esta ação não pode ser anulada.",
"deleteFolderConfirm": "Isto elimina permanentemente <1>{{name}}</1>. Esta ação não pode ser anulada.",
"deleteFolderTitle": "Eliminar a pasta?",
"deleteFolderWithFilesConfirm_one": "Isto elimina permanentemente <1>{{name}}</1> e <4>{{count}}</4> ficheiro que contém. Esta ação não pode ser anulada.",
+1
View File
@@ -316,6 +316,7 @@
"delete2": "删除 {{name}}",
"deleteAgent": "删除智能体",
"deleteComment": "删除评论",
"deleteFileConfirm": "将永久删除 <1>{{name}}</1>。此操作无法撤销。",
"deleteFolderConfirm": "将永久删除 <1>{{name}}</1>。此操作无法撤销。",
"deleteFolderTitle": "删除文件夹?",
"deleteFolderWithFilesConfirm_one": "将永久删除 <1>{{name}}</1> 及其内的 <4>{{count}}</4> 个文件。此操作无法撤销。",
+1
View File
@@ -316,6 +316,7 @@
"delete2": "刪除 {{name}}",
"deleteAgent": "刪除代理程式",
"deleteComment": "刪除評論",
"deleteFileConfirm": "將永久刪除 <1>{{name}}</1>。此操作無法撤銷。",
"deleteFolderConfirm": "將永久刪除 <1>{{name}}</1>。此操作無法撤銷。",
"deleteFolderTitle": "刪除資料夾?",
"deleteFolderWithFilesConfirm_one": "將永久刪除 <1>{{name}}</1> 及其內的 <4>{{count}}</4> 個檔案。此操作無法撤銷。",
+1
View File
@@ -316,6 +316,7 @@
"delete2": "刪除 {{name}}",
"deleteAgent": "刪除代理程式",
"deleteComment": "刪除評論",
"deleteFileConfirm": "將永久刪除 <1>{{name}}</1>。此操作無法撤銷。",
"deleteFolderConfirm": "將永久刪除 <1>{{name}}</1>。此操作無法撤銷。",
"deleteFolderTitle": "刪除資料夾?",
"deleteFolderWithFilesConfirm_one": "將永久刪除 <1>{{name}}</1> 及其內的 <4>{{count}}</4> 個檔案。此操作無法撤銷。",