- {canGoUp && !isSearchMode && (
-
setFocusIdx(0)} onDoubleClick={goUp} className="fb-thumb"
- data-testid="file-grid-node-up"
- style={{
- display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
- padding: 2, borderRadius: 2, cursor: 'pointer', gap: 2, aspectRatio: '1',
- borderWidth: 1, borderColor: 'transparent',
- borderStyle: 'solid',
- background: focusIdx === 0 ? FOCUS_BG : 'transparent',
- outline: focusIdx === 0 ? `2px solid ${FOCUS_BORDER}` : 'none',
- outlineOffset: '2px',
- }}>
-
-
..
-
- )}
- {sorted.map((node, i) => {
- const idx = ((canGoUp && !isSearchMode) ? i + 1 : i);
- const isDir = getMimeCategory(node) === 'dir';
- const isFocused = focusIdx === idx;
- const isSelected = selected.some(sel => sel.path === node.path);
- const { _uploading, _progress, _error } = node as any;
+ selected, onItemClick, onItemDoubleClick, thumbSize, mount, tokenParam, fontSize, isSearchMode,
+ vfsContextMenuActions, onVfsContextMenuOpen,
+}) => {
+ const menuOn = Boolean(vfsContextMenuActions?.length && onVfsContextMenuOpen);
+ const wrapRow = (stableKey: string, idx: number, row: React.ReactElement) =>
+ menuOn ? (
+
onVfsContextMenuOpen!(idx)}
+ >
+ {row}
+
+ ) : (
+ row
+ );
- return (
-
onItemClick(idx, e)}
- onDoubleClick={() => !_uploading && onItemDoubleClick(idx)}
- className="fb-thumb" style={{
+ return (
+
+ {canGoUp && !isSearchMode && wrapRow('fb-up', 0, (
+
setFocusIdx(0)} onDoubleClick={goUp} className="fb-thumb"
+ data-testid="file-grid-node-up"
+ style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
- padding: 6, borderRadius: 6, cursor: _uploading ? 'default' : 'pointer', gap: 6, overflow: 'hidden',
- background: isSelected ? 'rgba(59, 130, 246, 0.05)' : 'transparent',
- opacity: (_uploading || _error) ? 0.7 : 1,
- }}>
-
- {isDir ?
:
}
-
- {_uploading && (
-
- )}
- {_error && (
-
- {_error}
-
- )}
-
-
-
- {node.name}
-
- {isSearchMode && (
-
- {node.parent || '/'}
-
- )}
-
+
+
..
- );
- })}
-
-);
+ ))}
+ {sorted.map((node, i) => {
+ const idx = ((canGoUp && !isSearchMode) ? i + 1 : i);
+ const isDir = getMimeCategory(node) === 'dir';
+ const isFocused = focusIdx === idx;
+ const isSelected = selected.some(sel => sel.path === node.path);
+ const { _uploading, _progress, _error } = node as any;
+
+ return wrapRow(node.path || node.name, idx, (
+
onItemClick(idx, e)}
+ onDoubleClick={() => !_uploading && onItemDoubleClick(idx)}
+ className="fb-thumb" style={{
+ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
+ padding: 6, borderRadius: 6, cursor: _uploading ? 'default' : 'pointer', gap: 6, overflow: 'hidden',
+ background: isSelected ? 'rgba(59, 130, 246, 0.05)' : 'transparent',
+ opacity: (_uploading || _error) ? 0.7 : 1,
+ }}>
+
+ {isDir ?
:
}
+
+ {_uploading && (
+
+ )}
+ {_error && (
+
+ {_error}
+
+ )}
+
+
+
+ {node.name}
+
+ {isSearchMode && (
+
+ {node.parent || '/'}
+
+ )}
+
+
+ ));
+ })}
+
+ );
+};
export default FileGridView;
diff --git a/packages/ui/src/modules/storage/FileListView.tsx b/packages/ui/src/modules/storage/FileListView.tsx
index 2f4fe91b..ec3312f8 100644
--- a/packages/ui/src/modules/storage/FileListView.tsx
+++ b/packages/ui/src/modules/storage/FileListView.tsx
@@ -1,5 +1,7 @@
import React from 'react';
import { ArrowUp } from 'lucide-react';
+import type { Action } from '@/actions/types';
+import { VfsContextMenuRow } from '@/modules/storage/VfsContextMenu';
import type { INode } from './types';
import { FOCUS_BG, FOCUS_BORDER, SELECTED_BG, SELECTED_BORDER } from './types';
import { getMimeCategory, CATEGORY_STYLE, formatSize, formatDate } from './helpers';
@@ -22,16 +24,35 @@ interface FileListViewProps {
/** Current type-ahead search buffer for visual highlight */
searchBuffer?: string;
isSearchMode?: boolean;
+ /** Radix context menu: same VFS actions as ribbon; requires both. */
+ vfsContextMenuActions?: Action[];
+ onVfsContextMenuOpen?: (idx: number) => void;
}
// ── Component ────────────────────────────────────────────────────
const FileListView: React.FC
= ({
listRef, sorted, canGoUp, goUp, focusIdx, setFocusIdx,
- selected, onItemClick, onItemDoubleClick, fontSize, mode, searchBuffer, isSearchMode
-}) => (
+ selected, onItemClick, onItemDoubleClick, fontSize, mode, searchBuffer, isSearchMode,
+ vfsContextMenuActions, onVfsContextMenuOpen,
+}) => {
+ const menuOn = Boolean(vfsContextMenuActions?.length && onVfsContextMenuOpen);
+ const wrapRow = (stableKey: string, idx: number, row: React.ReactElement) =>
+ menuOn ? (
+ onVfsContextMenuOpen!(idx)}
+ >
+ {row}
+
+ ) : (
+ row
+ );
+
+ return (
- {canGoUp && !isSearchMode && (
+ {canGoUp && !isSearchMode && wrapRow('fb-up', 0, (
setFocusIdx(0)} onDoubleClick={goUp}
data-testid="file-list-node-up"
className="fb-row" style={{
@@ -46,7 +67,7 @@ const FileListView: React.FC
= ({
..
- )}
+ ))}
{sorted.map((node, i) => {
// Note: in search mode, canGoUp is usually false, or we explicitly don't shift index by 1 since there is no ".." node rendered
const idx = ((canGoUp && !isSearchMode) ? i + 1 : i);
@@ -55,8 +76,8 @@ const FileListView: React.FC
= ({
const isSelected = selected.some(sel => sel.path === node.path);
const { _uploading, _progress, _error } = node as any;
- return (
- onItemClick(idx, e)}
@@ -129,9 +150,10 @@ const FileListView: React.FC = ({
)}
- );
+ ));
})}
-);
+ );
+};
export default FileListView;
diff --git a/packages/ui/src/modules/storage/FileTree.tsx b/packages/ui/src/modules/storage/FileTree.tsx
index 6781deb5..2d6f4333 100644
--- a/packages/ui/src/modules/storage/FileTree.tsx
+++ b/packages/ui/src/modules/storage/FileTree.tsx
@@ -1,8 +1,10 @@
import React, { useMemo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronRight, Folder, FolderOpen, File, ArrowUp, Loader2 } from "lucide-react";
+import type { Action } from "@/actions/types";
import { INode } from "@/modules/storage/types";
import { getMimeCategory } from "@/modules/storage/helpers";
import { cn } from "@/lib/utils";
+import { VfsContextMenuRow } from "@/modules/storage/VfsContextMenu";
export interface FileTreeProps {
data: INode[];
@@ -17,6 +19,8 @@ export interface FileTreeProps {
/** Async loader for folder children. If provided, folders become expandable inline. */
fetchChildren?: (node: INode) => Promise;
fontSize?: number;
+ /** Same VFS context menu as list/grid; tree applies Explorer-style selection in-tree. */
+ vfsContextMenuActions?: Action[];
}
type TreeRow = {
@@ -114,7 +118,7 @@ function findMatchIdx(rows: TreeRow[], str: string, startFrom: number, direction
}
export const FileTree = React.forwardRef(
- ({ data, onSelect, onSelectionChange, onActivate, onGoUp, canGoUp = false, selectedId, className, fetchChildren, fontSize = 14 }, forwardedRef) => {
+ ({ data, onSelect, onSelectionChange, onActivate, onGoUp, canGoUp = false, selectedId, className, fetchChildren, fontSize = 14, vfsContextMenuActions }, forwardedRef) => {
const [expandMap, setExpandMap] = useState>({});
const rows = useMemo(() => buildRows(data, canGoUp, expandMap), [data, canGoUp, expandMap]);
const [focusIdx, setFocusIdx] = useState(0);
@@ -341,6 +345,33 @@ export const FileTree = React.forwardRef(
containerRef.current?.focus({ preventScroll: true });
}, [selectRow, toggleSelectRow, rangeSelectTo]);
+ const menuOn = Boolean(vfsContextMenuActions?.length);
+
+ /** Explorer-style: focus row; if target is not in the current selection, select only that row. */
+ const prepareContextMenuRow = useCallback((idx: number) => {
+ const row = rows[idx];
+ setFocusIdx(idx);
+ anchorIdx.current = idx;
+ setSelectedIds((prev) => {
+ if (!row || row.isNavUp) return new Set();
+ if (prev.has(row.id)) return prev;
+ return new Set([row.id]);
+ });
+ }, [rows]);
+
+ const wrapTreeRow = useCallback((stableKey: string, idx: number, rowEl: React.ReactElement) =>
+ menuOn ? (
+ prepareContextMenuRow(idx)}
+ >
+ {rowEl}
+
+ ) : (
+ React.cloneElement(rowEl, { key: stableKey })
+ ), [menuOn, vfsContextMenuActions, prepareContextMenuRow]);
+
// Keyboard handler
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.altKey) return;
@@ -503,6 +534,20 @@ export const FileTree = React.forwardRef(
}
break;
}
+ case 'F10': {
+ if (e.shiftKey && menuOn) {
+ e.preventDefault();
+ e.stopPropagation();
+ const idx = focusIdx;
+ prepareContextMenuRow(idx);
+ const el = rowRefs.current[idx] ?? containerRef.current?.querySelector(`[data-fb-idx="${idx}"]`);
+ if (el) {
+ const r = el.getBoundingClientRect();
+ el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, view: window, button: 2 }));
+ }
+ }
+ break;
+ }
default: {
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key.toLowerCase();
@@ -519,7 +564,7 @@ export const FileTree = React.forwardRef(
break;
}
}
- }, [focusIdx, rows, canGoUp, onGoUp, activate, selectRow, toggleExpand, collapseNode, fetchChildren, rangeSelectTo, selectedIds, toggleExpandSelected]);
+ }, [focusIdx, rows, canGoUp, onGoUp, activate, selectRow, toggleExpand, collapseNode, fetchChildren, rangeSelectTo, selectedIds, toggleExpandSelected, menuOn, prepareContextMenuRow]);
return (
(
{rows.map((row, idx) => {
const isSelected = selectedIds.has(row.id);
const isFocused = focusIdx === idx;
- return (
+ return wrapTreeRow(row.id, idx, (
{ rowRefs.current[idx] = el; }}
className={cn(
"flex items-center gap-1 py-0.5 cursor-pointer select-none rounded group",
@@ -609,7 +654,7 @@ export const FileTree = React.forwardRef(
})()}
- );
+ ));
})}
);
diff --git a/packages/ui/src/modules/storage/PanelSide.tsx b/packages/ui/src/modules/storage/PanelSide.tsx
index eb4299d3..824ebc09 100644
--- a/packages/ui/src/modules/storage/PanelSide.tsx
+++ b/packages/ui/src/modules/storage/PanelSide.tsx
@@ -1,6 +1,7 @@
import React, { useCallback } from 'react';
import { X } from 'lucide-react';
import { translate } from '@/i18n';
+import { useIsMobile } from '@/hooks/use-mobile';
import { useFileBrowser, type Side } from './FileBrowserContext';
import FileBrowserPanel from './FileBrowserPanel';
@@ -9,13 +10,17 @@ interface PanelSideProps {
}
const PanelSide: React.FC = ({ side }) => {
+ const isMobile = useIsMobile();
const {
leftPanels, rightPanels,
activeSide, activePanelIdx,
setActivePanel, updatePanel, removePanel,
- viewMode, showToolbar, initialFile, mode, index,
+ viewMode, showToolbar, initialFile, mode, index, chrome,
} = useFileBrowser();
+ /** Ribbon replaces the per-panel toolbar on desktop only; mobile uses the compact toolbar. */
+ const showPanelToolbar = showToolbar && (chrome !== 'ribbon' || isMobile);
+
const panels = side === 'left' ? leftPanels : rightPanels;
const isActiveSide = activeSide === side;
const activeIdx = isActiveSide ? activePanelIdx : 0;
@@ -109,13 +114,15 @@ const PanelSide: React.FC = ({ side }) => {
();
+ for (const a of actions) {
+ const g = a.group || 'Other';
+ if (!m.has(g)) m.set(g, []);
+ m.get(g)!.push(a);
+ }
+ return Array.from(m.entries()).sort(([a], [b]) => a.localeCompare(b));
+}
+
+/**
+ * Wraps a file row / tile so a right-click opens the same VFS commands as the ribbon
+ * (actions with {@link Action.visibilities}.ContextMenu), after {@link onBeforeOpen} runs.
+ */
+export function VfsContextMenuRow({
+ actions,
+ onBeforeOpen,
+ children,
+}: {
+ actions: Action[];
+ onBeforeOpen: () => void;
+ children: React.ReactElement;
+}) {
+ const grouped = useMemo(() => groupActions(actions), [actions]);
+
+ if (!actions.length) {
+ return children;
+ }
+
+ return (
+ { if (open) onBeforeOpen(); }}>
+ {children}
+
+ {grouped.map(([group, items], gi) => (
+
+ {gi > 0 && }
+
+ {group.replace(/^VFS · /, '')}
+
+ {items.map((action) => {
+ const Icon = action.icon;
+ return (
+ { void action.handler?.(); }}
+ className={cn(action.metadata?.active && 'bg-accent/70')}
+ >
+ {Icon ? : null}
+ {action.label}
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/ui/src/modules/storage/file-browser-commands.ts b/packages/ui/src/modules/storage/file-browser-commands.ts
new file mode 100644
index 00000000..e799cbc0
--- /dev/null
+++ b/packages/ui/src/modules/storage/file-browser-commands.ts
@@ -0,0 +1,141 @@
+import type { LucideIcon } from 'lucide-react';
+import type { Action } from '@/actions/types';
+import type { SortKey } from '@/modules/storage/types';
+
+/**
+ * Zustand {@link Action} ids — use {@link vfsPanelActionId} so paths stay consistent.
+ *
+ * **Testing:**
+ * - Store `id` includes `panelId` → unique per panel; use `getActionsByPath(\`vfs/panel/${id}/\`)` in unit tests when you control the panel id.
+ * - **E2E:** prefer stable **`data-testid`** on ribbon/context items using {@link vfsActionTestId} (no panel segment) so selectors do not depend on dynamic panel ids.
+ */
+export const VFS_ACTION_PREFIX = 'vfs/panel' as const;
+
+/** Stable slug (last path segment) for each command — use in tests and {@link vfsPanelActionId}. */
+export const VfsActionSlug = {
+ navigateUp: 'navigate-up',
+ refresh: 'refresh',
+ open: 'open',
+ download: 'download',
+ copy: 'copy',
+ sort: 'sort',
+ viewList: 'view-list',
+ viewThumbs: 'view-thumbs',
+ viewTree: 'view-tree',
+ zoomIn: 'zoom-in',
+ zoomOut: 'zoom-out',
+ toggleExplorer: 'toggle-explorer',
+ togglePreview: 'toggle-preview',
+ filter: 'filter',
+ search: 'search',
+ clearSearch: 'clear-search',
+ /** Krusader-style: new tab on this side, cloned from current folder */
+ newTab: 'new-tab',
+ /** Switch file browser to dual-pane layout */
+ dualLayout: 'dual-layout',
+ /** Back to single-pane layout */
+ singleLayout: 'single-layout',
+ /** Mirror left navigation to right pane (dual mode) */
+ linkPanes: 'link-panes',
+ /** In-app fullscreen: hide app nav/footer for the file browser shell */
+ appFullscreen: 'app-fullscreen',
+} as const;
+
+export type VfsActionSlugKey = keyof typeof VfsActionSlug;
+
+/** Full action id in the global store (unique per panel). */
+export function vfsPanelActionId(panelId: string, slug: string): string {
+ return `${VFS_ACTION_PREFIX}/${panelId}/${slug}`;
+}
+
+/**
+ * Stable `data-testid` for DOM (ribbon, menus) — **no** panelId, safe for e2e.
+ * Pattern: `vfs-action-`
+ */
+export function vfsActionTestId(slug: string): string {
+ return `vfs-action-${slug.replace(/[^a-z0-9-]/gi, '_')}`;
+}
+
+/**
+ * Primitive signature for Zustand selectors — subscribe to this instead of the full `actions` map
+ * so unrelated store updates and register/unregister cycles do not re-render every consumer.
+ */
+export function vfsPanelActionStoreSignature(
+ actions: Record,
+ panelId: string,
+ visibility: 'Ribbon' | 'ContextMenu',
+): string {
+ const prefix = `${VFS_ACTION_PREFIX}/${panelId}/`;
+ return Object.values(actions)
+ .filter((a) => {
+ if (!a.id.startsWith(prefix)) return false;
+ if (visibility === 'Ribbon') return a.visibilities?.Ribbon !== false;
+ return a.visibilities?.ContextMenu !== false;
+ })
+ .map(
+ (a) =>
+ `${a.id}:${String(a.disabled)}:${String(a.metadata?.active ?? '')}:${String(a.metadata?.ribbonTab ?? '')}`,
+ )
+ .sort()
+ .join('|');
+}
+
+/** Which ribbon tab an action belongs to (Explorer-style Home vs View). */
+export type VfsRibbonTabId = 'home' | 'view';
+
+/**
+ * Command surface for the active file browser panel — consumed by {@link FileBrowserRibbonBar}
+ * and (later) context menus and plugin automation. Keep fields stable; extend with optional blocks.
+ */
+export interface FileBrowserPanelCommandApi {
+ panelId: string;
+ mount: string;
+ path: string;
+ selectionCount: number;
+
+ canGoUp: boolean;
+ goUp: () => void;
+ refresh: () => void;
+
+ canOpen: boolean;
+ openSelected: () => void;
+
+ allowDownload: boolean;
+ download: () => void;
+ downloadFolder?: () => void;
+
+ canCopy: boolean;
+ copy: () => void;
+
+ sortBy: SortKey;
+ sortAsc: boolean;
+ cycleSort: () => void;
+
+ viewMode: 'list' | 'thumbs' | 'tree';
+ setViewMode: (m: 'list' | 'thumbs' | 'tree') => void;
+
+ zoomIn: () => void;
+ zoomOut: () => void;
+
+ showExplorer: boolean;
+ toggleExplorer: () => void;
+ showPreview: boolean;
+ togglePreview: () => void;
+
+ openFilter: () => void;
+ openSearch: () => void;
+ isSearchMode: boolean;
+ clearSearch: () => void;
+}
+
+/** Future: extension commands (open-with, automation pipes) keyed by group id */
+export interface FileBrowserPluginCommand {
+ id: string;
+ label: string;
+ icon?: LucideIcon;
+ /** Ribbon group id, e.g. "automate" | "open-with" */
+ group: string;
+ order?: number;
+ disabled?: boolean;
+ run: (api: FileBrowserPanelCommandApi) => void | Promise;
+}
diff --git a/packages/ui/src/modules/storage/hooks/useDefaultKeyboardHandler.ts b/packages/ui/src/modules/storage/hooks/useDefaultKeyboardHandler.ts
index 450122ff..c992b13d 100644
--- a/packages/ui/src/modules/storage/hooks/useDefaultKeyboardHandler.ts
+++ b/packages/ui/src/modules/storage/hooks/useDefaultKeyboardHandler.ts
@@ -52,6 +52,7 @@ export interface UseDefaultKeyboardHandlerProps {
onSelect?: (nodes: INode[] | INode | null) => void;
sorted: INode[];
onCopyRequest?: () => void;
+ onOpenContextMenu?: () => void;
}
export function useDefaultKeyboardHandler({
@@ -87,6 +88,7 @@ export function useDefaultKeyboardHandler({
onSelect,
sorted,
onCopyRequest,
+ onOpenContextMenu,
}: UseDefaultKeyboardHandlerProps) {
// ── Search state ─────────────────────────────────────────────
@@ -169,6 +171,7 @@ export function useDefaultKeyboardHandler({
setSearchDisplay,
clearSelection,
onCopyRequest,
+ onOpenContextMenu,
});
// ── Focus management on view mode change ─────────────────────
diff --git a/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts b/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts
index 51d75b40..9ad10394 100644
--- a/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts
+++ b/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts
@@ -28,6 +28,8 @@ export interface UseKeyboardNavigationProps {
setSearchDisplay: (val: string) => void;
clearSelection: () => void;
onCopyRequest?: () => void;
+ /** Shift+F10: open row context menu (list/thumbs; tree handles in FileTree). */
+ onOpenContextMenu?: () => void;
}
export function useKeyboardNavigation({
@@ -55,7 +57,8 @@ export function useKeyboardNavigation({
searchBufferRef,
setSearchDisplay,
clearSelection,
- onCopyRequest
+ onCopyRequest,
+ onOpenContextMenu
}: UseKeyboardNavigationProps) {
const moveFocus = useCallback((next: number, shift: boolean, ctrl: boolean) => {
@@ -111,7 +114,7 @@ export function useKeyboardNavigation({
// In tree mode, we want to let FileTree handle most of the arrow keys and selections
// so we don't accidentally synchronize the fallback grid's focusIdx.
// We only want to handle global hotkeys like toggle view mode.
- if (viewMode === 'tree' && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', ' ', 'Backspace', 'Home', 'End'].includes(e.key)) {
+ if (viewMode === 'tree' && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', ' ', 'Backspace', 'Home', 'End', 'F10'].includes(e.key)) {
return;
}
@@ -246,6 +249,13 @@ export function useKeyboardNavigation({
}
break;
}
+ case 'F10': {
+ if (e.shiftKey && onOpenContextMenu) {
+ e.preventDefault();
+ onOpenContextMenu();
+ }
+ break;
+ }
case 'F1': {
if (e.altKey) {
e.preventDefault();
@@ -333,7 +343,8 @@ export function useKeyboardNavigation({
}, [
viewMode, itemCount, getGridCols, currentGlob, showFolders, setTempGlob, setTempShowFolders,
setFilterDialogOpen, moveFocus, focusIdx, searchBufferRef, getNode, setSearchDisplay, goUp,
- updatePath, openPreview, selected, setSelected, clearSelection, setViewMode, setDisplayMode, cycleSort, onCopyRequest
+ updatePath, openPreview, selected, setSelected, clearSelection, setViewMode, setDisplayMode, cycleSort, onCopyRequest,
+ onOpenContextMenu
]);
return { handleKeyDown, moveFocus };
diff --git a/packages/ui/src/modules/storage/hooks/useSelection.ts b/packages/ui/src/modules/storage/hooks/useSelection.ts
index 8331b9d5..e864636e 100644
--- a/packages/ui/src/modules/storage/hooks/useSelection.ts
+++ b/packages/ui/src/modules/storage/hooks/useSelection.ts
@@ -60,6 +60,20 @@ export function useSelection({ sorted, canGoUp, onSelect }: UseSelectionProps) {
setFocusIdx(-1);
}, []);
+ /** Right-click: focus row; if target is not in the current selection, reduce selection to that item (Explorer-style). */
+ const handleItemContextMenu = useCallback((idx: number) => {
+ setFocusIdx(idx);
+ const node = getNode(idx);
+ if (!node) {
+ setSelected([]);
+ return;
+ }
+ setSelected((prev) => {
+ if (prev.some((n) => n.path === node.path)) return prev;
+ return [node];
+ });
+ }, [getNode]);
+
return {
focusIdx,
setFocusIdx,
@@ -68,6 +82,7 @@ export function useSelection({ sorted, canGoUp, onSelect }: UseSelectionProps) {
itemCount,
getNode,
handleItemClick,
+ handleItemContextMenu,
clearSelection
};
}
diff --git a/packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts b/packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts
new file mode 100644
index 00000000..c6e01959
--- /dev/null
+++ b/packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts
@@ -0,0 +1,277 @@
+import { useEffect } from 'react';
+import {
+ ArrowUp,
+ RefreshCw,
+ Copy,
+ FolderOpen,
+ Download,
+ Filter,
+ Search,
+ X,
+ List,
+ LayoutGrid,
+ Network,
+ ZoomIn,
+ ZoomOut,
+ PanelLeft,
+ PanelRight,
+ ArrowUpDown,
+ Plus,
+ Columns2,
+ Square,
+ Link,
+ Unlink,
+ Maximize2,
+ Minimize2,
+} from 'lucide-react';
+import { useActionStore } from '@/actions/store';
+import type { Action } from '@/actions/types';
+import {
+ vfsPanelActionId,
+ vfsActionTestId,
+ VfsActionSlug,
+ type VfsRibbonTabId,
+} from '@/modules/storage/file-browser-commands';
+import type { SortKey } from '@/modules/storage/types';
+
+export interface VfsPanelActionSpec {
+ canGoUp: boolean;
+ goUp: () => void;
+ refresh: () => void;
+ canOpen: boolean;
+ openSelected: () => void;
+ allowDownload: boolean;
+ download: () => void;
+ downloadFolder?: () => void;
+ canCopy: boolean;
+ copy: () => void;
+ sortBy: SortKey;
+ sortAsc: boolean;
+ cycleSort: () => void;
+ viewMode: 'list' | 'thumbs' | 'tree';
+ setViewMode: (m: 'list' | 'thumbs' | 'tree') => void;
+ zoomIn: () => void;
+ zoomOut: () => void;
+ showExplorer: boolean;
+ toggleExplorer: () => void;
+ showPreview: boolean;
+ togglePreview: () => void;
+ openFilter: () => void;
+ openSearch: () => void;
+ isSearchMode: boolean;
+ clearSearch: () => void;
+
+ /** Multi-tab / layout — ribbon-only on desktop when `allowPanels`; no duplicate LayoutToolbar. */
+ allowPanels?: boolean;
+ addNewTab?: () => void;
+ switchToDualLayout?: () => void;
+ switchToSingleLayout?: () => void;
+ toggleLinkedPanes?: () => void;
+ linked?: boolean;
+ layout?: 'single' | 'dual';
+
+ /** In-app fullscreen (app shell hidden) — not browser Fullscreen API */
+ fileBrowserImmersive?: boolean;
+ toggleFileBrowserImmersive?: () => void;
+}
+
+function reg(
+ panelId: string,
+ slug: string,
+ label: string,
+ group: string,
+ icon: Action['icon'],
+ handler: () => void,
+ opts: { disabled?: boolean; ribbonTab: VfsRibbonTabId; active?: boolean },
+): Action {
+ const id = vfsPanelActionId(panelId, slug);
+ return {
+ id,
+ label,
+ group,
+ icon,
+ handler,
+ disabled: opts.disabled,
+ visibilities: { Ribbon: true, ContextMenu: true },
+ metadata: {
+ testId: vfsActionTestId(slug),
+ ribbonTab: opts.ribbonTab,
+ ...(opts.active !== undefined ? { active: opts.active } : {}),
+ },
+ };
+}
+
+/**
+ * Registers Zustand {@link Action}s for one panel (prefix `vfs/panel//`).
+ * Re-registers when `spec` changes (pass a memoized spec from the panel).
+ */
+export function useRegisterVfsPanelActions(panelId: string | undefined, enabled: boolean, spec: VfsPanelActionSpec): void {
+ useEffect(() => {
+ if (!panelId || !enabled) return;
+
+ const { registerAction, unregisterAction } = useActionStore.getState();
+
+ const ids: string[] = [];
+ const push = (a: Action) => {
+ ids.push(a.id);
+ registerAction(a);
+ };
+
+ push(
+ reg(panelId, VfsActionSlug.navigateUp, 'Up', 'VFS · Navigate', ArrowUp, spec.goUp, {
+ disabled: !spec.canGoUp,
+ ribbonTab: 'home',
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.refresh, 'Refresh', 'VFS · Navigate', RefreshCw, spec.refresh, {
+ ribbonTab: 'home',
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.copy, 'Copy', 'VFS · Clipboard', Copy, spec.copy, {
+ disabled: !spec.canCopy,
+ ribbonTab: 'home',
+ }),
+ );
+ if (spec.allowPanels && spec.switchToSingleLayout) {
+ push(
+ reg(panelId, VfsActionSlug.singleLayout, 'Single', 'VFS · Layout', Square, spec.switchToSingleLayout, {
+ disabled: spec.layout === 'single',
+ ribbonTab: 'home',
+ }),
+ );
+ }
+ if (spec.allowPanels && spec.switchToDualLayout) {
+ push(
+ reg(panelId, VfsActionSlug.dualLayout, 'Dual', 'VFS · Layout', Columns2, spec.switchToDualLayout, {
+ disabled: spec.layout === 'dual',
+ ribbonTab: 'home',
+ }),
+ );
+ }
+ if (spec.allowPanels && spec.toggleLinkedPanes) {
+ const LinkIcon = spec.linked ? Unlink : Link;
+ push(
+ reg(
+ panelId,
+ VfsActionSlug.linkPanes,
+ spec.linked ? 'Linked' : 'Link',
+ 'VFS · Layout',
+ LinkIcon,
+ spec.toggleLinkedPanes,
+ {
+ disabled: spec.layout !== 'dual',
+ ribbonTab: 'home',
+ active: spec.linked === true,
+ },
+ ),
+ );
+ }
+ if (spec.allowPanels && spec.addNewTab) {
+ push(
+ reg(panelId, VfsActionSlug.newTab, 'New tab', 'VFS · Layout', Plus, spec.addNewTab, {
+ ribbonTab: 'home',
+ }),
+ );
+ }
+ if (spec.toggleFileBrowserImmersive) {
+ const FsIcon = spec.fileBrowserImmersive ? Minimize2 : Maximize2;
+ push(
+ reg(
+ panelId,
+ VfsActionSlug.appFullscreen,
+ spec.fileBrowserImmersive ? 'Exit full screen' : 'Full screen',
+ 'VFS · Window',
+ FsIcon,
+ spec.toggleFileBrowserImmersive,
+ {
+ ribbonTab: 'home',
+ active: spec.fileBrowserImmersive === true,
+ },
+ ),
+ );
+ }
+ push(
+ reg(panelId, VfsActionSlug.open, 'Open', 'VFS · Open', FolderOpen, spec.openSelected, {
+ disabled: !spec.canOpen,
+ ribbonTab: 'home',
+ }),
+ );
+ push(
+ reg(panelId, 'download', 'Download', 'VFS · Open', Download, spec.download, {
+ disabled: !spec.allowDownload,
+ ribbonTab: 'home',
+ }),
+ );
+ if (spec.downloadFolder) {
+ push(
+ reg(panelId, 'download-folder', 'Download folder', 'VFS · Open', Download, spec.downloadFolder, {
+ disabled: !spec.allowDownload,
+ ribbonTab: 'home',
+ }),
+ );
+ }
+ push(
+ reg(panelId, VfsActionSlug.filter, 'Filter', 'VFS · Organize', Filter, spec.openFilter, {
+ ribbonTab: 'home',
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.search, 'Search', 'VFS · Organize', Search, spec.openSearch, {
+ ribbonTab: 'home',
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.clearSearch, 'Clear search', 'VFS · Organize', X, spec.clearSearch, {
+ disabled: !spec.isSearchMode,
+ ribbonTab: 'home',
+ }),
+ );
+
+ push(
+ reg(panelId, VfsActionSlug.viewList, 'List', 'VFS · Layout', List, () => spec.setViewMode('list'), {
+ ribbonTab: 'view',
+ active: spec.viewMode === 'list',
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.viewThumbs, 'Thumbnails', 'VFS · Layout', LayoutGrid, () => spec.setViewMode('thumbs'), {
+ ribbonTab: 'view',
+ active: spec.viewMode === 'thumbs',
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.viewTree, 'Tree', 'VFS · Layout', Network, () => spec.setViewMode('tree'), {
+ ribbonTab: 'view',
+ active: spec.viewMode === 'tree',
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.toggleExplorer, 'Explorer pane', 'VFS · Side panes', PanelLeft, spec.toggleExplorer, {
+ ribbonTab: 'view',
+ active: spec.showExplorer,
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.togglePreview, 'Preview pane', 'VFS · Side panes', PanelRight, spec.togglePreview, {
+ ribbonTab: 'view',
+ active: spec.showPreview,
+ }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.zoomIn, 'Zoom in', 'VFS · Zoom', ZoomIn, spec.zoomIn, { ribbonTab: 'view' }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.zoomOut, 'Zoom out', 'VFS · Zoom', ZoomOut, spec.zoomOut, { ribbonTab: 'view' }),
+ );
+ push(
+ reg(panelId, VfsActionSlug.sort, 'Sort', 'VFS · Sort', ArrowUpDown, spec.cycleSort, { ribbonTab: 'view' }),
+ );
+
+ return () => {
+ const { unregisterAction: unreg } = useActionStore.getState();
+ ids.forEach((id) => unreg(id));
+ };
+ }, [panelId, enabled, spec]);
+}
diff --git a/packages/ui/src/pages/PlaygroundVfs.tsx b/packages/ui/src/pages/PlaygroundVfs.tsx
index dac6e6d1..bbf4f61b 100644
--- a/packages/ui/src/pages/PlaygroundVfs.tsx
+++ b/packages/ui/src/pages/PlaygroundVfs.tsx
@@ -1,10 +1,12 @@
import React from 'react';
import FileBrowser from '@/modules/storage/FileBrowser';
+import { useAppStore } from '@/store/appStore';
import { FilePickerField, parseVfsStoredPath, type FilePickerResult } from '@/modules/storage';
import { Button } from '@/components/ui/button';
import { readVfsFileText, writeVfsFile } from '@/modules/storage/client-vfs';
const PlaygroundVfs = () => {
+ const fileBrowserImmersive = useAppStore((s) => s.fileBrowserImmersive);
const [pickedPath, setPickedPath] = React.useState('home:/');
const [saveAsPath, setSaveAsPath] = React.useState('home:/untitled.txt');
const [saveAsMeta, setSaveAsMeta] = React.useState(null);
@@ -42,8 +44,18 @@ const PlaygroundVfs = () => {
}, [saveAsMeta?.overwrite, saveAsPath]);
return (
-
-
+
+ {!fileBrowserImmersive && (
+
File Picker Test
@@ -80,13 +92,15 @@ const PlaygroundVfs = () => {
{writeStatus && {writeStatus}}
+ )}
diff --git a/packages/ui/src/store/appStore.ts b/packages/ui/src/store/appStore.ts
index 335152af..57a56b1a 100644
--- a/packages/ui/src/store/appStore.ts
+++ b/packages/ui/src/store/appStore.ts
@@ -3,9 +3,14 @@ import { create } from 'zustand';
interface AppState {
showGlobalFooter: boolean;
setShowGlobalFooter: (show: boolean) => void;
+ /** Hides app chrome (nav, footer, max-width) for file browser routes — in-app fullscreen, not browser Fullscreen API. */
+ fileBrowserImmersive: boolean;
+ setFileBrowserImmersive: (v: boolean) => void;
}
export const useAppStore = create
((set) => ({
showGlobalFooter: true,
setShowGlobalFooter: (show) => set({ showGlobalFooter: show }),
+ fileBrowserImmersive: false,
+ setFileBrowserImmersive: (v) => set({ fileBrowserImmersive: v }),
}));