fix(web): localize single-file confirmations (#2894)
This commit is contained in:
@@ -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: "",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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> ƒĩĺē ĩńśĩďē ĩţ. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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> 个文件。此操作无法撤销。",
|
||||
|
||||
@@ -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> 個檔案。此操作無法撤銷。",
|
||||
|
||||
@@ -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> 個檔案。此操作無法撤銷。",
|
||||
|
||||
Reference in New Issue
Block a user