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();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
created: 2026-09-23
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/changes-file-row-containment.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Mobile changes density
|
||||
|
||||
## Overview
|
||||
|
||||
Restore readable working-tree filenames with compact touch rows and one file
|
||||
action menu. The user explicitly requested AFK planning and implementation in
|
||||
one turn, followed by a PR with screenshots. Execute sequentially here.
|
||||
|
||||
## Scope
|
||||
|
||||
Change working-tree row presentation and its mobile action entry point. Keep
|
||||
Git semantics, desktop composition, file ordering, and the task toolbar intact.
|
||||
UI owns the existing reusable file-row presentation contract; no domain or
|
||||
architecture boundary changes.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Branch `FileRow` content using the canonical responsive hook. Add a small
|
||||
touch-content component and shared DropdownMenu action surface, forwarding the
|
||||
existing callbacks. Reuse translated action labels. Keep `PanelBody` as the
|
||||
single scroll owner and existing viewport/safe-area handling. Update the public
|
||||
review guide to describe the touch menu.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Changes tab, unstaged list, phone/coarse pointer.
|
||||
|
||||
```text
|
||||
Before: [+ large] folder/file... [discard] [edit]
|
||||
After:
|
||||
UNSTAGED (8) Stage all
|
||||
[file] status-surface-metrics.test.ts [...]
|
||||
apps/web/components/task +12 -3
|
||||
[file] long-basename-wraps-onto-
|
||||
another-line.test.ts [...]
|
||||
apps/web/components/task
|
||||
|
||||
Menu: full/path/status-surface-metrics.test.ts
|
||||
Stage file
|
||||
Edit
|
||||
Discard changes
|
||||
```
|
||||
|
||||
UI-02: Fine-pointer desktop, existing compact row.
|
||||
|
||||
```text
|
||||
[+] folder/basename.ts +12 -3 M
|
||||
Hover: [undo] [edit]
|
||||
```
|
||||
|
||||
Structural requirements: basename receives available width; path/stats below;
|
||||
one visible secondary action target; menu exposes the full path; 44px touch
|
||||
targets and preserved desktop density. Pixel spacing is illustrative. Pending
|
||||
staging shows a spinner and disables its menu item. Cancel discard preserves
|
||||
the file and returns to the list. Covers AC-002.1 through AC-002.4.
|
||||
|
||||
## Tests
|
||||
|
||||
Existing `changes-panel-tree.test.tsx` guards tree action wiring. Update the
|
||||
existing file-row tests for touch menus, pending state, and fine-pointer phones.
|
||||
Playwright owns rendered geometry, deep-tree containment, and action routing.
|
||||
|
||||
## E2E tests
|
||||
|
||||
`task/mobile-changes-panel.spec.ts` (mobile-chrome) gains compact-row geometry,
|
||||
menu/action flow, and breakpoint assertions for all four criteria. Existing
|
||||
`git/git-changes-panel.spec.ts` (chromium) guards desktop staging and unstaging
|
||||
and adds the 767px/768px fine-pointer composition transition. The existing
|
||||
`git/mobile-symlink-identification.spec.ts` follows the file menu to Edit while
|
||||
retaining symlink-marker containment and viewer checks.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Compact touch file rows](task-01-compact-touch-file-rows.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Completed: 10 mobile browser tests, 4 focused desktop browser tests, 24 file-row
|
||||
and tree unit tests, typecheck, ESLint, Prettier, staged i18n ratchet, documentation catalog,
|
||||
36 specification-linter tests, full spec lint, and diff checks passed. The
|
||||
[work order](task-01-compact-touch-file-rows.md#results) records commands, the
|
||||
baseline failure, and a temporary desktop-discovery override needed for this
|
||||
worktree's name. Four inspected screenshots use disposable E2E data and are
|
||||
published separately from the product branch.
|
||||
|
||||
## Risks
|
||||
|
||||
Menu close can race confirmation focus; pass the persistent trigger as anchor.
|
||||
Tree indentation and long paths must not clip touch controls. Existing mobile
|
||||
tests using inline Unstage must use the menu.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
id: "01-compact-touch-file-rows"
|
||||
title: "Compact touch file rows"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002
|
||||
acceptance_criteria:
|
||||
- AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.1
|
||||
- AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.2
|
||||
- AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.3
|
||||
- AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.4
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/changes-file-row-containment.md
|
||||
---
|
||||
|
||||
# Task 01: Compact touch file rows
|
||||
|
||||
## Summary
|
||||
|
||||
Give filenames the primary width and group touch actions in one menu. Preserve
|
||||
desktop rows and the existing repository/layer-aware handlers.
|
||||
|
||||
## In scope
|
||||
|
||||
Touch rows, menu, pending feedback, browser regression tests, screenshots, docs.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Toolbar redesign, Git transport, provider rows, saved preferences, new APIs.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Phone rows follow UI-01 and meet the geometry/action criteria.
|
||||
- Stage, Unstage, Edit, Discard cancellation, and diff opening work by touch.
|
||||
- Desktop inline actions and tree behavior remain valid.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01 from the [full preview](plan.md#ascii-ui-preview):
|
||||
|
||||
```text
|
||||
[file] status-surface-metrics.test.ts [...]
|
||||
apps/web/components/task +12 -3
|
||||
Menu: full path / Stage file / Edit / Discard changes
|
||||
```
|
||||
|
||||
UI-02 desktop retains `[+] folder/name.ts +12 -3 M` and hover actions.
|
||||
AC-002.1 through AC-002.4 require the filename hierarchy, single touch menu,
|
||||
44px targets, action parity, and unchanged desktop density.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root after `pnpm install --frozen-lockfile` in `apps`:
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/task/mobile-changes-panel.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/git/mobile-symlink-identification.spec.ts -- --retries 0)
|
||||
(cd apps/web
|
||||
cat > e2e/playwright.local-desktop.config.ts <<'EOF'
|
||||
import config from "./playwright.config";
|
||||
export default {
|
||||
...config,
|
||||
projects: config.projects?.map((project) => project.name === "chromium"
|
||||
? { ...project, testIgnore: [/\/mobile-[^/]*\.spec\.ts$/, ...(project.testIgnore as RegExp[]).slice(1)] }
|
||||
: project),
|
||||
};
|
||||
EOF
|
||||
pnpm e2e:run --host --no-build --project chromium tests/git/git-changes-panel.spec.ts -- --config e2e/playwright.local-desktop.config.ts --grep 'keeps compact desktop|keeps stage and unstage|shows same path|shows modified files in unstaged'
|
||||
desktop_result=$?
|
||||
rm e2e/playwright.local-desktop.config.ts
|
||||
exit "$desktop_result")
|
||||
(cd apps/web && pnpm exec vitest run components/task/changes-panel-file-row.test.tsx components/task/changes-panel-tree.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm exec eslint components/task/changes-panel-file-row.tsx components/task/changes-panel-touch-file-row.tsx)
|
||||
(cd apps/web && pnpm run i18n:ratchet)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.test.py
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
git status --short -- docs/plans/mobile-changes-density
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/changes-panel-file-row.tsx`
|
||||
- `apps/web/components/task/changes-panel-file-row.test.tsx`
|
||||
- `apps/web/components/task/changes-panel-touch-file-row.tsx`
|
||||
- `apps/web/e2e/tests/task/mobile-changes-panel.spec.ts`
|
||||
- `apps/web/e2e/tests/git/git-changes-panel.spec.ts`
|
||||
- `apps/web/e2e/tests/git/mobile-symlink-identification.spec.ts`
|
||||
- `docs/public/sessions-and-review.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
Preserve trigger focus around portaled menus and discard confirmation.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
The linked file-row requirement/design, existing GitHelper fixtures, shared
|
||||
DropdownMenu primitives, and mobile UI language contextual-action pattern.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: the phone filename-width assertion failed at 46.7% against a required minimum of 65%.
|
||||
- GREEN: managed production build and mobile run: **9 passed**, including flat/tree geometry, 22-level tree containment, stage/unstage, discard cancellation, Edit, and layer/PR diff routing.
|
||||
- Symlink mobile regression: **1 passed**, with retries disabled. CI and the local RED run timed out waiting for the removed inline Edit button. The updated test checks the menu trigger and Edit item touch targets, marker containment, and the same file-viewer behavior. Production rendering is unchanged by this test-only follow-up.
|
||||
- Desktop: **4 passed**, including the new 767px/768px transition and existing stage/unstage pending feedback and mixed-layer scenarios.
|
||||
- The default desktop discovery excluded this worktree because its absolute path contains `mobile-`. The temporary config above scopes that exclusion to the spec basename; it changes no test behavior and was removed after the run.
|
||||
- File-row and tree Vitest suites: **24 passed**. Updated obsolete coarse-pointer assertions and covered the pending menu and fine-pointer phone presentation.
|
||||
- Review regression RED: a 22-level tree left zero filename width. GREEN: touch indentation is capped at 24px; the same browser test preserves more than half the row width for the basename and keeps status/menu contained.
|
||||
- Typecheck, ESLint with zero warnings (both components, the file-row unit tests, and both browser specs), Prettier check, and staged i18n ratchet: **passed**.
|
||||
- Catalog validation: **299 decisions and 1112 specifications**. Specification linter tests: **36 passed**. Full specification lint and `git diff --check`: **passed**.
|
||||
- Four fresh, inspected, compressed screenshots cover phone flat/tree lists, the action sheet, and desktop. Assets use disposable E2E data and remain outside the product branch.
|
||||
- Rendered hierarchy matches UI-01/UI-02. No new API, persistence, or locale keys. Public review guide updated with the menu entry point.
|
||||
@@ -312,6 +312,12 @@ Changes are grouped by repository and then by state:
|
||||
|
||||
From this panel you can stage or unstage files, discard working-tree changes, commit, amend, reset or revert commits, pull, rebase, merge, push, force-push, rename the task branch, choose a base branch, and create or open a pull request or merge request. Operations apply to the selected repository. Discarding a file is permanent, and history-changing operations can lose work or invalidate review; read [Git operations](git-operations.md) before using them.
|
||||
|
||||
On phones and touch devices, working-tree rows give filenames the main space.
|
||||
Tap a filename to open its diff, or tap the row's **Show more actions** menu to
|
||||
stage or unstage, edit, or discard that file. The menu shows the full path, and
|
||||
discarding still requires confirmation. In list view, the folder appears below
|
||||
the filename; long filenames wrap.
|
||||
|
||||
Changes-panel Git operations use Kandev's control path, not the agent's shell. They can work when a restricted agent mode blocks shell writes to Git metadata. If the error says that `.git/index.lock` already exists or is held, stop other Git operations and inspect the lock before retrying. Remove a stale lock only after you confirm that no Git process owns it. The Changes panel uses the same worktree, so it does not bypass an active lock. If the agent cannot create `.git/index.lock` because of its permission mode, use the Changes panel. Read [Git operations](git-operations.md#prerequisites-and-trust-boundary) before you change the agent mode.
|
||||
|
||||
For a linked fork pull request, the Changes header shows the exact comparison target, such as
|
||||
|
||||
@@ -31,8 +31,19 @@ path is longer than the available panel width.
|
||||
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-001.3:** Truncation shall preserve the row's existing full-path title and its click or tap outcome, including the pull-request and repository identity used to open the diff.
|
||||
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-001.4:** Desktop and phone Changes surfaces shall provide the same containment behavior without adding horizontal overflow to the panel or document.
|
||||
|
||||
### REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002: Compact touch file rows
|
||||
|
||||
**Intent:** Let phone users scan file names without inline actions consuming most of each row.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.1:** On phones and coarse-pointer devices, working-tree rows shall prioritize the basename, place directory context below it in list mode, and allow long basenames to wrap. Deep tree indentation shall not consume the filename or metadata width. A normal one-line basename and directory shall fit in a row no taller than 56px at the standard root font.
|
||||
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.2:** Each touch row shall offer one visible action-menu trigger with a small icon and a hit target of at least 44px in both dimensions. Stage or Unstage, Edit, and Discard shall remain reachable through that menu; tapping the file identity shall still open its diff or image.
|
||||
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.3:** The menu shall expose the full path, retain repository and staged-layer identity, disable staging while pending, and preserve discard confirmation and cancellation. Closing it without an action shall return focus to its trigger.
|
||||
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.4:** Fine-pointer desktop rows shall retain their inline staging, hover actions, tree indentation, and compact sizing. Responsive presentation shall not change saved file-list preferences.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing file-path, diff-statistic, or file-status data.
|
||||
- Changing row ordering, grouping, density, actions, or diff-source precedence.
|
||||
- Changing row ordering, grouping, Git action semantics, or diff-source precedence.
|
||||
- Changing the Changes panel's resize limits, scroll ownership, or mobile entry point.
|
||||
|
||||
@@ -3,6 +3,7 @@ status: current
|
||||
system: ui
|
||||
requirements:
|
||||
- REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-001
|
||||
- REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002
|
||||
---
|
||||
|
||||
# File-row containment
|
||||
@@ -20,6 +21,7 @@ selection.
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-001` | [Row layout contract](#row-layout-contract), [Responsive composition](#responsive-composition), [Verification](#verification) |
|
||||
| `REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002` | [Touch file rows](#touch-file-rows), [Verification](#verification) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
@@ -42,8 +44,35 @@ the basename also accepts truncation when it alone exceeds the remaining width.
|
||||
The trailing statistics-and-status region does not shrink. The existing full
|
||||
path `title` remains on the path control.
|
||||
|
||||
This is a CSS layout correction only. It adds no responsive state, JavaScript
|
||||
measurement, path rewriting, tooltip, or alternate row component.
|
||||
Provider rows retain their existing containment layout. Working-tree rows also
|
||||
apply the touch presentation below without rewriting paths or measuring text.
|
||||
|
||||
## Touch file rows
|
||||
|
||||
`FileRow` uses `useResponsiveBreakpoint` to choose the compact presentation
|
||||
when `isMobile || !isFinePointer`. The existing row click handler continues
|
||||
to own selection, image editing, and repository/layer-aware diff opening.
|
||||
`FileRow` splits the path once for either presentation.
|
||||
`TouchFileRowContent` renders a small file icon, a flexible filename-first
|
||||
identity button, and one 44px ellipsis trigger. List mode puts the directory
|
||||
on a secondary line; tree mode retains parent context and caps file indentation
|
||||
at 24px so deep nesting cannot consume the filename or metadata width. Long
|
||||
basenames wrap instead of ending in an ellipsis. The identity button itself
|
||||
is at least 44px tall. Inline statistics share the secondary line without
|
||||
taking space from the basename.
|
||||
|
||||
`TouchFileRowActions` reuses the shared DropdownMenu primitives, including
|
||||
their inset phone sheet below 640px. Its label shows the full path with wrapping.
|
||||
Stage/Unstage, Edit, and Discard call the existing callbacks with repository
|
||||
identity. Menu events stop propagation so actions cannot open the diff. Pending
|
||||
staging uses the existing per-file pending flag. Discard passes the persistent
|
||||
ellipsis trigger as its confirmation anchor, outside the transient menu.
|
||||
|
||||
This follows the mobile UI language's visible contextual menu pattern and
|
||||
the shipped `MobileChangesPanel` full-height content surface. Primary content
|
||||
stays inline because scanning files is frequent; temporary action choices
|
||||
use the existing bottom menu. No new state, persistence, scroll owner, or
|
||||
viewport container is introduced. Fine-pointer desktop composition is retained.
|
||||
|
||||
## Responsive composition
|
||||
|
||||
@@ -54,12 +83,12 @@ measurement, path rewriting, tooltip, or alternate row component.
|
||||
its single `PanelBody` scroll owner remain unchanged.
|
||||
- **Nearest shipped exemplars:** the working-tree `FileRow` supplies the flex
|
||||
shrink geometry, while `MobileChangesPanel` supplies the phone composition.
|
||||
- **Hierarchy and primary action:** file identity stays first, trailing change
|
||||
evidence stays visible, and tapping the row still opens its diff.
|
||||
- **Hierarchy and primary action:** file identity stays first, change evidence
|
||||
stays visible, and tapping the row still opens its diff.
|
||||
- **Surface rationale:** file rows are primary, frequently scanned content, so
|
||||
in-place truncation fits better than a drawer, route, or horizontal scroller.
|
||||
- **Shared logic:** all data, selection, and diff handlers remain shared;
|
||||
viewport-specific presentation is unchanged.
|
||||
- **Shared logic:** all data, selection, Git mutations, and diff handlers remain
|
||||
shared; working-tree touch rows specialize presentation only.
|
||||
- **Geometry:** dynamic viewport behavior, safe-area handling, touch sizing,
|
||||
and vertical scroll ownership are unchanged. The correction must not add
|
||||
panel-level or document-level horizontal overflow.
|
||||
@@ -73,6 +102,12 @@ measurement, path rewriting, tooltip, or alternate row component.
|
||||
hit-testing, diff opening, and no row or document horizontal overflow.
|
||||
- Pixel 5 Playwright exercises the same seeded PR row through the existing
|
||||
mobile Changes entry point and proves the same visible and actionable result.
|
||||
- Working-tree phone Playwright proves filename width, row density, long-name
|
||||
wrapping, menu containment and focus return, staging/unstaging, Edit, discard
|
||||
cancellation, diff navigation, and a 22-level tree. It checks 393px and 767px
|
||||
touch widths; the fine-pointer desktop test checks the 767px/768px transition.
|
||||
- Desktop Git E2E and the existing file-row and tree Vitest suites guard inline
|
||||
actions, touch-menu routing, pending feedback, and tree behavior. Screenshots record the phone list, menu, and desktop surface.
|
||||
|
||||
## Related decisions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user