vfs
This commit is contained in:
parent
371b0e33e3
commit
efeafbb725
@ -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}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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={
|
||||
|
||||
286
packages/ui/src/modules/storage/FileBrowserRibbonBar.tsx
Normal file
286
packages/ui/src/modules/storage/FileBrowserRibbonBar.tsx
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
72
packages/ui/src/modules/storage/VfsContextMenu.tsx
Normal file
72
packages/ui/src/modules/storage/VfsContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
packages/ui/src/modules/storage/file-browser-commands.ts
Normal file
141
packages/ui/src/modules/storage/file-browser-commands.ts
Normal 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>;
|
||||
}
|
||||
@ -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 ─────────────────────
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
277
packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts
Normal file
277
packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts
Normal 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]);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 }),
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user