diff --git a/plugins/file-manager/README.md b/plugins/file-manager/README.md index e958e6e..b81a0a0 100644 --- a/plugins/file-manager/README.md +++ b/plugins/file-manager/README.md @@ -157,6 +157,9 @@ browser runs on. This plugin adds the other machine — the one bb itself runs o line. - **Right-click a row** in the file manager → **Add to chat**. Several files selected means several mentions, one per file. +- **On mobile**, tick one or more rows, then tap **Actions** in the selection + bar → **Add to chat**. The same drawer also contains download, organize, + rename, properties and delete actions for the selection. - **+ → From File Manager…** opens a small browser over the composer, starting in your start folder. Tick as many files as you need: a checkbox on every row, `Shift`-click for the run between two of them, and *Select every file @@ -208,6 +211,13 @@ download, drag and drop, and upload. Dragging over a collapsed folder for about 0.7 s springs it open so you can drop deeper; dropping onto a file row resolves to the folder that row lives in. +On a compact layout or a coarse-pointer device, tick a row or tile to reveal a +persistent selection bar. **Actions** opens the selected-item menu as a bottom +drawer; **Clear selection** removes the ticks. Native row dragging is disabled +there so a long press cannot turn the item into a browser drag ghost. This also +covers landscape phones and tablets; desktop row dragging and right-click menus +are unchanged. + Three behaviors worth knowing: - `Ctrl`/`Cmd`+`A` selects every **visible** row, including expanded children. diff --git a/plugins/file-manager/SPEC.md b/plugins/file-manager/SPEC.md index 75ef937..427458b 100644 --- a/plugins/file-manager/SPEC.md +++ b/plugins/file-manager/SPEC.md @@ -1037,6 +1037,8 @@ components/FileTable.tsx FRONTEND header row, sorting, rubber- components/FileRow.tsx FRONTEND one row: icon, name, size, mtime, drag source/target components/FileGallery.tsx FRONTEND the gallery view: thumbnail grid over the same handlers (§8.9) components/RowContextMenu.tsx FRONTEND right-click menu for a selection +components/SelectionActionBar.tsx FRONTEND compact/touch selected-item actions +components/selected-entry-actions.ts FRONTEND shared selected-action policy and ordering components/BackgroundContextMenu.tsx FRONTEND right-click menu for empty space components/ActivityTray.tsx FRONTEND upload progress + extract jobs, bottom-right components/EmptyState.tsx FRONTEND empty dir / no search results / escapesRoot dir @@ -1107,6 +1109,7 @@ preview, and extracting it is what the gesture is for. Downloading stays on the row menu, where it is explicit. | right click on a row | `RowContextMenu`; if the row is not selected, select it first | | right click on empty space | `BackgroundContextMenu` | +| select a row on a compact viewport or coarse primary pointer | show `SelectionActionBar`; **Actions** opens the same selected-item operations in a responsive bottom drawer | | click on a breadcrumb | navigate to that ancestor | | column header click | toggle sort field / direction (persisted via `savePreferences`) | @@ -1152,7 +1155,15 @@ when the event target is an `input`, `textarea` or `[contenteditable]`. **Internal (row → folder)** -* Rows are `draggable`. `dragstart`: +* Rows are `draggable` only outside compact layouts and coarse primary-pointer + devices. Those touch-oriented surfaces disable native row/tile dragging so + long-press cannot enter browser drag mode; selected-item operations remain + available from `SelectionActionBar`. Pointer capability is independent of + viewport width, so landscape phones and tablets follow the touch path too. +* `RowContextMenu` and `SelectionActionBar` render the same action groups from + `selectedEntryActionModel`; visibility, enablement, order and callbacks are + not reimplemented per surface. +* On a draggable row, `dragstart`: `dataTransfer.effectAllowed = "move"`, `setData("application/x-bb-file-manager", JSON.stringify(selectedPaths))`, plus a `text/plain` fallback of newline-joined paths. If the dragged row is @@ -1769,7 +1780,7 @@ first line plus the `matchMedia` / `scrollIntoView` stubs in the setup file. | `registration.test.tsx` | `app.navPanels[0]` matches `{ id: "file-manager", title: "File Manager", icon: "FolderOpen", path: "files" }`; `headerContent` and `experimental_sidebarAccessory` are functions | | `panel.test.tsx` | renders rows from a stubbed `listDir`; hidden toggle re-issues `listDir` with `showHidden: true`; sorting by size reorders without an RPC; search filters client-side; `emitRealtime("fs", { paths:[cwd] })` triggers exactly one refetch; `setRealtimeConnectionState("connected")` refetches | | `selection.test.tsx` | click / ctrl-click / shift-click / `Ctrl+A` / `Escape` produce the expected selections | -| `menus.test.tsx` | right-click on a file shows Download/Rename/Cut/Copy/Delete; Delete opens the confirm dialog when `confirmOnDelete`, calls `deleteEntries` when confirmed | +| `menus.test.tsx` | right-click on a file shows Download/Rename/Cut/Copy/Delete; Delete opens the confirm dialog when `confirmOnDelete`, calls `deleteEntries` when confirmed; compact and wide coarse-pointer selection disable native row/tile dragging and expose the responsive drawer; desktop remains draggable; desktop and touch action IDs and disabled states stay in parity | | `uploads.test.tsx` | dropping two `File`s calls `uploadCreate` twice and posts chunks in order (stub `XMLHttpRequest`); a 409 response resumes from `expected`; the tray shows percentages | | `bookmarks.test.tsx` | §8.11: the star lights up for a bookmarked folder and toggles `addBookmark` / `removeBookmark`; the list navigates through `navigateTo`; a missing row is marked and removes itself; the rename dialog sends `renameBookmark`; both context menus toggle; the compact chrome keeps the star and moves the list into the overflow; the 51st is refused client-side | diff --git a/plugins/file-manager/components/FileGallery.tsx b/plugins/file-manager/components/FileGallery.tsx index 1959b1d..f481d6d 100644 --- a/plugins/file-manager/components/FileGallery.tsx +++ b/plugins/file-manager/components/FileGallery.tsx @@ -34,6 +34,7 @@ export interface FileGalleryProps { selectedPaths: ReadonlySet; focusedPath: string | null; cutPaths: ReadonlySet; + dragEnabled: boolean; /** Path currently highlighted as a drop target (a tile, or `..`). */ dropTargetPath: string | null; /** @@ -66,6 +67,7 @@ export interface FileGalleryProps { interface GalleryTileProps { entry: FileEntry; + dragEnabled: boolean; selected: boolean; focused: boolean; cut: boolean; @@ -85,6 +87,7 @@ interface GalleryTileProps { function GalleryTileImpl({ entry, + dragEnabled, selected, focused, cut, @@ -119,7 +122,7 @@ function GalleryTileImpl({ data-selected={selected ? "true" : undefined} data-drop-target={dropTarget ? "true" : undefined} tabIndex={-1} - draggable + draggable={dragEnabled} title={entry.name} className={cn( "group flex min-w-0 cursor-default flex-col gap-1 rounded-md p-1.5 select-none", @@ -278,6 +281,7 @@ export function FileGallery(props: FileGalleryProps) { ({ + entries, + writable, + canPaste, + canExtract: + entries.length === 1 && + entries[0]?.archiveFormat != null && + isFormatSupported(entries[0].archiveFormat, archiveSupport), + onOpen: openEntry, + onDownload: () => downloadSelection(entries), + onAddToChat: () => addToChat(entries), + onExtract: (entry) => setDialog({ kind: "extract", entry }), + onCut: () => clipboard.cut(topLevelPaths(entries.map((entry) => entry.path))), + onCopy: () => clipboard.copy(topLevelPaths(entries.map((entry) => entry.path))), + onPaste: paste, + onMoveTo: () => + setDialog({ + kind: "picker", + mode: "move", + paths: entries.map((entry) => entry.path), + }), + onCopyTo: () => + setDialog({ + kind: "picker", + mode: "copy", + paths: entries.map((entry) => entry.path), + }), + onRename: (entry) => setDialog({ kind: "rename", entry }), + onCopyPath: () => copyPathsToClipboard(entries.map((entry) => entry.path)), + onDelete: () => requestDelete(entries), + onSetStartFolder: (entry) => setStartFolder(entry.path), + onProperties: () => openProperties(entries), + bookmarked, + canToggleBookmark: !bookmarks.loading, + onToggleBookmark: (entry) => toggleBookmark(entry.path), + }); + const selectedActionProps = actionPropsFor(selectedEntries, selectedActionBookmarked); + const rowMenuActionProps = actionPropsFor(menuEntries, rowMenuBookmarked); const bookmarkItems = { bookmarks: bookmarks.bookmarks, currentBookmarked, @@ -2345,6 +2396,13 @@ export function FileManagerSurface({ pathFocusTick={pathFocusTick} /> + {touchActionsEnabled && selectedEntries.length > 0 ? ( + + ) : null} + {stateError === null ? null : ( {menuEntries.length > 0 ? ( - downloadSelection(menuEntries)} - onAddToChat={() => addToChat(menuEntries)} - onExtract={(entry) => setDialog({ kind: "extract", entry })} - onCut={() => clipboard.cut(topLevelPaths(menuEntries.map((entry) => entry.path)))} - onCopy={() => clipboard.copy(topLevelPaths(menuEntries.map((entry) => entry.path)))} - onPaste={paste} - onMoveTo={() => - setDialog({ - kind: "picker", - mode: "move", - paths: menuEntries.map((entry) => entry.path), - }) - } - onCopyTo={() => - setDialog({ - kind: "picker", - mode: "copy", - paths: menuEntries.map((entry) => entry.path), - }) - } - onRename={(entry) => setDialog({ kind: "rename", entry })} - onCopyPath={() => copyPathsToClipboard(menuEntries.map((entry) => entry.path))} - onDelete={() => requestDelete(menuEntries)} - onSetStartFolder={(entry) => setStartFolder(entry.path)} - onProperties={() => openProperties(menuEntries)} - bookmarked={rowMenuBookmarked} - canToggleBookmark={!bookmarks.loading} - onToggleBookmark={(entry) => toggleBookmark(entry.path)} - /> + ) : ( ; focusedPath: string | null; cutPaths: ReadonlySet; + dragEnabled: boolean; sortField: SortField; sortDirection: SortDirection; onSort: (field: SortField) => void; @@ -313,6 +314,7 @@ export function FileTable(props: FileTableProps) { `: the panel owns one Radix -// ContextMenu root around the whole table, and swaps this component for -// BackgroundContextMenu depending on where the click landed. One root avoids -// the double-open you get when a per-row trigger and a container trigger both -// see the same `contextmenu` event. -import type { FileEntry } from "../contract"; +// components/RowContextMenu.tsx — desktop renderer for selected-entry actions. +import { Fragment } from "react"; + import { useMenuPointerGuard } from "../hooks/useMenuPointerGuard"; +import { + selectedEntryActionModel, + type SelectedEntryActionsProps, +} from "./selected-entry-actions"; import { ContextMenuContent, ContextMenuItem, @@ -15,188 +14,40 @@ import { ContextMenuShortcut, } from "./ui/context-menu"; import { Icon } from "./ui/icon"; -import { effectiveKind } from "./FileRow"; -export interface RowContextMenuProps { - /** Everything the action applies to; never empty when this is rendered. */ - entries: readonly FileEntry[]; - /** False when `listDir` said the current directory is read-only. */ - writable: boolean; - canPaste: boolean; - /** True when at least one extractor exists for the selected archive. */ - canExtract: boolean; - onOpen: (entry: FileEntry) => void; - onDownload: () => void; - /** One @-mention per selected file, into whatever composer is in reach (§8.8). */ - onAddToChat: () => void; - onExtract: (entry: FileEntry) => void; - onCut: () => void; - onCopy: () => void; - onPaste: () => void; - onMoveTo: () => void; - onCopyTo: () => void; - onRename: (entry: FileEntry) => void; - onCopyPath: () => void; - onDelete: () => void; - onSetStartFolder: (entry: FileEntry) => void; - onProperties: () => void; - /** True when the single directory row is already bookmarked (§8.11). */ - bookmarked: boolean; - /** False only while the list has not arrived yet. */ - canToggleBookmark: boolean; - onToggleBookmark: (entry: FileEntry) => void; -} +export type RowContextMenuProps = SelectedEntryActionsProps; -export function RowContextMenu({ - entries, - writable, - canPaste, - canExtract, - onOpen, - onDownload, - onAddToChat, - onExtract, - onCut, - onCopy, - onPaste, - onMoveTo, - onCopyTo, - onRename, - onCopyPath, - onDelete, - onSetStartFolder, - onProperties, - bookmarked, - canToggleBookmark, - onToggleBookmark, -}: RowContextMenuProps) { - const single = entries.length === 1 ? entries[0] : undefined; - const isDirectory = single !== undefined && effectiveKind(single) === "directory"; - const escapes = entries.some((entry) => entry.escapesRoot); - // Both "Download" and "Add to chat" act on exactly the real files in the - // selection: a folder has no bytes to send, and a link out of the root is - // refused by the server anyway (§6). - const files = entries.filter((entry) => !entry.escapesRoot && effectiveKind(entry) === "file"); - const downloadable = files.length > 0; - const archive = single !== undefined && single.archiveFormat !== null ? single : undefined; - // Letting go of the right button must not run whatever it landed on. +export function RowContextMenu(props: RowContextMenuProps) { + const model = selectedEntryActionModel(props); const pointerGuard = useMenuPointerGuard(); return ( - - {single === undefined ? `${String(entries.length)} items` : single.name} - - - - {single !== undefined && isDirectory && !escapes ? ( - onOpen(single)}> - - ) : null} - - - - - {/* Sits beside Download because it answers the same question — "take - this file somewhere" — with the other destination: the agent. */} - - - - {archive === undefined ? null : ( - onExtract(archive)}> - - )} - - - - - - - - - - - - - - - - - - - - { - if (single !== undefined) onRename(single); - }} - > - - - - {/* Folders only, and one at a time: a bookmark is a place to go, and a - file (or a selection of five) is not one. */} - {single !== undefined && isDirectory && !escapes ? ( - <> - onSetStartFolder(single)}> - - onToggleBookmark(single)} - > - - - ) : null} - - - - - - - + {model.label} + {model.groups.map((group, groupIndex) => ( + + + {group.map((action) => ( + + + ))} + + ))} ); } diff --git a/plugins/file-manager/components/SelectionActionBar.tsx b/plugins/file-manager/components/SelectionActionBar.tsx new file mode 100644 index 0000000..05c77bb --- /dev/null +++ b/plugins/file-manager/components/SelectionActionBar.tsx @@ -0,0 +1,96 @@ +// Selected-entry actions for compact and coarse-pointer layouts. +import { Fragment } from "react"; + +import { + selectedEntryActionModel, + type SelectedEntryActionsProps, +} from "./selected-entry-actions"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { CompactViewportOverrideProvider } from "./ui/hooks/use-compact-viewport"; +import { Icon } from "./ui/icon"; + +export interface SelectionActionBarProps extends SelectedEntryActionsProps { + onClear: () => void; +} + +export function SelectionActionBar({ onClear, ...actionProps }: SelectionActionBarProps) { + const model = selectedEntryActionModel(actionProps); + const count = actionProps.entries.length; + const itemWord = count === 1 ? "item" : "items"; + + return ( +
+ + {String(count)} selected + + + {/* This surface exists for touch use. Force the responsive menu into its + drawer renderer even on a wide coarse-pointer device. */} + + + + + + + {model.label} + {model.groups.map((group, groupIndex) => ( + + + {group.map((action) => ( + + + ))} + + ))} + + + + + +
+ ); +} diff --git a/plugins/file-manager/components/selected-entry-actions.ts b/plugins/file-manager/components/selected-entry-actions.ts new file mode 100644 index 0000000..7a77c27 --- /dev/null +++ b/plugins/file-manager/components/selected-entry-actions.ts @@ -0,0 +1,195 @@ +// One selected-entry policy shared by every menu surface. +// +// Context menus and touch drawers paint differently, but action visibility, +// enablement, order and behavior must never diverge between them (§8.2). +import type { FileEntry } from "../contract"; +import { effectiveKind } from "./FileRow"; +import type { IconName } from "./ui/icon"; + +export interface SelectedEntryActionsProps { + entries: readonly FileEntry[]; + writable: boolean; + canPaste: boolean; + canExtract: boolean; + onOpen: (entry: FileEntry) => void; + onDownload: () => void; + onAddToChat: () => void; + onExtract: (entry: FileEntry) => void; + onCut: () => void; + onCopy: () => void; + onPaste: () => void; + onMoveTo: () => void; + onCopyTo: () => void; + onRename: (entry: FileEntry) => void; + onCopyPath: () => void; + onDelete: () => void; + onSetStartFolder: (entry: FileEntry) => void; + onProperties: () => void; + bookmarked: boolean; + canToggleBookmark: boolean; + onToggleBookmark: (entry: FileEntry) => void; +} + +export type SelectedEntryActionId = + | "open" + | "download" + | "add-to-chat" + | "extract" + | "cut" + | "copy" + | "paste" + | "move-to" + | "copy-to" + | "rename" + | "copy-path" + | "set-start-folder" + | "bookmark" + | "properties" + | "delete"; + +export interface SelectedEntryAction { + id: SelectedEntryActionId; + label: string; + icon: IconName; + disabled: boolean; + destructive?: boolean; + /** Selection count displayed on every surface. */ + trailing?: string; + /** Desktop-only keyboard hint. */ + shortcut?: string; + run: () => void; +} + +export interface SelectedEntryActionModel { + label: string; + groups: readonly (readonly SelectedEntryAction[])[]; +} + +export function selectedEntryActionModel( + props: SelectedEntryActionsProps, +): SelectedEntryActionModel { + const { entries, writable, canPaste, canExtract, bookmarked, canToggleBookmark } = props; + const single = entries.length === 1 ? entries[0] : undefined; + const directory = single !== undefined && effectiveKind(single) === "directory"; + const escapes = entries.some((entry) => entry.escapesRoot); + const files = entries.filter( + (entry) => !entry.escapesRoot && effectiveKind(entry) === "file", + ); + const archive = single !== undefined && single.archiveFormat !== null ? single : undefined; + const folderAction = single !== undefined && directory && !escapes; + + const transfer: SelectedEntryAction[] = []; + if (folderAction) { + transfer.push({ + id: "open", + label: "Open", + icon: "FolderOpen", + disabled: false, + run: () => props.onOpen(single), + }); + } + transfer.push( + { + id: "download", + label: "Download", + icon: "Download", + disabled: files.length === 0, + trailing: entries.length > 1 ? String(entries.length) : undefined, + run: props.onDownload, + }, + { + id: "add-to-chat", + label: "Add to chat", + icon: "MessageSquarePlus", + disabled: files.length === 0, + trailing: files.length > 1 ? String(files.length) : undefined, + run: props.onAddToChat, + }, + ); + if (archive !== undefined) { + transfer.push({ + id: "extract", + label: "Extract…", + icon: "ArchiveRestore", + disabled: !canExtract || !writable, + run: () => props.onExtract(archive), + }); + } + + const organize: SelectedEntryAction[] = [ + { id: "cut", label: "Cut", icon: "Layers", disabled: escapes, shortcut: "Ctrl+X", run: props.onCut }, + { id: "copy", label: "Copy", icon: "Copy", disabled: escapes, shortcut: "Ctrl+C", run: props.onCopy }, + { + id: "paste", + label: "Paste", + icon: "PackageReceive", + disabled: !canPaste || !writable, + shortcut: "Ctrl+V", + run: props.onPaste, + }, + ]; + const destinations: SelectedEntryAction[] = [ + { id: "move-to", label: "Move to…", icon: "FolderExport", disabled: escapes, run: props.onMoveTo }, + { id: "copy-to", label: "Copy to…", icon: "Folder", disabled: escapes, run: props.onCopyTo }, + ]; + const details: SelectedEntryAction[] = [ + { + id: "rename", + label: "Rename", + icon: "Edit", + disabled: single === undefined || !writable, + shortcut: "F2", + run: () => { + if (single !== undefined) props.onRename(single); + }, + }, + { id: "copy-path", label: "Copy path", icon: "Paperclip", disabled: false, run: props.onCopyPath }, + ]; + if (folderAction) { + details.push( + { + id: "set-start-folder", + label: "Set as start folder", + icon: "Pin", + disabled: false, + run: () => props.onSetStartFolder(single), + }, + { + id: "bookmark", + label: bookmarked ? "Remove bookmark" : "Bookmark", + icon: bookmarked ? "PinOff" : "Star", + disabled: !canToggleBookmark, + run: () => props.onToggleBookmark(single), + }, + ); + } + details.push({ + id: "properties", + label: "Properties", + icon: "Info", + disabled: false, + shortcut: "Alt+Enter", + run: props.onProperties, + }); + + return { + label: single === undefined ? `${String(entries.length)} items` : single.name, + groups: [ + transfer, + organize, + destinations, + details, + [ + { + id: "delete", + label: "Delete", + icon: "Trash2", + disabled: !writable, + destructive: true, + shortcut: "Del", + run: props.onDelete, + }, + ], + ], + }; +} diff --git a/plugins/file-manager/components/ui/hooks/use-coarse-pointer.tsx b/plugins/file-manager/components/ui/hooks/use-coarse-pointer.tsx new file mode 100644 index 0000000..1b3fa06 --- /dev/null +++ b/plugins/file-manager/components/ui/hooks/use-coarse-pointer.tsx @@ -0,0 +1,34 @@ +import { + createContext, + createElement, + useContext, + type ReactNode, +} from "react"; + +import { useMediaQuery } from "./use-media-query.js"; + +export const COARSE_POINTER_QUERY = "(pointer: coarse)"; + +const CoarsePointerOverrideContext = createContext(null); + +interface CoarsePointerOverrideProviderProps { + children: ReactNode; + isCoarsePointer: boolean; +} + +export function CoarsePointerOverrideProvider({ + children, + isCoarsePointer, +}: CoarsePointerOverrideProviderProps) { + return createElement( + CoarsePointerOverrideContext.Provider, + { value: isCoarsePointer }, + children, + ); +} + +export function useIsCoarsePointer(): boolean { + const override = useContext(CoarsePointerOverrideContext); + const isCoarsePointer = useMediaQuery(COARSE_POINTER_QUERY); + return override ?? isCoarsePointer; +} diff --git a/plugins/file-manager/test/frontend/menus.test.tsx b/plugins/file-manager/test/frontend/menus.test.tsx index 7e0ccf6..d8aa78e 100644 --- a/plugins/file-manager/test/frontend/menus.test.tsx +++ b/plugins/file-manager/test/frontend/menus.test.tsx @@ -5,11 +5,14 @@ // exactly these arguments" — the menu is the only place most of the contract's // mutations can be reached from. import { cleanup, fireEvent, waitFor, within } from "@testing-library/react"; +import type { ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; import type { PluginRpcTestHandlers, RenderedSlot, RpcCall } from "@get-bb/plugin-sdk/testing/app"; import type { FileEntry, FileManagerContract, Job } from "../../contract"; +import { CompactViewportOverrideProvider } from "../../components/ui/hooks/use-compact-viewport"; +import { CoarsePointerOverrideProvider } from "../../components/ui/hooks/use-coarse-pointer"; const HOST_ID = "host_test"; @@ -137,6 +140,35 @@ async function mountPanel( return slot; } +async function mountPanelForInteraction( + interaction: { compact: boolean; coarsePointer: boolean }, + handlers: Partial> = baseRpc(), +): Promise { + const Panel = registration.component; + function InteractionPanel(props: ComponentProps) { + return ( + + + + + + ); + } + const slot = renderSlot( + { component: InteractionPanel }, + { subPath: "" }, + { rpc: handlers as PluginRpcTestHandlers }, + ); + await slot.findByText("notes.txt"); + return slot; +} + +async function mountCompactPanel( + handlers: Partial> = baseRpc(), +): Promise { + return mountPanelForInteraction({ compact: true, coarsePointer: true }, handlers); +} + function rowFor(slot: RenderedSlot, path: string): HTMLElement { return slot .getAllByTestId("fm-row") @@ -240,6 +272,113 @@ describe("keyboard access to the menus (§8.3)", () => { }); }); +describe("compact viewport selection actions", () => { + it("replaces native dragging with a touch-friendly selected-item menu", async () => { + const slot = await mountCompactPanel(); + const row = rowFor(slot, NOTES.path); + + fireEvent.click(within(row).getByRole("checkbox")); + + expect(row.draggable).toBe(false); + expect(slot.getByTestId("fm-selection-bar").textContent).toContain("1 selected"); + + fireEvent.click(slot.getByRole("button", { name: "Actions for 1 selected item" })); + const menu = await slot.findByTestId("fm-selection-menu"); + expect(menu.textContent).toContain("Download"); + expect(menu.textContent).toContain("Add to chat"); + expect(menu.textContent).toContain("Rename"); + expect(menu.textContent).toContain("Delete"); + + clickItem(menu, "Copy path"); + await waitFor(() => expect(clipboardWrites).toEqual([NOTES.path])); + + fireEvent.click(slot.getByRole("button", { name: "Clear selection" })); + expect(row.getAttribute("data-selected")).toBeNull(); + expect(slot.queryByTestId("fm-selection-bar")).toBeNull(); + }); + + it("keeps native dragging and the context-menu UI on desktop", async () => { + const slot = await mountPanel(); + const row = rowFor(slot, NOTES.path); + + fireEvent.click(within(row).getByRole("checkbox")); + + expect(row.draggable).toBe(true); + expect(slot.queryByTestId("fm-selection-bar")).toBeNull(); + }); + + it("uses touch actions on a wide coarse-pointer device", async () => { + const slot = await mountPanelForInteraction({ compact: false, coarsePointer: true }); + const row = rowFor(slot, NOTES.path); + + fireEvent.click(within(row).getByRole("checkbox")); + + expect(row.draggable).toBe(false); + expect(slot.getByTestId("fm-selection-bar").textContent).toContain("1 selected"); + }); + + it("disables native gallery-tile dragging on a wide coarse-pointer device", async () => { + const slot = await mountPanelForInteraction( + { compact: false, coarsePointer: true }, + baseRpc({ + getState: () => ({ + root: ROOT, + startFolder: ROOT, + preferences: { ...PREFERENCES, viewMode: "gallery" }, + chunkSizeBytes: 8 * 1024 * 1024, + maxListEntries: 5000, + archiveSupport: { zip: true, tar: true, sevenZip: false }, + pluginVersion: "0.1.0", + primaryHostId: HOST_ID, + }), + }), + ); + const tile = (await slot.findAllByTestId("fm-tile")).find( + (candidate) => candidate.getAttribute("data-fm-path") === NOTES.path, + )!; + + expect(tile.draggable).toBe(false); + fireEvent.click(within(tile).getByRole("checkbox")); + expect(slot.getByTestId("fm-selection-bar").textContent).toContain("1 selected"); + }); + + it("keeps desktop and touch action availability in parity", async () => { + const desktopSlot = await mountPanel(); + const desktopMenu = await openRowMenu(desktopSlot, NOTES.path); + const desktopActions = within(desktopMenu) + .getAllByRole("menuitem") + .map((item) => ({ + id: + item + .querySelector("[data-fm-selected-action]") + ?.getAttribute("data-fm-selected-action") ?? null, + disabled: item.getAttribute("aria-disabled") === "true", + })); + + cleanup(); + window.localStorage.clear(); + resetLastFolderStore(); + + const touchSlot = await mountCompactPanel(); + const row = rowFor(touchSlot, NOTES.path); + fireEvent.click(within(row).getByRole("checkbox")); + fireEvent.click(touchSlot.getByRole("button", { name: "Actions for 1 selected item" })); + const touchMenu = await touchSlot.findByTestId("fm-selection-menu"); + const touchActions = within(touchMenu) + .getAllByRole("menuitem") + .map((item) => ({ + id: + item + .querySelector("[data-fm-selected-action]") + ?.getAttribute("data-fm-selected-action") ?? null, + disabled: item.getAttribute("aria-disabled") === "true", + })); + + expect(touchActions).toEqual(desktopActions); + expect(touchActions.every((action) => action.id !== null)).toBe(true); + }); +}); + describe("row context menu (§8.2)", () => { it("selects the row it was opened on when it was not already selected", async () => { const slot = await mountPanel();