From 2b1dc541b83e8f5dc885860c9e8f16bcb63b3b5a Mon Sep 17 00:00:00 2001 From: Chris Sells Date: Sat, 29 Aug 2026 20:02:41 -0700 Subject: [PATCH 1/2] fix(file-manager): expose selection actions on mobile --- plugins/file-manager/README.md | 9 + plugins/file-manager/SPEC.md | 9 +- .../file-manager/components/FileGallery.tsx | 6 +- .../components/FileManagerPanel.tsx | 51 +++++ plugins/file-manager/components/FileRow.tsx | 5 +- plugins/file-manager/components/FileTable.tsx | 2 + .../components/SelectionActionBar.tsx | 199 ++++++++++++++++++ .../file-manager/test/frontend/menus.test.tsx | 58 +++++ 8 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 plugins/file-manager/components/SelectionActionBar.tsx diff --git a/plugins/file-manager/README.md b/plugins/file-manager/README.md index e958e6e..f45f589 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,12 @@ 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 mobile layout, 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. 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..7ae36a7 100644 --- a/plugins/file-manager/SPEC.md +++ b/plugins/file-manager/SPEC.md @@ -1037,6 +1037,7 @@ 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/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 +1108,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 | 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 +1154,10 @@ when the event target is an `input`, `textarea` or `[contenteditable]`. **Internal (row → folder)** -* Rows are `draggable`. `dragstart`: +* Rows are `draggable` on non-compact viewports. Compact viewports disable + native row/tile dragging so touch long-press cannot enter browser drag mode; + selected-item operations remain available from `SelectionActionBar`. +* 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 +1774,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 selection disables native dragging and exposes the same actions through the responsive drawer while desktop remains draggable | | `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) { + {isCompactViewport && selectedEntries.length > 0 ? ( + downloadSelection(selectedEntries)} + onAddToChat={() => addToChat(selectedEntries)} + onExtract={(entry) => setDialog({ kind: "extract", entry })} + onCut={() => clipboard.cut(topLevelPaths(selectedEntries.map((entry) => entry.path)))} + onCopy={() => clipboard.copy(topLevelPaths(selectedEntries.map((entry) => entry.path)))} + onPaste={paste} + onMoveTo={() => + setDialog({ + kind: "picker", + mode: "move", + paths: selectedEntries.map((entry) => entry.path), + }) + } + onCopyTo={() => + setDialog({ + kind: "picker", + mode: "copy", + paths: selectedEntries.map((entry) => entry.path), + }) + } + onRename={(entry) => setDialog({ kind: "rename", entry })} + onCopyPath={() => copyPathsToClipboard(selectedEntries.map((entry) => entry.path))} + onDelete={() => requestDelete(selectedEntries)} + onSetStartFolder={(entry) => setStartFolder(entry.path)} + onProperties={() => openProperties(selectedEntries)} + bookmarked={selectedActionBookmarked} + canToggleBookmark={!bookmarks.loading} + onToggleBookmark={(entry) => toggleBookmark(entry.path)} + onClear={selection.clear} + /> + ) : null} + {stateError === null ? null : ( ; focusedPath: string | null; cutPaths: ReadonlySet; + dragEnabled: boolean; sortField: SortField; sortDirection: SortDirection; onSort: (field: SortField) => void; @@ -313,6 +314,7 @@ export function FileTable(props: FileTableProps) { void; +} + +export function SelectionActionBar({ + entries, + writable, + canPaste, + canExtract, + onOpen, + onDownload, + onAddToChat, + onExtract, + onCut, + onCopy, + onPaste, + onMoveTo, + onCopyTo, + onRename, + onCopyPath, + onDelete, + onSetStartFolder, + onProperties, + bookmarked, + canToggleBookmark, + onToggleBookmark, + onClear, +}: SelectionActionBarProps) { + const single = entries.length === 1 ? entries[0] : undefined; + const isDirectory = single !== undefined && effectiveKind(single) === "directory"; + const escapes = entries.some((entry) => entry.escapesRoot); + const files = entries.filter( + (entry) => !entry.escapesRoot && effectiveKind(entry) === "file", + ); + const downloadable = files.length > 0; + const archive = single !== undefined && single.archiveFormat !== null ? single : undefined; + const count = entries.length; + const itemWord = count === 1 ? "item" : "items"; + + return ( +
+ + {String(count)} selected + + + + + + + + + {single === undefined ? `${String(count)} items` : single.name} + + + + {single !== undefined && isDirectory && !escapes ? ( + onOpen(single)}> + + ) : null} + + + + + + {archive === undefined ? null : ( + onExtract(archive)}> + + )} + + + + + + + + + + + + + + + + + + + + { + if (single !== undefined) onRename(single); + }} + > + + + + {single !== undefined && isDirectory && !escapes ? ( + <> + onSetStartFolder(single)}> + + onToggleBookmark(single)} + > + + + ) : null} + + + + + + + + + + + +
+ ); +} diff --git a/plugins/file-manager/test/frontend/menus.test.tsx b/plugins/file-manager/test/frontend/menus.test.tsx index 7e0ccf6..e82d847 100644 --- a/plugins/file-manager/test/frontend/menus.test.tsx +++ b/plugins/file-manager/test/frontend/menus.test.tsx @@ -5,11 +5,13 @@ // 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"; const HOST_ID = "host_test"; @@ -137,6 +139,26 @@ async function mountPanel( return slot; } +async function mountCompactPanel( + handlers: Partial> = baseRpc(), +): Promise { + const Panel = registration.component; + function CompactPanel(props: ComponentProps) { + return ( + + + + ); + } + const slot = renderSlot( + { component: CompactPanel }, + { subPath: "" }, + { rpc: handlers as PluginRpcTestHandlers }, + ); + await slot.findByText("notes.txt"); + return slot; +} + function rowFor(slot: RenderedSlot, path: string): HTMLElement { return slot .getAllByTestId("fm-row") @@ -240,6 +262,42 @@ 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(); + }); +}); + 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(); From 0d1f09fda10a3f06179ee2946b165276ac749048 Mon Sep 17 00:00:00 2001 From: Chris Sells Date: Sun, 30 Aug 2026 06:50:55 -0700 Subject: [PATCH 2/2] refactor(file-manager): unify touch and desktop actions --- plugins/file-manager/README.md | 11 +- plugins/file-manager/SPEC.md | 16 +- .../components/FileManagerPanel.tsx | 129 ++++------- .../components/RowContextMenu.tsx | 219 +++--------------- .../components/SelectionActionBar.tsx | 213 +++++------------ .../components/selected-entry-actions.ts | 195 ++++++++++++++++ .../ui/hooks/use-coarse-pointer.tsx | 34 +++ .../file-manager/test/frontend/menus.test.tsx | 91 +++++++- 8 files changed, 472 insertions(+), 436 deletions(-) create mode 100644 plugins/file-manager/components/selected-entry-actions.ts create mode 100644 plugins/file-manager/components/ui/hooks/use-coarse-pointer.tsx diff --git a/plugins/file-manager/README.md b/plugins/file-manager/README.md index f45f589..b81a0a0 100644 --- a/plugins/file-manager/README.md +++ b/plugins/file-manager/README.md @@ -211,11 +211,12 @@ 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 mobile layout, 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. Desktop row dragging and -right-click menus are unchanged. +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: diff --git a/plugins/file-manager/SPEC.md b/plugins/file-manager/SPEC.md index 7ae36a7..427458b 100644 --- a/plugins/file-manager/SPEC.md +++ b/plugins/file-manager/SPEC.md @@ -1038,6 +1038,7 @@ components/FileRow.tsx FRONTEND one row: icon, name, size, m 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 @@ -1108,7 +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 | show `SelectionActionBar`; **Actions** opens the same selected-item operations in a responsive bottom drawer | +| 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`) | @@ -1154,9 +1155,14 @@ when the event target is an `input`, `textarea` or `[contenteditable]`. **Internal (row → folder)** -* Rows are `draggable` on non-compact viewports. Compact viewports disable - native row/tile dragging so touch long-press cannot enter browser drag mode; - selected-item operations remain available from `SelectionActionBar`. +* 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))`, @@ -1774,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; compact selection disables native dragging and exposes the same actions through the responsive drawer while desktop remains draggable | +| `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/FileManagerPanel.tsx b/plugins/file-manager/components/FileManagerPanel.tsx index bed1a70..99be26a 100644 --- a/plugins/file-manager/components/FileManagerPanel.tsx +++ b/plugins/file-manager/components/FileManagerPanel.tsx @@ -112,6 +112,7 @@ import { FileGallery } from "./FileGallery"; import { FileTable } from "./FileTable"; import { RowContextMenu } from "./RowContextMenu"; import { SelectionActionBar } from "./SelectionActionBar"; +import type { SelectedEntryActionsProps } from "./selected-entry-actions"; import { Toolbar } from "./Toolbar"; import { BookmarkNameDialog } from "./dialogs/BookmarkNameDialog"; import { ConfirmDeleteDialog } from "./dialogs/ConfirmDeleteDialog"; @@ -123,6 +124,7 @@ import { PropertiesDialog, type PropertiesTarget } from "./dialogs/PropertiesDia import { RenameDialog } from "./dialogs/RenameDialog"; import { ContextMenu, ContextMenuTrigger } from "./ui/context-menu"; import { useIsCompactViewport } from "./ui/hooks/use-compact-viewport"; +import { useIsCoarsePointer } from "./ui/hooks/use-coarse-pointer"; import { publishPanelSnapshot, resetPanelSnapshot, @@ -388,6 +390,8 @@ export function FileManagerSurface({ }: FileManagerSurfaceProps) { const rpc = useFmRpc(); const isCompactViewport = useIsCompactViewport(); + const isCoarsePointer = useIsCoarsePointer(); + const touchActionsEnabled = isCompactViewport || isCoarsePointer; const subPath = location.subPath; const locationRef = useRef(location); locationRef.current = location; @@ -2242,6 +2246,47 @@ export function FileManagerSurface({ const selectedActionEntry = selectedEntries.length === 1 ? selectedEntries[0] : undefined; const selectedActionBookmarked = selectedActionEntry !== undefined && bookmarks.isBookmarked(selectedActionEntry.path); + const actionPropsFor = ( + entries: readonly FileEntry[], + bookmarked: boolean, + ): SelectedEntryActionsProps => ({ + 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, @@ -2351,45 +2396,9 @@ export function FileManagerSurface({ pathFocusTick={pathFocusTick} /> - {isCompactViewport && selectedEntries.length > 0 ? ( + {touchActionsEnabled && selectedEntries.length > 0 ? ( downloadSelection(selectedEntries)} - onAddToChat={() => addToChat(selectedEntries)} - onExtract={(entry) => setDialog({ kind: "extract", entry })} - onCut={() => clipboard.cut(topLevelPaths(selectedEntries.map((entry) => entry.path)))} - onCopy={() => clipboard.copy(topLevelPaths(selectedEntries.map((entry) => entry.path)))} - onPaste={paste} - onMoveTo={() => - setDialog({ - kind: "picker", - mode: "move", - paths: selectedEntries.map((entry) => entry.path), - }) - } - onCopyTo={() => - setDialog({ - kind: "picker", - mode: "copy", - paths: selectedEntries.map((entry) => entry.path), - }) - } - onRename={(entry) => setDialog({ kind: "rename", entry })} - onCopyPath={() => copyPathsToClipboard(selectedEntries.map((entry) => entry.path))} - onDelete={() => requestDelete(selectedEntries)} - onSetStartFolder={(entry) => setStartFolder(entry.path)} - onProperties={() => openProperties(selectedEntries)} - bookmarked={selectedActionBookmarked} - canToggleBookmark={!bookmarks.loading} - onToggleBookmark={(entry) => toggleBookmark(entry.path)} + {...selectedActionProps} onClear={selection.clear} /> ) : null} @@ -2454,7 +2463,7 @@ export function FileManagerSurface({ selectedPaths={selection.selected} focusedPath={selection.focus} cutPaths={cutPaths} - dragEnabled={!isCompactViewport} + dragEnabled={!touchActionsEnabled} dropTargetPath={dropTarget} previewBaseUrl={previewBaseUrl} parentPath={parentPath} @@ -2481,7 +2490,7 @@ export function FileManagerSurface({ selectedPaths={selection.selected} focusedPath={selection.focus} cutPaths={cutPaths} - dragEnabled={!isCompactViewport} + dragEnabled={!touchActionsEnabled} sortField={sortField} sortDirection={sortDirection} onSort={handleHeaderSort} @@ -2514,45 +2523,7 @@ export function FileManagerSurface({ {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)} - /> + ) : ( `: 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 index d3776d2..05c77bb 100644 --- a/plugins/file-manager/components/SelectionActionBar.tsx +++ b/plugins/file-manager/components/SelectionActionBar.tsx @@ -1,11 +1,10 @@ -// Selected-entry actions for compact/touch layouts. -// -// A context menu is still the fastest desktop interaction, but it has no -// discoverable or reliable equivalent on touch screens. This bar appears once -// an entry is selected and opens the same actions in the responsive dropdown, -// which becomes a bottom drawer on compact viewports. -import type { FileEntry } from "../contract"; -import { effectiveKind } from "./FileRow"; +// 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, @@ -16,46 +15,16 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from "./ui/dropdown-menu"; +import { CompactViewportOverrideProvider } from "./ui/hooks/use-compact-viewport"; import { Icon } from "./ui/icon"; -import type { RowContextMenuProps } from "./RowContextMenu"; -export interface SelectionActionBarProps extends RowContextMenuProps { +export interface SelectionActionBarProps extends SelectedEntryActionsProps { onClear: () => void; } -export function SelectionActionBar({ - entries, - writable, - canPaste, - canExtract, - onOpen, - onDownload, - onAddToChat, - onExtract, - onCut, - onCopy, - onPaste, - onMoveTo, - onCopyTo, - onRename, - onCopyPath, - onDelete, - onSetStartFolder, - onProperties, - bookmarked, - canToggleBookmark, - onToggleBookmark, - onClear, -}: SelectionActionBarProps) { - const single = entries.length === 1 ? entries[0] : undefined; - const isDirectory = single !== undefined && effectiveKind(single) === "directory"; - const escapes = entries.some((entry) => entry.escapesRoot); - const files = entries.filter( - (entry) => !entry.escapesRoot && effectiveKind(entry) === "file", - ); - const downloadable = files.length > 0; - const archive = single !== undefined && single.archiveFormat !== null ? single : undefined; - const count = entries.length; +export function SelectionActionBar({ onClear, ...actionProps }: SelectionActionBarProps) { + const model = selectedEntryActionModel(actionProps); + const count = actionProps.entries.length; const itemWord = count === 1 ? "item" : "items"; return ( @@ -67,122 +36,50 @@ export function SelectionActionBar({ {String(count)} selected - - - - - - - {single === undefined ? `${String(count)} items` : single.name} - - - - {single !== undefined && isDirectory && !escapes ? ( - onOpen(single)}> - - ) : null} - - - - - - {archive === undefined ? null : ( - onExtract(archive)}> - - )} - - - - - - - - - - - - - - - - - - - - { - if (single !== undefined) onRename(single); - }} + {/* This surface exists for touch use. Force the responsive menu into its + drawer renderer even on a wide coarse-pointer device. */} + + + + + + - - - - {single !== undefined && isDirectory && !escapes ? ( - <> - onSetStartFolder(single)}> - - onToggleBookmark(single)} - > - - - ) : null} - - - - - - - - - + {model.label} + {model.groups.map((group, groupIndex) => ( + + + {group.map((action) => ( + + + ))} + + ))} + + +