This commit is contained in:
lovebird 2026-04-07 18:59:02 +02:00
parent 371b0e33e3
commit efeafbb725
17 changed files with 1250 additions and 131 deletions

View File

@ -119,7 +119,20 @@ const AppWrapper = () => {
const isPageEditor = location.pathname.includes('/pages/') && searchParams.get('edit') === 'true';
const isFullScreenPage = location.pathname.startsWith('/video-feed') || isPageEditor;
const containerClassName = isFullScreenPage
const isFileBrowserShellRoute =
location.pathname.startsWith('/app/filebrowser') ||
location.pathname.startsWith('/playground/vfs');
const fileBrowserImmersive = useAppStore((s) => s.fileBrowserImmersive);
const setFileBrowserImmersive = useAppStore((s) => s.setFileBrowserImmersive);
React.useEffect(() => {
if (!isFileBrowserShellRoute) setFileBrowserImmersive(false);
}, [location.pathname, isFileBrowserShellRoute, setFileBrowserImmersive]);
const hideAppChrome =
isFullScreenPage || (fileBrowserImmersive && isFileBrowserShellRoute);
const containerClassName = hideAppChrome
? "flex flex-col min-h-svh transition-colors duration-200 h-full"
: "mx-auto max-w-[1400px] flex flex-col min-h-svh transition-colors duration-200 h-full";
@ -128,9 +141,9 @@ const AppWrapper = () => {
const ecommerce = import.meta.env.VITE_ENABLE_ECOMMERCE === 'true';
return (
<div className={containerClassName}>
{!isFullScreenPage && <TopNavigation />}
{!hideAppChrome && <TopNavigation />}
<React.Suspense fallback={null}><GlobalDragDrop /></React.Suspense>
<main className="flex-1">
<main className={hideAppChrome ? "flex-1 flex flex-col min-h-0 overflow-hidden" : "flex-1"}>
<Routes>
{/* Top-level routes (no organization context) */}
<Route path="/" element={<Index />} />
@ -230,7 +243,7 @@ const AppWrapper = () => {
<Route path="*" element={<React.Suspense fallback={<div>Loading...</div>}><NotFound /></React.Suspense>} />
</Routes >
</main>
{!isFullScreenPage && showGlobalFooter && <Footer />}
{!hideAppChrome && showGlobalFooter && <Footer />}
</div >
);
};
@ -286,7 +299,7 @@ const App = () => {
<Toaster />
<Sonner />
<ActionProvider>
<BrowserRouter>
<BrowserRouter future={{ v7_relativeSplatPath: true }}>
<DragDropProvider>
<ProfilesProvider>
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>

View File

@ -1,13 +1,15 @@
import type { INode } from '@/modules/storage/types';
import React, { useEffect } from 'react';
import { useAppStore } from '@/store/appStore';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { T } from '@/i18n';
import { FileBrowserProvider, useFileBrowser } from './FileBrowserContext';
import { FileBrowserProvider, useFileBrowser, type FileBrowserChrome } from './FileBrowserContext';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { useIsMobile } from '@/hooks/use-mobile';
import LayoutToolbar from './LayoutToolbar';
import PanelSide from './PanelSide';
import FileBrowserRibbonBar from './FileBrowserRibbonBar';
/**
* Standalone FileBrowser page Krusader-style dual pane.
@ -16,7 +18,8 @@ import PanelSide from './PanelSide';
*/
const FileBrowserInner: React.FC<{ disableRoutingSync?: boolean, onSelect?: (node: INode | null, mount?: string) => void }> = ({ disableRoutingSync, onSelect }) => {
const { loading } = useAuth();
const { layout, activePanel, allowPanels } = useFileBrowser();
const { layout, activePanel, allowPanels, chrome } = useFileBrowser();
const fileBrowserImmersive = useAppStore((s) => s.fileBrowserImmersive);
const navigate = useNavigate();
const isMobile = useIsMobile();
const hasInitialSelectedRef = React.useRef(false);
@ -121,9 +124,20 @@ const FileBrowserInner: React.FC<{ disableRoutingSync?: boolean, onSelect?: (nod
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 56px)', overflow: 'hidden' }}>
{/* ═══ Layout Toolbar ═══════════════════════════ */}
{allowPanels && <LayoutToolbar />}
<div
style={{
display: 'flex',
flexDirection: 'column',
height: fileBrowserImmersive ? '100vh' : 'calc(100vh - 56px)',
minHeight: fileBrowserImmersive ? '100dvh' : undefined,
overflow: 'hidden',
}}
>
{/* Layout chrome: ribbon carries Single / Dual / Link / New tab on desktop; keep top bar only on mobile (no ribbon) or toolbar chrome. */}
{allowPanels && (chrome !== 'ribbon' || isMobile) && <LayoutToolbar />}
{/* Ribbon is desktop-first (tall + horizontal groups). On narrow viewports we fall back to FileBrowserToolbar in PanelSide. */}
{chrome === 'ribbon' && !isMobile && <FileBrowserRibbonBar />}
{/* ═══ Resizable Panes ══════════════════════════ */}
<ResizablePanelGroup direction={isMobile ? "vertical" : "horizontal"} style={{ flex: 1, overflow: 'hidden' }}>
@ -154,8 +168,9 @@ const FileBrowser: React.FC<{
index?: boolean,
disableRoutingSync?: boolean,
initialMount?: string,
initialChrome?: FileBrowserChrome,
onSelect?: (node: INode | null, mount?: string) => void
}> = ({ allowPanels, mode, index, disableRoutingSync, initialMount: propInitialMount, onSelect }) => {
}> = ({ allowPanels, mode, index, disableRoutingSync, initialMount: propInitialMount, initialChrome, onSelect }) => {
const location = useLocation();
let initialMount = propInitialMount;
@ -235,6 +250,7 @@ const FileBrowser: React.FC<{
initialShowFolders={initialShowFolders}
initialSearchQuery={initialSearchQuery}
initialSearchFullText={initialSearchFullText}
initialChrome={initialChrome}
>
<FileBrowserInner disableRoutingSync={disableRoutingSync} onSelect={onSelect} />
</FileBrowserProvider>

View File

@ -18,6 +18,7 @@ export interface PanelState {
export type Side = 'left' | 'right';
export type LayoutMode = 'single' | 'dual';
export type FileBrowserChrome = 'toolbar' | 'ribbon';
// ── Context Shape ────────────────────────────────────────────────
@ -64,8 +65,12 @@ interface FileBrowserContextType {
/** File to auto-select on initial load */
initialFile?: string;
initialSearchFullText?: boolean;
/** Compact toolbar vs Explorer-style ribbon (ribbon hides per-panel FileBrowserToolbar). */
chrome: FileBrowserChrome;
setChrome: (c: FileBrowserChrome) => void;
}
const FileBrowserContext = createContext<FileBrowserContextType | undefined>(undefined);
@ -110,7 +115,8 @@ export const FileBrowserProvider: React.FC<{
initialShowPreview?: boolean;
initialSearchQuery?: string;
initialSearchFullText?: boolean;
}> = ({ children, initialMount, initialPath, initialViewMode, initialShowToolbar, initialAllowPanels, initialMode, initialFile, initialIndex, initialGlob, initialShowFolders, initialShowExplorer, initialShowPreview, initialSearchQuery, initialSearchFullText }) => {
initialChrome?: FileBrowserChrome;
}> = ({ children, initialMount, initialPath, initialViewMode, initialShowToolbar, initialAllowPanels, initialMode, initialFile, initialIndex, initialGlob, initialShowFolders, initialShowExplorer, initialShowPreview, initialSearchQuery, initialSearchFullText, initialChrome }) => {
const [layout, setLayout] = useState<LayoutMode>('single');
const [linked, setLinked] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'thumbs' | 'tree'>(initialViewMode || 'list');
@ -131,6 +137,7 @@ export const FileBrowserProvider: React.FC<{
const [rightPanels, setRightPanels] = useState<PanelState[]>([createPanel()]);
const [activeSide, setActiveSide] = useState<Side>('left');
const [activePanelIdx, setActivePanelIdx] = useState(0);
const [chrome, setChrome] = useState<FileBrowserChrome>(initialChrome ?? 'toolbar');
const setActivePanel = useCallback((side: Side, idx: number) => {
setActiveSide(side);
@ -151,8 +158,15 @@ export const FileBrowserProvider: React.FC<{
}, [linked, activePanelIdx]);
const addPanel = useCallback((side: Side, initial?: Partial<PanelState>) => {
let newIdx = 0;
const setter = side === 'left' ? setLeftPanels : setRightPanels;
setter(prev => [...prev, createPanel(initial)]);
setter(prev => {
const next = [...prev, createPanel(initial)];
newIdx = next.length - 1;
return next;
});
setActiveSide(side);
setActivePanelIdx(newIdx);
}, []);
const removePanel = useCallback((side: Side, idx: number) => {
@ -186,6 +200,8 @@ export const FileBrowserProvider: React.FC<{
index, setIndex,
initialFile,
initialSearchFullText,
chrome,
setChrome,
}}>
{children}
</FileBrowserContext.Provider>
@ -199,3 +215,5 @@ export const useFileBrowser = () => {
if (!ctx) throw new Error('useFileBrowser must be used within FileBrowserProvider');
return ctx;
};
export const useOptionalFileBrowser = () => useContext(FileBrowserContext);

View File

@ -40,6 +40,11 @@ import { useDefaultSelectionHandler } from '@/modules/storage/hooks/useDefaultSe
import { useDefaultActions } from '@/modules/storage/hooks/useDefaultActions';
import { FileTree } from './FileTree';
import SearchDialog from './SearchDialog';
import { useActionStore } from '@/actions/store';
import { useOptionalFileBrowser, type Side } from '@/modules/storage/FileBrowserContext';
import { VFS_ACTION_PREFIX, vfsPanelActionStoreSignature } from '@/modules/storage/file-browser-commands';
import { useRegisterVfsPanelActions, type VfsPanelActionSpec } from '@/modules/storage/useRegisterVfsPanelActions';
import { useAppStore } from '@/store/appStore';
// ── Props ────────────────────────────────────────────────────────
@ -84,6 +89,10 @@ export interface FileBrowserPanelProps {
onLayoutChange?: (sizes: number[], direction: 'horizontal' | 'vertical') => void;
showStatusBar?: boolean;
allowCopyAction?: boolean;
/** Stable id for Zustand ribbon actions (`vfs/panel/<panelId>/…`). */
panelId?: string;
/** Which Krusader-style pane this instance is (left/right); used for “New tab” / panel actions. */
browserSide?: Side;
}
// ── Main Component ───────────────────────────────────────────────
@ -125,9 +134,12 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
onLayoutChange,
showStatusBar = true,
allowCopyAction = true,
panelId,
browserSide,
}) => {
const { session } = useAuth();
const fileBrowserCtx = useOptionalFileBrowser();
const accessToken = session?.access_token;
const [readmeContent, setReadmeContent] = useState<string | null>(null);
@ -457,6 +469,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
itemCount,
getNode,
handleItemClick,
handleItemContextMenu,
clearSelection
} = useSelection({
sorted: displayNodes,
@ -556,6 +569,28 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
listRef,
});
/** Context menu actions (before keyboard hook — used by Shift+F10 for list/thumbs). */
const vfsContextMenuSig = useActionStore((s) =>
(panelId ? vfsPanelActionStoreSignature(s.actions, panelId, 'ContextMenu') : ''),
);
const vfsContextMenuActions = useMemo(() => {
if (!panelId) return [];
const prefix = `${VFS_ACTION_PREFIX}/${panelId}/`;
return Object.values(useActionStore.getState().actions)
.filter((a) => a.id.startsWith(prefix) && a.visibilities?.ContextMenu !== false)
.sort((a, b) => (a.group || '').localeCompare(b.group || '') || a.label.localeCompare(b.label));
}, [panelId, vfsContextMenuSig]);
const openContextMenuFromKeyboard = useCallback(() => {
if (!vfsContextMenuActions.length || viewMode === 'tree') return;
handleItemContextMenu(focusIdx);
const el = listRef.current?.querySelector(`[data-fb-idx="${focusIdx}"]`) as HTMLElement | null;
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 }));
}
}, [vfsContextMenuActions, viewMode, handleItemContextMenu, focusIdx]);
// ── Default Keyboard Handler (uses wrapped goUp) ─────────────
const {
@ -599,6 +634,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
onSelect,
sorted: displayNodes,
onCopyRequest: copyTransfer.handleCopyRequest,
onOpenContextMenu: openContextMenuFromKeyboard,
});
// ── Default Actions ──────────────────────────────────────────
@ -642,6 +678,117 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
goUp,
});
const allowPanels = fileBrowserCtx?.allowPanels ?? false;
const rightPanels = fileBrowserCtx?.rightPanels;
const activeSide = fileBrowserCtx?.activeSide;
const addPanel = fileBrowserCtx?.addPanel;
const setLayoutMode = fileBrowserCtx?.setLayout;
const layoutMode = fileBrowserCtx?.layout;
const linked = fileBrowserCtx?.linked;
const setLinked = fileBrowserCtx?.setLinked;
const addNewTab = useCallback(() => {
if (!allowPanels || !browserSide || !addPanel) return;
// Always open the new tab in the right pane (Krusader-style); show dual layout if needed.
setLayoutMode?.('dual');
addPanel('right', {
mount,
path: pathProp ?? '/',
glob: actualCurrentGlob,
showFolders,
showExplorer: showExplorer !== false,
showPreview: showPreview !== false,
searchQuery: undefined,
searchFullText: false,
selected: [],
});
}, [allowPanels, browserSide, addPanel, setLayoutMode, mount, pathProp, actualCurrentGlob, showFolders, showExplorer, showPreview]);
const switchToDualLayout = useCallback(() => {
setLayoutMode?.('dual');
}, [setLayoutMode]);
const switchToSingleLayout = useCallback(() => {
setLayoutMode?.('single');
}, [setLayoutMode]);
const toggleLinkedPanes = useCallback(() => {
if (layoutMode !== 'dual' || !setLinked) return;
setLinked(!linked);
}, [layoutMode, linked, setLinked]);
/** Copy To dialog: in dual layout, default destination to the right pane when the left pane initiates copy. */
const copyToInitialValue = useMemo(() => {
const base = `${mount}:${currentPath || '/'}`;
if (!fileBrowserCtx || layoutMode !== 'dual' || !rightPanels?.length) {
return base;
}
if (activeSide === 'left') {
const rp = rightPanels[0];
return `${rp.mount}:${rp.path || '/'}`;
}
return base;
}, [fileBrowserCtx, layoutMode, rightPanels, activeSide, mount, currentPath]);
const fileBrowserImmersive = useAppStore((s) => s.fileBrowserImmersive);
const toggleFileBrowserImmersive = useCallback(() => {
const cur = useAppStore.getState().fileBrowserImmersive;
useAppStore.getState().setFileBrowserImmersive(!cur);
}, []);
const vfsSpec = useMemo<VfsPanelActionSpec>(() => ({
canGoUp,
goUp,
refresh: () => { fetchDir(currentPath || '/'); },
canOpen: !!selectedFile,
openSelected: handleView,
allowDownload: allowDownload && selected.length > 0,
download: handleDownload,
downloadFolder: allowDownload ? handleDownloadDir : undefined,
canCopy: copyEnabled,
copy: () => { void copyTransfer.handleCopyRequest(); },
sortBy,
sortAsc,
cycleSort,
viewMode,
setViewMode,
zoomIn,
zoomOut,
showExplorer: showExplorer !== false,
toggleExplorer: onToggleExplorer ?? (() => {}),
showPreview: showPreview !== false,
togglePreview: onTogglePreview ?? (() => {}),
openFilter: () => {
setTempGlob(currentGlob);
setTempShowFolders(showFolders);
setFilterDialogOpen(true);
},
openSearch: () => { setSearchOpen(true); },
isSearchMode,
clearSearch: () => { onSearchQueryChange?.(''); },
allowPanels,
addNewTab: allowPanels && browserSide ? addNewTab : undefined,
switchToDualLayout: allowPanels ? switchToDualLayout : undefined,
switchToSingleLayout: allowPanels ? switchToSingleLayout : undefined,
toggleLinkedPanes: allowPanels ? toggleLinkedPanes : undefined,
linked: linked ?? false,
layout: layoutMode,
fileBrowserImmersive,
toggleFileBrowserImmersive: fileBrowserCtx ? toggleFileBrowserImmersive : undefined,
}), [
canGoUp, goUp, fetchDir, currentPath, selectedFile, handleView, allowDownload, selected.length,
handleDownload, handleDownloadDir, copyEnabled,
copyTransfer.handleCopyRequest,
sortBy, sortAsc, cycleSort,
viewMode, setViewMode, zoomIn, zoomOut, showExplorer, onToggleExplorer, showPreview, onTogglePreview,
currentGlob, showFolders, isSearchMode, onSearchQueryChange, setSearchOpen,
allowPanels, browserSide, addNewTab, switchToDualLayout, switchToSingleLayout, toggleLinkedPanes, linked, layoutMode,
fileBrowserImmersive, fileBrowserCtx, toggleFileBrowserImmersive,
]);
const vfsActionsEnabled = Boolean(panelId && fileBrowserCtx);
useRegisterVfsPanelActions(panelId, vfsActionsEnabled, vfsSpec);
return (
<div
ref={containerRef}
@ -797,6 +944,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
openPreview(n);
}
}}
vfsContextMenuActions={vfsContextMenuActions}
/>
</div>
) : viewMode === 'list' ? (
@ -815,6 +963,8 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
mode={currentMode}
searchBuffer={isSearchMode ? (searchQuery || searchDisplay) : searchDisplay}
isSearchMode={isSearchMode}
vfsContextMenuActions={vfsContextMenuActions}
onVfsContextMenuOpen={handleItemContextMenu}
/>
</div>
) : (
@ -834,6 +984,8 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
tokenParam={tokenParam}
fontSize={fontSize}
isSearchMode={isSearchMode}
vfsContextMenuActions={vfsContextMenuActions}
onVfsContextMenuOpen={handleItemContextMenu}
/>
</div>
)}
@ -1085,7 +1237,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
title={translate('Copy To')}
confirmLabel={copyTransfer.copyBusy ? translate('Copying...') : translate('Copy')}
confirmDisabled={copyTransfer.copyBusy}
initialValue={`${mount}:${currentPath || '/'}`}
initialValue={copyToInitialValue}
initialMask="*.*"
onConfirm={copyTransfer.handleCopyConfirm}
extensionSlot={

View File

@ -0,0 +1,286 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ChevronDown, HardDrive } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useActionStore } from '@/actions/store';
import { useFileBrowser } from '@/modules/storage/FileBrowserContext';
import { VFS_ACTION_PREFIX, vfsPanelActionStoreSignature, type VfsRibbonTabId } from '@/modules/storage/file-browser-commands';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { Action } from '@/actions/types';
import { translate } from '@/i18n';
/** Tailwind icon colors aligned with {@link PageRibbonBar} ribbon items. */
function vfsIconColor(action: Action): string {
const slug = action.id.split('/').pop() ?? '';
const bySlug: Record<string, string> = {
'navigate-up': 'text-blue-600 dark:text-blue-400',
refresh: 'text-sky-600 dark:text-sky-400',
copy: 'text-purple-600 dark:text-purple-400',
open: 'text-emerald-600 dark:text-emerald-400',
download: 'text-teal-600 dark:text-teal-400',
'download-folder': 'text-cyan-600 dark:text-cyan-400',
filter: 'text-amber-600 dark:text-amber-400',
search: 'text-orange-600 dark:text-orange-400',
'clear-search': 'text-rose-500 dark:text-rose-400',
'view-list': 'text-blue-600 dark:text-blue-400',
'view-thumbs': 'text-violet-600 dark:text-violet-400',
'view-tree': 'text-green-600 dark:text-green-400',
'toggle-explorer': 'text-indigo-600 dark:text-indigo-400',
'toggle-preview': 'text-indigo-500 dark:text-indigo-300',
'zoom-in': 'text-slate-700 dark:text-slate-300',
'zoom-out': 'text-slate-700 dark:text-slate-300',
sort: 'text-fuchsia-600 dark:text-fuchsia-400',
'new-tab': 'text-green-600 dark:text-green-400',
'dual-layout': 'text-cyan-600 dark:text-cyan-400',
'single-layout': 'text-slate-600 dark:text-slate-400',
'link-panes': 'text-orange-600 dark:text-orange-400',
'app-fullscreen': 'text-sky-600 dark:text-sky-400',
};
return bySlug[slug] ?? 'text-blue-600 dark:text-blue-400';
}
const RibbonTab = ({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
className={cn(
'px-4 py-1 text-sm font-medium transition-all duration-200 border-t-2 border-transparent select-none',
active
? 'dark:bg-slate-800/50 text-primary border-t-blue-500 shadow-[0_4px_12px_-4px_rgba(0,0,0,0.1)]'
: 'text-muted-foreground hover:bg-background/40 hover:text-foreground',
)}
>
{children}
</button>
);
const RibbonGroup = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="flex flex-col h-full border-r border-border/40 px-2 last:border-r-0 relative group">
<div className="flex-1 flex items-center gap-1 justify-center px-1 min-h-[5.5rem]">
{children}
</div>
<div className="text-[10px] text-center text-muted-foreground/70 uppercase tracking-wider font-semibold select-none pb-1 transition-colors group-hover:text-muted-foreground">
{label}
</div>
</div>
);
const RibbonItemSmall = ({
icon: Icon,
label,
onClick,
active,
iconColor,
disabled = false,
testId,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick?: () => void;
active?: boolean;
iconColor?: string;
disabled?: boolean;
testId?: string;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
data-testid={testId}
className={cn(
'flex items-center gap-2 px-2 py-0.5 h-7 w-full text-left rounded-sm transition-colors text-xs font-medium',
!disabled && 'hover:bg-accent/60',
active && 'bg-blue-100/40 dark:bg-blue-900/10 text-blue-700 dark:text-blue-300',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
<Icon className={cn('h-4 w-4 shrink-0 transition-colors', iconColor ?? 'text-foreground')} />
<span className="truncate pr-1">{label}</span>
</button>
);
const CompactFlowGroup = ({
actions,
}: {
actions: {
id: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick?: () => void;
active?: boolean;
disabled?: boolean;
iconColor?: string;
testId?: string;
}[];
}) => (
<div className="grid grid-rows-3 grid-flow-col gap-1">
{actions.map((a) => (
<RibbonItemSmall
key={a.id}
icon={a.icon}
label={a.label}
onClick={a.onClick}
active={a.active}
disabled={a.disabled}
iconColor={a.iconColor}
testId={a.testId}
/>
))}
</div>
);
/**
* Explorer-style ribbon for the active file browser panel.
* Visual language matches {@link PageRibbonBar}.
*/
const FileBrowserRibbonBar: React.FC = () => {
const { session } = useAuth();
const accessToken = session?.access_token;
const { activePanel, activeSide, activePanelIdx, updatePanel, leftPanels, rightPanels } = useFileBrowser();
const [availableMounts, setAvailableMounts] = useState<string[]>([]);
useEffect(() => {
const headers: Record<string, string> = {};
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
fetch('/api/vfs/mounts', { headers })
.then((r) => (r.ok ? r.json() : []))
.then((mounts: { name: string }[]) => setAvailableMounts(mounts.map((m) => m.name)))
.catch(() => {});
}, [accessToken]);
const panelIdx = useMemo(() => {
const panels = activeSide === 'left' ? leftPanels : rightPanels;
return Math.min(activePanelIdx, Math.max(0, panels.length - 1));
}, [activeSide, activePanelIdx, leftPanels, rightPanels]);
const onSelectMount = (m: string) => {
updatePanel(activeSide, panelIdx, { mount: m, path: '/' });
setTimeout(() => {
document.querySelector<HTMLElement>('.fb-panel-container')?.focus({ preventScroll: true });
}, 50);
};
const vfsRibbonSig = useActionStore((s) =>
vfsPanelActionStoreSignature(s.actions, activePanel.id, 'Ribbon'),
);
const [tab, setTab] = useState<VfsRibbonTabId>('home');
const panelPrefix = `${VFS_ACTION_PREFIX}/${activePanel.id}/`;
const panelActions = useMemo(() => {
return Object.values(useActionStore.getState().actions).filter(
(a) =>
a.id.startsWith(panelPrefix) &&
a.visibilities?.Ribbon !== false &&
(a.metadata?.ribbonTab as VfsRibbonTabId | undefined) === tab,
);
}, [panelPrefix, tab, vfsRibbonSig]);
const groups = useMemo(() => {
const byGroup = new Map<string, Action[]>();
for (const a of panelActions) {
const g = a.group || 'Other';
if (!byGroup.has(g)) byGroup.set(g, []);
byGroup.get(g)!.push(a);
}
return Array.from(byGroup.entries()).sort(([a], [b]) => a.localeCompare(b));
}, [panelActions]);
return (
<div className="flex flex-col w-full border-b shadow-sm shrink-0 z-40" data-testid="file-browser-ribbon">
<div className="flex items-center border-b bg-muted/90 backdrop-blur-sm">
<div className="px-4 py-1.5 bg-gradient-to-r from-blue-600 to-blue-500 text-white text-xs font-bold tracking-widest shadow-sm">
FILES
</div>
<div className="flex-1 flex overflow-x-auto scrollbar-none pl-2">
<RibbonTab active={tab === 'home'} onClick={() => setTab('home')}>
HOME
</RibbonTab>
<RibbonTab active={tab === 'view'} onClick={() => setTab('view')}>
VIEW
</RibbonTab>
</div>
</div>
<div className="min-h-[7.5rem] flex items-stretch backdrop-blur supports-[backdrop-filter]:bg-background/60 shadow-inner px-0 overflow-x-auto scrollbar-custom">
{availableMounts.length > 1 && (
<RibbonGroup label={translate('Mount')}>
<div className="flex flex-col items-center justify-center w-full min-w-[7rem] px-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
id="fb-mount-trigger"
title={translate('Switch mount')}
className={cn(
'flex items-center gap-1.5 px-2 py-1.5 rounded-md border border-border/60 bg-background/80',
'text-xs font-semibold hover:bg-accent/60 transition-colors max-w-[200px] w-full justify-center',
)}
>
<HardDrive className="h-3.5 w-3.5 shrink-0 opacity-80" />
<span className="truncate">{activePanel.mount}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[160px] z-[250]">
{availableMounts.map((m) => (
<DropdownMenuItem
key={m}
onClick={() => onSelectMount(m)}
className={cn(m === activePanel.mount && 'font-semibold bg-accent')}
>
<HardDrive className="h-3 w-3 mr-2 opacity-60" />
{m}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</RibbonGroup>
)}
{groups.length === 0 ? (
<div className="flex items-center px-4 text-xs text-muted-foreground">No commands for this tab.</div>
) : (
groups.map(([groupName, items]) => (
<RibbonGroup key={groupName} label={groupName.replace(/^VFS · /, '')}>
<CompactFlowGroup
actions={items.map((action) => {
const Icon = action.icon;
const testId = action.metadata?.testId as string | undefined;
const active = action.metadata?.active === true;
return {
id: action.id,
icon: Icon,
label: action.label,
onClick: () => {
void action.handler?.();
},
active,
disabled: action.disabled,
iconColor: vfsIconColor(action),
testId,
};
})}
/>
</RibbonGroup>
))
)}
</div>
</div>
);
};
export default FileBrowserRibbonBar;

View File

@ -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 } from './helpers';
@ -22,111 +24,131 @@ interface FileGridViewProps {
tokenParam: string;
fontSize: number;
isSearchMode?: boolean;
vfsContextMenuActions?: Action[];
onVfsContextMenuOpen?: (idx: number) => void;
}
// ── Component ────────────────────────────────────────────────────
const FileGridView: React.FC<FileGridViewProps> = ({
listRef, sorted, canGoUp, goUp, focusIdx, setFocusIdx,
selected, onItemClick, onItemDoubleClick, thumbSize, mount, tokenParam, fontSize, isSearchMode
}) => (
<div ref={listRef as any} data-testid="file-grid-view" style={{
overflowY: 'auto', flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`,
gridAutoRows: 'max-content',
gap: 12, padding: 16, alignContent: 'start',
}}>
{canGoUp && !isSearchMode && (
<div data-fb-idx={0} onClick={() => 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',
}}>
<ArrowUp size={24} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontSize: fontSize }}>..</span>
</div>
)}
{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 ? (
<VfsContextMenuRow
key={stableKey}
actions={vfsContextMenuActions!}
onBeforeOpen={() => onVfsContextMenuOpen!(idx)}
>
{row}
</VfsContextMenuRow>
) : (
row
);
return (
<div key={node.path || node.name} data-fb-idx={idx}
data-testid="file-grid-node"
data-node-id={node.path || node.name}
onClick={(e) => onItemClick(idx, e)}
onDoubleClick={() => !_uploading && onItemDoubleClick(idx)}
className="fb-thumb" style={{
return (
<div ref={listRef as any} data-testid="file-grid-view" style={{
overflowY: 'auto', flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`,
gridAutoRows: 'max-content',
gap: 12, padding: 16, alignContent: 'start',
}}>
{canGoUp && !isSearchMode && wrapRow('fb-up', 0, (
<div data-fb-idx={0} onClick={() => 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,
}}>
<div style={{
width: '100%', aspectRatio: '1/1',
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 6, overflow: 'hidden',
borderWidth: isSelected ? 0 : 1,
borderColor: isSelected ? SELECTED_BORDER : 'transparent',
borderStyle: isSelected ? 'solid' : 'solid',
outline: isFocused ? `2px solid ${FOCUS_BORDER}` : 'none',
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',
position: 'relative'
}}>
{isDir ? <NodeIcon node={node} size={Math.max(24, Math.floor(thumbSize * 0.5))} /> : <ThumbPreview node={node} mount={mount} tokenParam={tokenParam} thumbSize={thumbSize} />}
{_uploading && (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, height: 6,
background: 'rgba(0,0,0,0.5)', overflow: 'hidden'
}}>
<div style={{
height: '100%', width: `${_progress || 0}%`,
background: 'var(--primary, #3b82f6)', transition: 'width 0.2s linear'
}} />
</div>
)}
{_error && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(239, 68, 68, 0.12)', borderRadius: 6,
}}>
<span style={{ fontSize: 11, color: '#ef4444', fontWeight: 600, textAlign: 'center', padding: 4 }}>{_error}</span>
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', alignItems: 'center', gap: 2 }}>
<span style={{
fontSize: fontSize, textAlign: 'center', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis',
display: '-webkit-box', WebkitLineClamp: isSearchMode ? 1 : 2, WebkitBoxOrient: 'vertical',
wordBreak: 'break-word', lineHeight: 1.3,
minHeight: isSearchMode ? fontSize * 1.3 : fontSize * 1.3 * 2,
}}>
{node.name}
</span>
{isSearchMode && (
<span style={{
fontSize: Math.max(10, fontSize * 0.8), color: 'var(--muted-foreground)', textAlign: 'center', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
}}>
{node.parent || '/'}
</span>
)}
</div>
<ArrowUp size={24} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontSize: fontSize }}>..</span>
</div>
);
})}
</div>
);
))}
{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, (
<div
data-fb-idx={idx}
data-testid="file-grid-node"
data-node-id={node.path || node.name}
onClick={(e) => 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,
}}>
<div style={{
width: '100%', aspectRatio: '1/1',
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 6, overflow: 'hidden',
borderWidth: isSelected ? 0 : 1,
borderColor: isSelected ? SELECTED_BORDER : 'transparent',
borderStyle: 'solid',
outline: isFocused ? `2px solid ${FOCUS_BORDER}` : 'none',
outlineOffset: '2px',
position: 'relative'
}}>
{isDir ? <NodeIcon node={node} size={Math.max(24, Math.floor(thumbSize * 0.5))} /> : <ThumbPreview node={node} mount={mount} tokenParam={tokenParam} thumbSize={thumbSize} />}
{_uploading && (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, height: 6,
background: 'rgba(0,0,0,0.5)', overflow: 'hidden'
}}>
<div style={{
height: '100%', width: `${_progress || 0}%`,
background: 'var(--primary, #3b82f6)', transition: 'width 0.2s linear'
}} />
</div>
)}
{_error && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(239, 68, 68, 0.12)', borderRadius: 6,
}}>
<span style={{ fontSize: 11, color: '#ef4444', fontWeight: 600, textAlign: 'center', padding: 4 }}>{_error}</span>
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', alignItems: 'center', gap: 2 }}>
<span style={{
fontSize: fontSize, textAlign: 'center', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis',
display: '-webkit-box', WebkitLineClamp: isSearchMode ? 1 : 2, WebkitBoxOrient: 'vertical',
wordBreak: 'break-word', lineHeight: 1.3,
minHeight: isSearchMode ? fontSize * 1.3 : fontSize * 1.3 * 2,
}}>
{node.name}
</span>
{isSearchMode && (
<span style={{
fontSize: Math.max(10, fontSize * 0.8), color: 'var(--muted-foreground)', textAlign: 'center', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
}}>
{node.parent || '/'}
</span>
)}
</div>
</div>
));
})}
</div>
);
};
export default FileGridView;

