fix: make mobile changes rows compact (#3881)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -1,18 +1,22 @@
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { cleanup, 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 }));
|
||||
const responsive = vi.hoisted(() => ({ isFinePointer: true, isMobile: false }));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => responsive,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
responsive.isFinePointer = true;
|
||||
responsive.isMobile = false;
|
||||
});
|
||||
|
||||
const moreActionsLabel = "Show more actions";
|
||||
|
||||
const noop = () => {};
|
||||
const noopSelect = () => false;
|
||||
|
||||
@@ -233,21 +237,24 @@ describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
|
||||
expect(gridWrapper.className).toContain("row-start-1");
|
||||
});
|
||||
|
||||
it("hides statistics behind always-visible actions for coarse pointers", () => {
|
||||
it("keeps statistics in the identity and exposes one menu 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");
|
||||
const { getByTitle, getByRole, queryByTitle } = renderRow("file.go");
|
||||
expect(getByTitle("file.go").textContent).toContain("+18");
|
||||
expect(getByRole("button", { name: moreActionsLabel })).not.toBeNull();
|
||||
expect(queryByTitle("Stage file")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the pending spinner inside a touch-sized target for coarse pointers", () => {
|
||||
it("uses the touch menu on a phone even with a fine pointer", () => {
|
||||
responsive.isMobile = true;
|
||||
const { getByRole, queryByTitle } = renderRow("file.go");
|
||||
expect(getByRole("button", { name: moreActionsLabel })).not.toBeNull();
|
||||
expect(queryByTitle("Stage file")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps pending feedback in the identity and disables staging in the menu", async () => {
|
||||
responsive.isFinePointer = false;
|
||||
const { container } = render(
|
||||
const { container, getByTitle, getByRole, findByRole } = render(
|
||||
<TooltipProvider>
|
||||
<ul>
|
||||
<FileRow
|
||||
@@ -266,8 +273,11 @@ describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
|
||||
|
||||
const spinner = container.querySelector("svg.animate-spin");
|
||||
expect(spinner).not.toBeNull();
|
||||
expect(spinner?.parentElement?.className).toContain("min-h-11");
|
||||
expect(spinner?.parentElement?.className).toContain("min-w-11");
|
||||
expect(spinner?.closest("button")).toBe(getByTitle("pending-mobile.go"));
|
||||
fireEvent.keyDown(getByRole("button", { name: moreActionsLabel }), { key: "Enter" });
|
||||
expect(
|
||||
(await findByRole("menuitem", { name: "Stage file" })).getAttribute("aria-disabled"),
|
||||
).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,10 +348,12 @@ describe("FileRow tree-mode hover stage action", () => {
|
||||
expect(rightActions!.querySelector("button[title='Unstage file']")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the tree-mode stage action visible and touch-sized for coarse pointers", () => {
|
||||
it("unstages a touch tree row through its menu without opening a diff", async () => {
|
||||
responsive.isFinePointer = false;
|
||||
|
||||
const { container } = render(
|
||||
const onUnstage = vi.fn();
|
||||
const onOpenDiff = vi.fn();
|
||||
const { getByRole, findByRole } = render(
|
||||
<TooltipProvider>
|
||||
<ul>
|
||||
<FileRow
|
||||
@@ -349,9 +361,9 @@ describe("FileRow tree-mode hover stage action", () => {
|
||||
isPending={false}
|
||||
treeMode
|
||||
onSelect={noopSelect}
|
||||
onOpenDiff={noop}
|
||||
onOpenDiff={onOpenDiff}
|
||||
onStage={noop}
|
||||
onUnstage={noop}
|
||||
onUnstage={onUnstage}
|
||||
onDiscard={noop}
|
||||
onEditFile={noop}
|
||||
/>
|
||||
@@ -359,13 +371,10 @@ describe("FileRow tree-mode hover stage action", () => {
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const iconSlot = container.querySelector("[data-testid='file-row-icon-action-slot']");
|
||||
const action = iconSlot?.querySelector("button[title='Unstage file']");
|
||||
|
||||
expect(iconSlot?.className).toContain("size-11");
|
||||
expect(action?.className).toContain("min-h-11");
|
||||
expect(action?.className).toContain("min-w-11");
|
||||
expect(action?.parentElement?.className).not.toContain("opacity-0");
|
||||
fireEvent.keyDown(getByRole("button", { name: moreActionsLabel }), { key: "Enter" });
|
||||
fireEvent.click(await findByRole("menuitem", { name: "Unstage file" }));
|
||||
expect(onUnstage).toHaveBeenCalledWith("src/mobile.go", undefined);
|
||||
expect(onOpenDiff).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ 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 COARSE_POINTER_TARGET_CLASS = "min-h-11 min-w-11";
|
||||
import { TouchFileRowContent } from "./changes-panel-touch-file-row";
|
||||
|
||||
const splitPath = (path: string) => {
|
||||
const lastSlash = path.lastIndexOf("/");
|
||||
@@ -34,7 +33,7 @@ const splitPath = (path: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
type FileRowProps = {
|
||||
export type FileRowProps = {
|
||||
file: ChangedFile;
|
||||
isPending: boolean;
|
||||
isSelected?: boolean;
|
||||
@@ -60,23 +59,13 @@ type FileRowProps = {
|
||||
indentPx?: number;
|
||||
};
|
||||
|
||||
export function FileRow({
|
||||
file,
|
||||
isPending,
|
||||
isSelected,
|
||||
isActive,
|
||||
onSelect,
|
||||
onOpenDiff,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscard,
|
||||
onEditFile,
|
||||
treeMode,
|
||||
indentPx,
|
||||
}: FileRowProps) {
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
export type FileRowContentProps = FileRowProps & { folder: string; name: string };
|
||||
|
||||
export function FileRow(props: FileRowProps) {
|
||||
const { file, isSelected, isActive, onSelect, onEditFile, onOpenDiff } = props;
|
||||
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
|
||||
const touchMode = isMobile || !isFinePointer;
|
||||
const { folder, file: name } = splitPath(file.path);
|
||||
const showFolder = !treeMode && folder;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (e.button === 2) return;
|
||||
@@ -101,14 +90,38 @@ export function FileRow({
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
data-active={isActive ? "true" : "false"}
|
||||
className={cn(
|
||||
"group flex items-center justify-between gap-2 rounded-md border border-transparent px-2 py-1.5 -mx-1 text-sm cursor-pointer",
|
||||
"md:px-1 md:py-0.5",
|
||||
"group flex items-center justify-between rounded-md border border-transparent -mx-1 text-sm cursor-pointer",
|
||||
touchMode ? "gap-1 px-1 py-0.5" : "gap-2 px-2 py-1.5 md:px-1 md:py-0.5",
|
||||
isSelected || isActive
|
||||
? "border-primary/50 bg-card text-foreground hover:bg-muted/70"
|
||||
: "hover:bg-muted/60",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{touchMode ? (
|
||||
<TouchFileRowContent {...props} folder={folder} name={name} />
|
||||
) : (
|
||||
<DesktopFileRowContent {...props} folder={folder} name={name} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopFileRowContent({
|
||||
file,
|
||||
isPending,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscard,
|
||||
onEditFile,
|
||||
treeMode,
|
||||
indentPx,
|
||||
folder,
|
||||
name,
|
||||
}: FileRowContentProps) {
|
||||
const showFolder = !treeMode && folder;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0"
|
||||
style={indentPx ? { paddingLeft: indentPx } : undefined}
|
||||
@@ -117,7 +130,6 @@ export function FileRow({
|
||||
<TreeModeFileActionSlot
|
||||
name={name}
|
||||
isPending={isPending}
|
||||
isFinePointer={isFinePointer}
|
||||
staged={file.staged}
|
||||
path={file.path}
|
||||
repo={file.repositoryName}
|
||||
@@ -128,7 +140,6 @@ export function FileRow({
|
||||
<StageButton
|
||||
isPending={isPending}
|
||||
staged={file.staged}
|
||||
touchSized={!isFinePointer}
|
||||
path={file.path}
|
||||
repo={file.repositoryName}
|
||||
onStage={onStage}
|
||||
@@ -148,27 +159,21 @@ export function FileRow({
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid items-center shrink-0 [&>*]:col-start-1 [&>*]:row-start-1">
|
||||
<FileRowStats file={file} isFinePointer={isFinePointer} />
|
||||
<FileRowStats file={file} />
|
||||
<FileRowActions
|
||||
path={file.path}
|
||||
repo={file.repositoryName}
|
||||
isFinePointer={isFinePointer}
|
||||
onDiscard={onDiscard}
|
||||
onEditFile={onEditFile}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRowStats({ file, isFinePointer }: { file: ChangedFile; isFinePointer: boolean }) {
|
||||
function FileRowStats({ file }: { file: ChangedFile }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 justify-end transition-opacity pointer-events-none",
|
||||
isFinePointer ? "group-hover:opacity-0" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 justify-end transition-opacity pointer-events-none group-hover:opacity-0">
|
||||
<LineStat added={file.plus} removed={file.minus} />
|
||||
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
|
||||
</div>
|
||||
@@ -178,7 +183,6 @@ function FileRowStats({ file, isFinePointer }: { file: ChangedFile; isFinePointe
|
||||
function TreeModeFileActionSlot({
|
||||
name,
|
||||
isPending,
|
||||
isFinePointer,
|
||||
staged,
|
||||
path,
|
||||
repo,
|
||||
@@ -187,7 +191,6 @@ function TreeModeFileActionSlot({
|
||||
}: {
|
||||
name: string;
|
||||
isPending: boolean;
|
||||
isFinePointer: boolean;
|
||||
staged: boolean;
|
||||
path: string;
|
||||
repo?: string;
|
||||
@@ -197,31 +200,24 @@ function TreeModeFileActionSlot({
|
||||
return (
|
||||
<div
|
||||
data-testid="file-row-icon-action-slot"
|
||||
className={cn(
|
||||
"grid shrink-0 items-center justify-center [&>*]:col-start-1 [&>*]:row-start-1",
|
||||
isFinePointer ? "size-4" : "size-11",
|
||||
)}
|
||||
className="grid size-4 shrink-0 items-center justify-center [&>*]:col-start-1 [&>*]:row-start-1"
|
||||
>
|
||||
{isFinePointer && (
|
||||
<FileIcon
|
||||
fileName={name}
|
||||
className={cn(
|
||||
"size-4 transition-opacity pointer-events-none",
|
||||
isPending ? "opacity-0" : "group-hover:opacity-0",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FileIcon
|
||||
fileName={name}
|
||||
className={cn(
|
||||
"size-4 transition-opacity pointer-events-none",
|
||||
isPending ? "opacity-0" : "group-hover:opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
isFinePointer &&
|
||||
!isPending &&
|
||||
!isPending &&
|
||||
"opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||
)}
|
||||
>
|
||||
<StageButton
|
||||
isPending={isPending}
|
||||
staged={staged}
|
||||
touchSized={!isFinePointer}
|
||||
path={path}
|
||||
repo={repo}
|
||||
onStage={onStage}
|
||||
@@ -235,7 +231,6 @@ function TreeModeFileActionSlot({
|
||||
function StageButton({
|
||||
isPending,
|
||||
staged,
|
||||
touchSized,
|
||||
path,
|
||||
repo,
|
||||
onStage,
|
||||
@@ -243,7 +238,6 @@ function StageButton({
|
||||
}: {
|
||||
isPending: boolean;
|
||||
staged: boolean;
|
||||
touchSized?: boolean;
|
||||
path: string;
|
||||
repo?: string;
|
||||
onStage: (path: string, repo?: string) => void;
|
||||
@@ -252,12 +246,7 @@ function StageButton({
|
||||
const { t } = useTranslation();
|
||||
if (isPending) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 flex items-center justify-center size-4",
|
||||
touchSized && COARSE_POINTER_TARGET_CLASS,
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0 flex items-center justify-center size-4">
|
||||
<IconLoader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
@@ -267,10 +256,7 @@ function StageButton({
|
||||
<button
|
||||
type="button"
|
||||
title={t("task:unstageFile")}
|
||||
className={cn(
|
||||
"group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer",
|
||||
touchSized && COARSE_POINTER_TARGET_CLASS,
|
||||
)}
|
||||
className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnstage(path, repo);
|
||||
@@ -285,10 +271,7 @@ function StageButton({
|
||||
<button
|
||||
type="button"
|
||||
title={t("task:stageFile")}
|
||||
className={cn(
|
||||
"flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer",
|
||||
touchSized && COARSE_POINTER_TARGET_CLASS,
|
||||
)}
|
||||
className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStage(path, repo);
|
||||
@@ -302,13 +285,11 @@ function StageButton({
|
||||
function FileRowActions({
|
||||
path,
|
||||
repo,
|
||||
isFinePointer,
|
||||
onDiscard,
|
||||
onEditFile,
|
||||
}: {
|
||||
path: string;
|
||||
repo?: string;
|
||||
isFinePointer: boolean;
|
||||
onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void;
|
||||
onEditFile: (path: string, repo?: string) => void;
|
||||
}) {
|
||||
@@ -316,22 +297,14 @@ function FileRowActions({
|
||||
return (
|
||||
<div
|
||||
data-testid="file-row-hover-actions"
|
||||
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",
|
||||
)}
|
||||
className="flex items-center gap-1 justify-end transition-opacity opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("task:discardChanges2")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground cursor-pointer",
|
||||
!isFinePointer && COARSE_POINTER_TARGET_CLASS,
|
||||
)}
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDiscard(path, repo, e.currentTarget);
|
||||
@@ -347,10 +320,7 @@ function FileRowActions({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common:edit")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground cursor-pointer",
|
||||
!isFinePointer && COARSE_POINTER_TARGET_CLASS,
|
||||
)}
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditFile(path, repo);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconArrowBackUp,
|
||||
IconDots,
|
||||
IconLoader2,
|
||||
IconMinus,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { FileIcon } from "@/components/ui/file-icon";
|
||||
import { FileStatusIcon } from "@/components/shared/file-status-icon";
|
||||
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
|
||||
import { LineStat } from "@/components/diff-stat";
|
||||
import type { FileRowProps, FileRowContentProps } from "./changes-panel-file-row";
|
||||
|
||||
export function TouchFileRowContent(props: FileRowContentProps) {
|
||||
const { file, treeMode, indentPx, isPending, folder, name } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={file.path}
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center gap-2 text-left cursor-pointer"
|
||||
style={indentPx ? { paddingLeft: Math.min(indentPx, 24) } : undefined}
|
||||
>
|
||||
{isPending ? (
|
||||
<IconLoader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon fileName={name} className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-normal [overflow-wrap:anywhere] text-xs font-medium leading-4">
|
||||
{name}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-[11px] leading-4 text-muted-foreground">
|
||||
{!treeMode && folder && <span className="min-w-0 truncate">{folder}</span>}
|
||||
<LineStat
|
||||
added={file.plus}
|
||||
removed={file.minus}
|
||||
className="shrink-0 text-[10px] gap-1"
|
||||
/>
|
||||
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
|
||||
<SymlinkIndicator isSymlink={file.isSymlink} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<TouchFileRowActions {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TouchFileRowActions({
|
||||
file,
|
||||
isPending,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onEditFile,
|
||||
onDiscard,
|
||||
}: FileRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const StageIcon = file.staged ? IconMinus : IconPlus;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={t("common:showMoreActions")}
|
||||
className="flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconDots className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-72 max-w-[calc(100vw-1rem)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuLabel className="whitespace-normal [overflow-wrap:anywhere] text-xs font-normal text-muted-foreground">
|
||||
{file.path}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="min-h-11 cursor-pointer"
|
||||
disabled={isPending}
|
||||
onSelect={() => (file.staged ? onUnstage : onStage)(file.path, file.repositoryName)}
|
||||
>
|
||||
{isPending ? <IconLoader2 className="animate-spin" /> : <StageIcon />}
|
||||
{t(file.staged ? "task:unstageFile" : "task:stageFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-h-11 cursor-pointer"
|
||||
onSelect={() => onEditFile(file.path, file.repositoryName)}
|
||||
>
|
||||
<IconPencil />
|
||||
{t("common:edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="min-h-11 cursor-pointer"
|
||||
onSelect={() =>
|
||||
onDiscard(file.path, file.repositoryName, triggerRef.current ?? undefined)
|
||||
}
|
||||
>
|
||||
<IconArrowBackUp />
|
||||
{t("task:discardChanges2")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -465,6 +465,68 @@ test.describe("Git Changes Panel", () => {
|
||||
* Creates a task, modifies a file in the repository, and verifies the Changes panel
|
||||
* shows the modification in real-time via WebSocket updates.
|
||||
*/
|
||||
// @covers AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.4
|
||||
test("keeps compact desktop actions across the phone breakpoint", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
prCapture,
|
||||
}) => {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Improve mobile changes",
|
||||
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();
|
||||
const git = new GitHelper(path.join(backend.tmpDir, "repos", "e2e-repo"), {
|
||||
...process.env,
|
||||
HOME: backend.tmpDir,
|
||||
});
|
||||
const filePath = "status-surface-metrics.test.ts";
|
||||
git.createFile(filePath, "METRICS_MARKER\n");
|
||||
git.createFile("mobile-resource-metrics-display.test.ts", "MOBILE_MARKER\n");
|
||||
await session.clickTab("Changes");
|
||||
await session.expandChangesSection("unstaged-files-section");
|
||||
const row = testPage.getByTestId(`file-row-${filePath}`);
|
||||
const stage = row.getByTitle("Stage file");
|
||||
await expect(stage).toBeVisible();
|
||||
expect((await stage.boundingBox())!.height).toBeLessThanOrEqual(24);
|
||||
await expect(row.getByRole("button", { name: "Show more actions" })).toHaveCount(0);
|
||||
await prCapture.screenshot("desktop-changes", {
|
||||
caption: "Desktop retains compact inline staging and hover actions.",
|
||||
});
|
||||
|
||||
await testPage.setViewportSize({ width: 767, height: 851 });
|
||||
await testPage
|
||||
.getByRole("navigation")
|
||||
.getByRole("button", { name: /Changes$/ })
|
||||
.click();
|
||||
await expect(row.getByRole("button", { name: "Show more actions" })).toBeVisible();
|
||||
await expect(row.getByText(filePath, { exact: true })).toHaveCSS("white-space", "normal");
|
||||
await expect(stage).toHaveCount(0);
|
||||
|
||||
await testPage.setViewportSize({ width: 768, height: 851 });
|
||||
await session.clickTab("Changes");
|
||||
await expect(stage).toBeVisible();
|
||||
expect((await stage.boundingBox())!.height).toBeLessThanOrEqual(24);
|
||||
await expect(row.getByRole("button", { name: "Show more actions" })).toHaveCount(0);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("shows modified files in unstaged section", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
|
||||
// @covers AC-WORKSPACES-SYMLINK-001.1, AC-WORKSPACES-SYMLINK-001.2, AC-WORKSPACES-SYMLINK-001.4
|
||||
test("identifies a symlink in Changes and the mobile file viewer", async ({
|
||||
@@ -33,13 +34,19 @@ test("identifies a symlink in Changes and the mobile file viewer", async ({
|
||||
const row = testPage.getByTestId(`file-row-${name}`);
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
await expect(row.getByTestId("symlink-indicator")).toBeVisible();
|
||||
const edit = row.getByRole("button", { name: "Edit", exact: true });
|
||||
expect((await edit.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
const actions = row.getByRole("button", { name: "Show more actions", exact: true });
|
||||
const actionsBounds = (await actions.boundingBox())!;
|
||||
expect(actionsBounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(actionsBounds.width).toBeGreaterThanOrEqual(44);
|
||||
const rowBounds = (await row.boundingBox())!;
|
||||
const markerBounds = (await row.getByTestId("symlink-indicator").boundingBox())!;
|
||||
const editBounds = (await edit.boundingBox())!;
|
||||
expect(markerBounds.x).toBeGreaterThanOrEqual(rowBounds.x);
|
||||
expect(markerBounds.x + markerBounds.width).toBeLessThanOrEqual(editBounds.x);
|
||||
expect(markerBounds.x + markerBounds.width).toBeLessThanOrEqual(actionsBounds.x);
|
||||
await actions.tap();
|
||||
const edit = testPage.getByRole("menuitem", { name: "Edit", exact: true });
|
||||
await expect(edit).toBeVisible();
|
||||
await waitForFiniteAnimations(testPage.getByRole("menu"));
|
||||
expect((await edit.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await edit.tap();
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -86,6 +87,218 @@ test.describe("Mobile changes panel", () => {
|
||||
git.exec("git clean -fd");
|
||||
});
|
||||
|
||||
// @covers AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.1 through 002.3
|
||||
test("compact file rows preserve names and expose touch actions", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
prCapture,
|
||||
}) => {
|
||||
const initialLayout = (await apiClient.getUserSettings()).settings.changes_panel_layout;
|
||||
try {
|
||||
await apiClient.saveUserSettings({ changes_panel_layout: "tree" });
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Improve mobile changes",
|
||||
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();
|
||||
|
||||
const git = new GitHelper(
|
||||
path.join(backend.tmpDir, "repos", "e2e-repo"),
|
||||
makeGitEnv(backend.tmpDir),
|
||||
);
|
||||
const filePath = "apps/web/components/task/status-surface-metrics.test.ts";
|
||||
const longPath =
|
||||
"apps/web/components/task/mobile-resource-metrics-display-with-a-long-filename.test.ts";
|
||||
git.createFile(filePath, "READABLE_FILE_MARKER\n");
|
||||
git.createFile(longPath, "LONG_FILE_MARKER\n");
|
||||
git.createFile("docs/public/operations.md", "Operations\n");
|
||||
git.createFile("docs/plans/mobile-status/plan.md", "Mobile status plan\n");
|
||||
await openMobileChangesPanel(testPage);
|
||||
await expandSection(testPage, "unstaged-files-section");
|
||||
const section = testPage.getByTestId("unstaged-files-section");
|
||||
const rowId = `file-row-${filePath.replaceAll("/", "-")}`;
|
||||
const row = section.getByTestId(rowId);
|
||||
await expect(row).toBeVisible();
|
||||
await prCapture.screenshot("compact-changes-tree", {
|
||||
caption: "Phone Changes list gives filenames the primary width and groups file actions.",
|
||||
});
|
||||
|
||||
const identity = row.getByTitle(filePath, { exact: true });
|
||||
const rowBox = (await row.boundingBox())!;
|
||||
const identityBox = (await identity.boundingBox())!;
|
||||
expect(identityBox.width / rowBox.width).toBeGreaterThan(0.65);
|
||||
expect(rowBox.height).toBeLessThanOrEqual(56);
|
||||
expect(identityBox.height).toBeGreaterThanOrEqual(44);
|
||||
await expect(identity.getByText("status-surface-metrics.test.ts", { exact: true })).toHaveCSS(
|
||||
"white-space",
|
||||
"normal",
|
||||
);
|
||||
const longName = section.getByText(
|
||||
"mobile-resource-metrics-display-with-a-long-filename.test.ts",
|
||||
{ exact: true },
|
||||
);
|
||||
expect(await longName.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
const more = row.getByRole("button", { name: "Show more actions" });
|
||||
const moreBox = (await more.boundingBox())!;
|
||||
expect(moreBox.width).toBeGreaterThanOrEqual(44);
|
||||
expect(moreBox.height).toBeGreaterThanOrEqual(44);
|
||||
expect(moreBox.x + moreBox.width).toBeLessThanOrEqual(rowBox.x + rowBox.width);
|
||||
expect(
|
||||
await more.evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return el.contains(
|
||||
document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2),
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await apiClient.saveUserSettings({ changes_panel_layout: "flat" });
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await openMobileChangesPanel(testPage);
|
||||
await expandSection(testPage, "unstaged-files-section");
|
||||
await expect(row).toBeVisible();
|
||||
const folder = identity.getByText("apps/web/components/task", { exact: true });
|
||||
await expect(folder).toBeVisible();
|
||||
const basenameBox = (await identity
|
||||
.getByText("status-surface-metrics.test.ts", { exact: true })
|
||||
.boundingBox())!;
|
||||
expect((await folder.boundingBox())!.y).toBeGreaterThanOrEqual(
|
||||
basenameBox.y + basenameBox.height,
|
||||
);
|
||||
expect((await row.boundingBox())!.height).toBeLessThanOrEqual(56);
|
||||
await prCapture.screenshot("compact-changes-list", {
|
||||
caption: "Flat phone list with full basenames, directory context, and compact actions.",
|
||||
});
|
||||
for (const width of [767, 393]) {
|
||||
await testPage.setViewportSize({ width, height: 851 });
|
||||
if (width === 393) await openMobileChangesPanel(testPage);
|
||||
await expect(more).toBeVisible();
|
||||
expect((await more.boundingBox())!.width).toBeGreaterThanOrEqual(44);
|
||||
await expect(
|
||||
identity.getByText("status-surface-metrics.test.ts", { exact: true }),
|
||||
).toHaveCSS("white-space", "normal");
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
await more.tap();
|
||||
const menu = testPage.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByText(filePath, { exact: true })).toBeVisible();
|
||||
await waitForFiniteAnimations(menu);
|
||||
const menuBox = (await menu.boundingBox())!;
|
||||
expect(menuBox.x).toBeGreaterThanOrEqual(0);
|
||||
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(testPage.viewportSize()!.width);
|
||||
await prCapture.screenshot("compact-file-actions", {
|
||||
caption: "Full file path and touch actions in the phone menu.",
|
||||
});
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(more).toBeFocused();
|
||||
await more.tap();
|
||||
await menu.getByRole("menuitem", { name: "Stage file", exact: true }).tap();
|
||||
await expandSection(testPage, "staged-files-section");
|
||||
const stagedRow = testPage.getByTestId("staged-files-section").getByTestId(rowId);
|
||||
await expect(stagedRow).toBeVisible();
|
||||
await expect(testPage.getByTestId("mobile-diff-sheet-close")).not.toBeVisible();
|
||||
await stagedRow.getByRole("button", { name: "Show more actions" }).tap();
|
||||
await menu.getByRole("menuitem", { name: "Unstage file", exact: true }).tap();
|
||||
await expect(row).toBeVisible();
|
||||
await more.tap();
|
||||
await menu.getByRole("menuitem", { name: "Discard changes", exact: true }).tap();
|
||||
await expect(testPage.getByTestId("discard-local-changes-confirm")).toBeVisible();
|
||||
await testPage.getByRole("button", { name: "Cancel", exact: true }).tap();
|
||||
await expect(row).toBeVisible();
|
||||
expect(git.exec("git status --porcelain --untracked-files=all")).toContain(filePath);
|
||||
|
||||
await identity.tap();
|
||||
await expectDiffText(testPage, "READABLE_FILE_MARKER");
|
||||
await testPage.getByTestId("mobile-diff-sheet-close").tap();
|
||||
await more.tap();
|
||||
await menu.getByRole("menuitem", { name: "Edit", exact: true }).tap();
|
||||
await expect(testPage.getByTestId("mobile-changes-panel")).not.toBeVisible();
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible();
|
||||
await expect(viewer.getByText(filePath, { exact: true })).toBeVisible();
|
||||
expect((await apiClient.getUserSettings()).settings.changes_panel_layout).toBe("flat");
|
||||
} finally {
|
||||
await apiClient.saveUserSettings({ changes_panel_layout: initialLayout });
|
||||
}
|
||||
});
|
||||
|
||||
// @covers AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.1
|
||||
test("deep tree rows keep filenames and actions within the panel", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const initialLayout = (await apiClient.getUserSettings()).settings.changes_panel_layout;
|
||||
try {
|
||||
await apiClient.saveUserSettings({ changes_panel_layout: "tree" });
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Deep changes tree",
|
||||
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();
|
||||
const git = new GitHelper(
|
||||
path.join(backend.tmpDir, "repos", "e2e-repo"),
|
||||
makeGitEnv(backend.tmpDir),
|
||||
);
|
||||
let folder = "src";
|
||||
for (let depth = 0; depth < 22; depth++) {
|
||||
git.createFile(`${folder}/sibling.ts`, "sibling\n");
|
||||
folder += `/level-${depth}`;
|
||||
}
|
||||
const name = "deeply-nested-file.test.ts";
|
||||
const filePath = `${folder}/${name}`;
|
||||
git.createFile(filePath, "DEEP_FILE_MARKER\n");
|
||||
await openMobileChangesPanel(testPage);
|
||||
await expandSection(testPage, "unstaged-files-section");
|
||||
const row = testPage.getByTestId(`file-row-${filePath.replaceAll("/", "-")}`);
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
const rowBox = (await row.boundingBox())!;
|
||||
const filenameBox = (await row.getByText(name, { exact: true }).boundingBox())!;
|
||||
expect(filenameBox.width).toBeGreaterThan(rowBox.width * 0.5);
|
||||
const statusBox = (await row.locator("[data-file-status]").boundingBox())!;
|
||||
const menuButton = row.getByRole("button", { name: "Show more actions" });
|
||||
const menuBox = (await menuButton.boundingBox())!;
|
||||
expect(statusBox.x + statusBox.width).toBeLessThanOrEqual(menuBox.x);
|
||||
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(rowBox.x + rowBox.width);
|
||||
await menuButton.tap();
|
||||
await expect(testPage.getByRole("menu").getByText(filePath, { exact: true })).toBeVisible();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await row.getByTitle(filePath, { exact: true }).tap();
|
||||
await expectDiffText(testPage, "DEEP_FILE_MARKER");
|
||||
} finally {
|
||||
await apiClient.saveUserSettings({ changes_panel_layout: initialLayout });
|
||||
}
|
||||
});
|
||||
|
||||
test("renders timeline surface and opens Diff/Review/file/commit overlays", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -272,7 +485,8 @@ test.describe("Mobile changes panel", () => {
|
||||
await expectDiffTextAbsent(testPage, "MOBILE_UNSTAGED_LAYER_MARKER");
|
||||
await testPage.getByTestId("mobile-diff-sheet-close").tap();
|
||||
|
||||
await stagedRow.getByTitle("Unstage file").tap();
|
||||
await stagedRow.getByRole("button", { name: "Show more actions" }).tap();
|
||||
await testPage.getByRole("menuitem", { name: "Unstage file", exact: true }).tap();
|
||||
await expect(stagedRow).toHaveCount(0);
|
||||
await expect(unstagedRow).toBeVisible();
|
||||
await expect(unstagedRow.getByText("+2", { exact: true })).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user