Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion apps/web/src/activatable-row.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import { describe, expect, test } from "bun:test";

import { isRowActivationKey, rowActivationProps } from "./activatable-row";
import {
isAdditiveSelectClick,
isRowActivationKey,
rowActivationProps,
} from "./activatable-row";

describe("isAdditiveSelectClick", () => {
// The test DOM reports a Darwin platform, so these exercise the Mac rules.
test("cmd-click is additive", () => {
expect(isAdditiveSelectClick({ metaKey: true, ctrlKey: false })).toBe(true);
});

test("ctrl-click is not additive on Mac (it's the context-menu gesture)", () => {
expect(isAdditiveSelectClick({ metaKey: false, ctrlKey: true })).toBe(
false,
);
});

test("a plain click is not additive", () => {
expect(isAdditiveSelectClick({ metaKey: false, ctrlKey: false })).toBe(
false,
);
});
});

describe("isRowActivationKey", () => {
test("Enter and Space activate", () => {
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/activatable-row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ export function isRowActivationKey(key: string): boolean {
return key === "Enter" || key === " ";
}

/**
* Whether a click's modifiers mean "add this row to the selection" rather
* than "activate/replace". Cmd-click is the additive gesture on every
* platform; Ctrl-click only joins in on non-Mac, because on Mac Ctrl-click
* is the native context-menu gesture — the browser can fire `click` and
* `contextmenu` from the same physical click, and treating Ctrl as additive
* there would silently toggle the very row the context menu is about to
* act on.
*/
export function isAdditiveSelectClick(event: {
readonly metaKey: boolean;
readonly ctrlKey: boolean;
}): boolean {
return event.metaKey || (!isMacPlatform() && event.ctrlKey);
}

function isMacPlatform(): boolean {
// Browsers report "MacIntel"; happy-dom (our test DOM) reports
// "X11; Darwin arm64" — both are the same Ctrl-click-is-context-menu OS.
return (
typeof navigator !== "undefined" && /mac|darwin/i.test(navigator.platform)
);
}

export function rowActivationProps(onSelect: () => void) {
return {
role: "button" as const,
Expand Down
173 changes: 148 additions & 25 deletions apps/web/src/pages/library-page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BulkActionBar,
Button,
LibrarySearchInput,
Menu,
Expand All @@ -7,6 +8,7 @@ import {
MenuTrigger,
PageShell,
RichEmptyState,
SelectionCheckbox,
Skeleton,
Table,
TableBody,
Expand All @@ -18,8 +20,13 @@ import {
artifactKindLabel,
formatRelativeTime,
toast,
useListSelection,
} from "@corbits/react-ui";
import type {
SelectionCheckboxState,
UseListSelectionResult,
ViewMode,
} from "@corbits/react-ui";
import type { ViewMode } from "@corbits/react-ui";
import {
ArtifactCard,
ArtifactRenderer,
Expand All @@ -33,8 +40,15 @@ import {
} from "@corbits/artifact-ui";
import type { ArtifactSort, ArtifactSummary } from "@corbits/artifact-ui";
import { useQueryClient } from "@tanstack/react-query";
import { ArrowsDownUp, ArrowSquareOut, Stack, X } from "@corbits/icons";
import {
ArrowsDownUp,
ArrowSquareOut,
LinkSimple as LinkIcon,
Stack,
X,
} from "@corbits/icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import {
describeApiError,
ListSkeleton,
Expand All @@ -49,7 +63,7 @@ import {
useAPIQuery,
type ArtifactDetail,
} from "../api";
import { rowActivationProps } from "../activatable-row";
import { isAdditiveSelectClick, isRowActivationKey } from "../activatable-row";
import { useBench } from "../bench-context";
import { readLastWorkbenchId } from "../last-workbench";
import {
Expand All @@ -63,7 +77,11 @@ import { tenantKeys } from "../query-client";
import { useBenchActivity } from "../shell/bench-activity";
import {
artifactUploadToast,
copyArtifactLinks,
copyArtifactLinksActionLabel,
copyArtifactLinksToastLabel,
isArtifactsUnavailableStatus,
LIBRARY_BULK_OPERATION_IDS,
mapArtifactListToSummaries,
uploadArtifactFiles,
} from "../shell/library-artifacts";
Expand All @@ -79,45 +97,109 @@ function ArtifactRows({
now,
selectedId,
onSelect,
selection,
}: {
readonly artifacts: readonly ArtifactSummary[];
readonly now: number | undefined;
readonly selectedId: string | null;
readonly onSelect: (id: string) => void;
readonly selection: UseListSelectionResult<string>;
}) {
const allSelected =
artifacts.length > 0 && selection.selectedCount === artifacts.length;
const headerChecked: SelectionCheckboxState =
selection.selectedCount === 0
? false
: allSelected
? true
: "indeterminate";
// `useListSelection` hands back ids in toggle/insertion order, not row
// order — a bottom-up shift-select would otherwise join/copy links out of
// visible order. Sort against this row order before handing ids to any
// bulk operation (copy links here, the context menu's `ids` below).
const visibleOrder = useMemo(
() => new Map(artifacts.map((artifact, index) => [artifact.id, index])),
[artifacts],
);

return (
<Table aria-label="Files">
<TableHeader>
<TableRow>
<TableHead className="w-10">
<SelectionCheckbox
checked={headerChecked}
onToggle={() =>
allSelected ? selection.clear() : selection.selectAll()
}
rowLabel="all files"
ariaLabel="Select all files"
className="opacity-100"
/>
</TableHead>
<TableHead>Title</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{artifacts.map((artifact) => (
<TableRow
key={artifact.id}
data-state={selectedId === artifact.id ? "selected" : undefined}
className="cursor-pointer"
{...rowActivationProps(() => onSelect(artifact.id))}
>
<TableCell className="font-medium">{artifact.title}</TableCell>
<TableCell className="text-muted-foreground">
{artifactKindLabel(artifact.kind)}
</TableCell>
<TableCell className="text-muted-foreground">
{artifact.ownerName ?? "—"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatRelativeTime(
artifact.updatedAt ?? artifact.createdAt,
now,
)}
</TableCell>
</TableRow>
))}
{artifacts.map((artifact) => {
const isSelected = selection.isSelected(artifact.id);
const selectionIds =
isSelected && selection.selectedCount > 1
? [...selection.selectedIds].sort(
(a, b) =>
(visibleOrder.get(a) ?? 0) - (visibleOrder.get(b) ?? 0),
)
: [artifact.id];
return (
<TableRow
key={artifact.id}
data-state={selectedId === artifact.id ? "selected" : undefined}
data-ctx-artifact={artifact.id}
data-ctx-artifact-selected-ids={selectionIds.join(",")}
className="group cursor-pointer"
role="button"
tabIndex={0}
onClick={(event: ReactMouseEvent) => {
if (event.shiftKey || isAdditiveSelectClick(event)) {
selection.toggle(artifact.id, { shiftKey: event.shiftKey });
return;
}
onSelect(artifact.id);
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event.key)) return;
event.preventDefault();
onSelect(artifact.id);
}}
>
<TableCell onClick={(event) => event.stopPropagation()}>
<SelectionCheckbox
checked={isSelected}
onToggle={(modifiers) =>
selection.toggle(artifact.id, modifiers)
}
rowLabel={artifact.title}
/>
</TableCell>
<TableCell className="font-medium">{artifact.title}</TableCell>
<TableCell className="text-muted-foreground">
{artifactKindLabel(artifact.kind)}
</TableCell>
<TableCell className="text-muted-foreground">
{artifact.ownerName ?? "—"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatRelativeTime(
artifact.updatedAt ?? artifact.createdAt,
now,
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
Expand Down Expand Up @@ -297,6 +379,26 @@ export function LibraryPage({
[artifacts, activeQuery, sort, onQueryChange],
);

const visibleIds = useMemo(
() => visible.map((artifact) => artifact.id),
[visible],
);
// A row filtered out of `visibleIds` drops out of `selection.selectedIds`
// immediately (the hook reconciles against `ids` on every read) but
// `useListSelection` keeps it in its own internal state, so the row comes
// back selected if the filter that hid it is cleared. Deliberate: it
// matches Finder/Sheets ("clearing a filter doesn't lose your picks") and
// needs no bookkeeping here.
const selection = useListSelection({ ids: visibleIds });

// Rows and cards render selection differently — only rows has checkboxes
// — so a selection made in one view has nothing to anchor to in the
// other. Clearing on view change is simpler than teaching the card view
// its own checkboxes for a selection UI it doesn't otherwise need.
useEffect(() => {
selection.clear();
}, [viewMode, selection.clear]);

const openPicker = useCallback(() => {
if (uploading === true) return;
fileInputRef.current?.click();
Expand Down Expand Up @@ -447,6 +549,7 @@ export function LibraryPage({
now={now}
selectedId={activeSelected}
onSelect={(id) => select(id)}
selection={selection}
/>
</div>
) : (
Expand Down Expand Up @@ -479,6 +582,26 @@ export function LibraryPage({
</div>
) : null}
</div>
<BulkActionBar count={selection.selectedCount} onClear={selection.clear}>
<Button
type="button"
size="sm"
variant="outline"
data-bulk-action={LIBRARY_BULK_OPERATION_IDS[0]}
onClick={() => {
const ids = [...selection.selectedIds].sort(
(a, b) => visibleIds.indexOf(a) - visibleIds.indexOf(b),
);
void copyArtifactLinks(ids).then(
() => toast(copyArtifactLinksToastLabel(ids.length)),
() => toast("Couldn't copy the link"),
);
}}
>
<LinkIcon aria-hidden="true" />
{copyArtifactLinksActionLabel(selection.selectedCount)}
</Button>
</BulkActionBar>
</div>
);
}
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/shell/context-menu/items.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const toastMock = spyOnReactUiToast();
import { shellContextMenuFor } from "./items";
import type { ShellContextMenuActions } from "./items";
import type { ShellContextMenuTarget } from "./targets";
import { LIBRARY_BULK_OPERATION_IDS } from "../library-artifacts";

function itemIds(entries: readonly ContextMenuEntry[]): readonly string[] {
return entries
Expand Down Expand Up @@ -152,6 +153,48 @@ describe("shellContextMenuFor: insights-run", () => {
});
});

describe("shellContextMenuFor: artifact", () => {
test("a single right-clicked file offers exactly the Files bulk action bar's operation set", () => {
const target: ShellContextMenuTarget = {
type: "artifact",
id: "art_1",
ids: ["art_1"],
};
const menu = shellContextMenuFor(target, actions());
// Parity, not eyeballing: the context menu and the bulk action bar are
// driven off the exact same constant (CL-6423).
expect(itemIds(menu.entries)).toEqual([...LIBRARY_BULK_OPERATION_IDS]);
expect(findItem(menu.entries, "copy-link").label).toBe("Copy link");
});

test("right-clicking inside a multi-select still offers the same operation set, pluralized", () => {
const target: ShellContextMenuTarget = {
type: "artifact",
id: "art_2",
ids: ["art_1", "art_2", "art_3"],
};
const menu = shellContextMenuFor(target, actions());
expect(itemIds(menu.entries)).toEqual([...LIBRARY_BULK_OPERATION_IDS]);
expect(findItem(menu.entries, "copy-link").label).toBe("Copy 3 links");
});

test("copy-link writes every acted-on file's canonical link, newline-joined", async () => {
const target: ShellContextMenuTarget = {
type: "artifact",
id: "art_1",
ids: ["art_1", "art_2"],
};
const menu = shellContextMenuFor(target, actions());
findItem(menu.entries, "copy-link").onSelect();
await Promise.resolve();
await Promise.resolve();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
`${window.location.origin}/files/a/art_1\n${window.location.origin}/files/a/art_2`,
);
expect(toastMock).toHaveBeenCalledWith("2 links copied");
});
});

describe("shellContextMenuFor: account", () => {
test("offers settings and sign-out, never a bare destructive gesture", () => {
const menu = shellContextMenuFor({ type: "account" }, actions());
Expand Down
Loading
Loading