View File

@ -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<FileListViewProps> = ({
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 ? (
<VfsContextMenuRow
key={stableKey}
actions={vfsContextMenuActions!}
onBeforeOpen={() => onVfsContextMenuOpen!(idx)}
>
{row}
</VfsContextMenuRow>
) : (
row
);
return (
<div ref={listRef as any} data-testid="file-list-view" style={{ overflowY: 'auto', flex: 1, padding: 2 }}>
{canGoUp && !isSearchMode && (
{canGoUp && !isSearchMode && wrapRow('fb-up', 0, (
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp}
data-testid="file-list-node-up"
className="fb-row" style={{
@ -46,7 +67,7 @@ const FileListView: React.FC<FileListViewProps> = ({
<ArrowUp size={14} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontWeight: 500 }}>..</span>
</div>
)}
))}
{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<FileListViewProps> = ({
const isSelected = selected.some(sel => sel.path === node.path);
const { _uploading, _progress, _error } = node as any;
return (
<div key={node.path || node.name} data-fb-idx={idx}
return wrapRow(node.path || node.name, idx, (
<div data-fb-idx={idx}
data-testid="file-list-node"
data-node-id={node.path || node.name}
onClick={(e) => onItemClick(idx, e)}
@ -129,9 +150,10 @@ const FileListView: React.FC<FileListViewProps> = ({
</span>
)}
</div>
);
));
})}
</div>
);
);
};
export default FileListView;

View File

@ -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<INode[]>;
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<HTMLDivElement, FileTreeProps>(
({ 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<Record<string, ExpandState>>({});
const rows = useMemo(() => buildRows(data, canGoUp, expandMap), [data, canGoUp, expandMap]);
const [focusIdx, setFocusIdx] = useState(0);
@ -341,6 +345,33 @@ export const FileTree = React.forwardRef<HTMLDivElement, FileTreeProps>(
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 ? (
<VfsContextMenuRow
key={stableKey}
actions={vfsContextMenuActions!}
onBeforeOpen={() => prepareContextMenuRow(idx)}
>
{rowEl}
</VfsContextMenuRow>
) : (
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<HTMLDivElement, FileTreeProps>(
}
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<HTMLDivElement, FileTreeProps>(
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 (
<div
@ -532,11 +577,11 @@ export const FileTree = React.forwardRef<HTMLDivElement, FileTreeProps>(
{rows.map((row, idx) => {
const isSelected = selectedIds.has(row.id);
const isFocused = focusIdx === idx;
return (
return wrapTreeRow(row.id, idx, (
<div
key={row.id}
data-testid="file-tree-node"
data-node-id={row.id}
data-fb-idx={idx}
ref={(el) => { 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<HTMLDivElement, FileTreeProps>(
})()}
</span>
</div>
);
));
})}
</div>
);

View File

@ -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<PanelSideProps> = ({ 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<PanelSideProps> = ({ side }) => {
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<FileBrowserPanel
key={panel.id}
panelId={panel.id}
browserSide={side}
mount={panel.mount}
path={panel.path}
glob={panel.glob}
searchQuery={panel.searchQuery}
mode={mode}
viewMode={viewMode}
showToolbar={showToolbar}
showToolbar={showPanelToolbar}
canChangeMount={true}
allowFileViewer={true}
allowLightbox={true}

View File

@ -0,0 +1,72 @@
import React, { useMemo } from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import type { Action } from '@/actions/types';
import { cn } from '@/lib/utils';
function groupActions(actions: Action[]): [string, Action[]][] {
const m = new Map<string, Action[]>();
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 (
<ContextMenu onOpenChange={(open) => { if (open) onBeforeOpen(); }}>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="max-h-[min(70vh,480px)] overflow-y-auto z-[200]">
{grouped.map(([group, items], gi) => (
<React.Fragment key={group}>
{gi > 0 && <ContextMenuSeparator />}
<ContextMenuLabel className="text-[11px] text-muted-foreground font-medium normal-case tracking-normal">
{group.replace(/^VFS · /, '')}
</ContextMenuLabel>
{items.map((action) => {
const Icon = action.icon;
return (
<ContextMenuItem
key={action.id}
disabled={action.disabled}
data-testid={action.metadata?.testId as string | undefined}
onSelect={() => { void action.handler?.(); }}
className={cn(action.metadata?.active && 'bg-accent/70')}
>
{Icon ? <Icon className="mr-2 h-4 w-4 shrink-0" /> : null}
{action.label}
</ContextMenuItem>
);
})}
</React.Fragment>
))}
</ContextMenuContent>
</ContextMenu>
);
}

View File

@ -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-<slug>`
*/
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<string, Action>,
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<void>;
}

View File

@ -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 ─────────────────────

View File

@ -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 };

View File

@ -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
};
}

View File

@ -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/<panelId>/`).
* 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]);
}

View File

@ -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<string>('home:/');
const [saveAsPath, setSaveAsPath] = React.useState<string>('home:/untitled.txt');
const [saveAsMeta, setSaveAsMeta] = React.useState<FilePickerResult | null>(null);
@ -42,8 +44,18 @@ const PlaygroundVfs = () => {
}, [saveAsMeta?.overwrite, saveAsPath]);
return (
<div style={{ height: 'calc(100vh - 56px)', overflow: 'hidden', display: 'flex', flexDirection: 'column', gap: 12, padding: 12 }}>
<div className="border rounded-md p-3 bg-card/40 space-y-3">
<div
className={fileBrowserImmersive ? 'min-h-0 flex flex-col' : 'min-h-0 gap-2 p-2 md:gap-3 md:p-3'}
style={{
height: fileBrowserImmersive ? '100vh' : 'calc(100vh - 56px)',
minHeight: fileBrowserImmersive ? '100dvh' : undefined,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{!fileBrowserImmersive && (
<div className="border rounded-md p-2 md:p-3 bg-card/40 space-y-2 md:space-y-3 shrink-0">
<div className="text-sm font-semibold">File Picker Test</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
@ -80,13 +92,15 @@ const PlaygroundVfs = () => {
{writeStatus && <span className="text-xs font-mono text-muted-foreground">{writeStatus}</span>}
</div>
</div>
)}
<div style={{ flex: 1, minHeight: 0 }}>
<FileBrowser
key={browserRefreshKey}
allowPanels={false}
allowPanels={true}
mode="simple"
disableRoutingSync={true}
initialMount="home"
initialChrome="ribbon"
/>
</div>
</div>

View File

@ -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<AppState>((set) => ({
showGlobalFooter: true,
setShowGlobalFooter: (show) => set({ showGlobalFooter: show }),
fileBrowserImmersive: false,
setFileBrowserImmersive: (v) => set({ fileBrowserImmersive: v }),
}));