From d9c8ccd43395c8028dc4f6f92d57dcc521f46805 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sun, 22 Feb 2026 20:25:20 +0100 Subject: [PATCH] filemanager 1/2 --- packages/ui/src/App.tsx | 2 + .../ui/src/apps/filebrowser/FileBrowser.tsx | 58 ++ .../src/apps/filebrowser/FileBrowserApp.tsx | 27 + .../apps/filebrowser/FileBrowserContext.tsx | 138 +++++ .../src/apps/filebrowser/FileBrowserPanel.tsx | 525 ++++++++++++++++++ .../ui/src/apps/filebrowser/LayoutToolbar.tsx | 78 +++ .../ui/src/apps/filebrowser/PanelSide.tsx | 117 ++++ packages/ui/src/apps/filebrowser/main.tsx | 10 + 8 files changed, 955 insertions(+) create mode 100644 packages/ui/src/apps/filebrowser/FileBrowser.tsx create mode 100644 packages/ui/src/apps/filebrowser/FileBrowserApp.tsx create mode 100644 packages/ui/src/apps/filebrowser/FileBrowserContext.tsx create mode 100644 packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx create mode 100644 packages/ui/src/apps/filebrowser/LayoutToolbar.tsx create mode 100644 packages/ui/src/apps/filebrowser/PanelSide.tsx create mode 100644 packages/ui/src/apps/filebrowser/main.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 6e739fb4..8901312e 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -58,6 +58,7 @@ const PlaygroundCanvas = React.lazy(() => import("./modules/layout/PlaygroundCan const TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground")); const VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor }))); const Tetris = React.lazy(() => import("./apps/tetris/Tetris")); +const FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser")); const I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground")); const VersionMap = React.lazy(() => import("./pages/VersionMap")); @@ -133,6 +134,7 @@ const AppWrapper = () => { {/* Apps */} Loading...}>} /> + Loading...}>} /> {/* Ecommerce Routes */} {(ecommerce) && ( diff --git a/packages/ui/src/apps/filebrowser/FileBrowser.tsx b/packages/ui/src/apps/filebrowser/FileBrowser.tsx new file mode 100644 index 00000000..096fb661 --- /dev/null +++ b/packages/ui/src/apps/filebrowser/FileBrowser.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useAuth } from '@/hooks/useAuth'; +import { FileBrowserProvider, useFileBrowser } from './FileBrowserContext'; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; +import LayoutToolbar from './LayoutToolbar'; +import PanelSide from './PanelSide'; + +/** + * Standalone FileBrowser page — Krusader-style dual pane. + * Inner component that requires FileBrowserProvider context. + */ +const FileBrowserInner: React.FC = () => { + const { loading } = useAuth(); + const { layout } = useFileBrowser(); + + if (loading) { + return ( +
+ Loading… +
+ ); + } + + return ( +
+ {/* ═══ Layout Toolbar ═══════════════════════════ */} + + + {/* ═══ Resizable Panes ══════════════════════════ */} + + + + + {layout === 'dual' && ( + <> + + + + + + )} + +
+ ); +}; + +/** + * Exported wrapper — provides FileBrowserProvider so this component + * works both from the standalone app AND when lazy-loaded by App.tsx. + */ +const FileBrowser: React.FC = () => ( + + + +); + +export default FileBrowser; + diff --git a/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx b/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx new file mode 100644 index 00000000..e9cb5305 --- /dev/null +++ b/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from '@/lib/queryClient'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthProvider } from '@/hooks/useAuth'; +import { Toaster } from '@/components/ui/sonner'; +import { FileBrowserProvider } from './FileBrowserContext'; +import FileBrowser from './FileBrowser'; + +const FileBrowserApp: React.FC = () => { + return ( + + + + +
+ +
+
+ +
+
+
+ ); +}; + +export default FileBrowserApp; diff --git a/packages/ui/src/apps/filebrowser/FileBrowserContext.tsx b/packages/ui/src/apps/filebrowser/FileBrowserContext.tsx new file mode 100644 index 00000000..692e826a --- /dev/null +++ b/packages/ui/src/apps/filebrowser/FileBrowserContext.tsx @@ -0,0 +1,138 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import type { INode } from '@/modules/storage/types'; + +// ── Panel State ────────────────────────────────────────────────── + +export interface PanelState { + id: string; + mount: string; + path: string; + glob: string; + selected: INode | null; +} + +export type Side = 'left' | 'right'; +export type LayoutMode = 'single' | 'dual'; + +// ── Context Shape ──────────────────────────────────────────────── + +interface FileBrowserContextType { + layout: LayoutMode; + setLayout: (mode: LayoutMode) => void; + + /** Link mode: left folder navigation mirrors to right */ + linked: boolean; + setLinked: (v: boolean) => void; + + /** Panels per side */ + leftPanels: PanelState[]; + rightPanels: PanelState[]; + + /** Which side + panel index is active */ + activeSide: Side; + activePanelIdx: number; + setActivePanel: (side: Side, idx: number) => void; + + /** Get the currently active panel state */ + activePanel: PanelState; + + /** Update a panel's state */ + updatePanel: (side: Side, idx: number, patch: Partial) => void; + + /** Add a new tab/panel to a side */ + addPanel: (side: Side, initial?: Partial) => void; + + /** Remove a panel (tab) from a side — won't remove last panel */ + removePanel: (side: Side, idx: number) => void; +} + +const FileBrowserContext = createContext(undefined); + +// ── Helpers ────────────────────────────────────────────────────── + +let _panelIdCounter = 0; +function nextPanelId(): string { + return `panel-${++_panelIdCounter}`; +} + +function createPanel(overrides?: Partial): PanelState { + return { + id: nextPanelId(), + mount: 'machines', + path: '/', + glob: '*.*', + selected: null, + ...overrides, + }; +} + +// ── Provider ───────────────────────────────────────────────────── + +export const FileBrowserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [layout, setLayout] = useState('single'); + const [linked, setLinked] = useState(false); + const [leftPanels, setLeftPanels] = useState([createPanel()]); + const [rightPanels, setRightPanels] = useState([createPanel()]); + const [activeSide, setActiveSide] = useState('left'); + const [activePanelIdx, setActivePanelIdx] = useState(0); + + const setActivePanel = useCallback((side: Side, idx: number) => { + setActiveSide(side); + setActivePanelIdx(idx); + }, []); + + const updatePanel = useCallback((side: Side, idx: number, patch: Partial) => { + const setter = side === 'left' ? setLeftPanels : setRightPanels; + setter(prev => prev.map((p, i) => i === idx ? { ...p, ...patch } : p)); + + // Link mode: left path changes mirror to right's active panel + if (linked && side === 'left' && patch.path !== undefined) { + setRightPanels(prev => { + const rIdx = Math.min(activePanelIdx, prev.length - 1); + return prev.map((p, i) => i === rIdx ? { ...p, path: patch.path! } : p); + }); + } + }, [linked, activePanelIdx]); + + const addPanel = useCallback((side: Side, initial?: Partial) => { + const setter = side === 'left' ? setLeftPanels : setRightPanels; + setter(prev => [...prev, createPanel(initial)]); + }, []); + + const removePanel = useCallback((side: Side, idx: number) => { + const setter = side === 'left' ? setLeftPanels : setRightPanels; + setter(prev => { + if (prev.length <= 1) return prev; // Keep at least one + const next = prev.filter((_, i) => i !== idx); + // Adjust active index if needed + if (side === activeSide && activePanelIdx >= next.length) { + setActivePanelIdx(next.length - 1); + } + return next; + }); + }, [activeSide, activePanelIdx]); + + const panels = activeSide === 'left' ? leftPanels : rightPanels; + const activePanel = panels[Math.min(activePanelIdx, panels.length - 1)]; + + return ( + + {children} + + ); +}; + +// ── Hook ───────────────────────────────────────────────────────── + +export const useFileBrowser = () => { + const ctx = useContext(FileBrowserContext); + if (!ctx) throw new Error('useFileBrowser must be used within FileBrowserProvider'); + return ctx; +}; diff --git a/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx b/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx new file mode 100644 index 00000000..818369a1 --- /dev/null +++ b/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx @@ -0,0 +1,525 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { Loader2 } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import ImageLightbox from '@/components/ImageLightbox'; +import LightboxText from '@/components/LightboxText'; + +import type { INode, SortKey } from '@/modules/storage/types'; +import { getMimeCategory, sortNodes, vfsUrl, formatSize } from '@/modules/storage/helpers'; +import FileBrowserToolbar from '@/modules/storage/FileBrowserToolbar'; +import FileListView from '@/modules/storage/FileListView'; +import FileGridView from '@/modules/storage/FileGridView'; +import FileDetailPanel from '@/modules/storage/FileDetailPanel'; + +// ── Props ──────────────────────────────────────────────────────── + +export interface FileBrowserPanelProps { + mount?: string; + path?: string; + glob?: string; + mode?: 'simple' | 'advanced'; + viewMode?: 'list' | 'thumbs'; + sortBy?: SortKey; + showToolbar?: boolean; + canChangeMount?: boolean; + allowFileViewer?: boolean; + allowLightbox?: boolean; + allowDownload?: boolean; + jail?: boolean; + onPathChange?: (path: string) => void; + onMountChange?: (mount: string) => void; + onSelect?: (node: INode | null) => void; +} + +// ── Main Component ─────────────────────────────────────────────── + +const FileBrowserPanel: React.FC = (props) => { + + const { + mount: mountProp = 'machines', + path: pathProp = '/', + glob = '*.*', + mode = 'advanced', + viewMode: initialViewMode = 'list', + sortBy: initialSort = 'name', + showToolbar = true, + canChangeMount = true, + allowFileViewer = true, + allowLightbox = true, + allowDownload = true, + jail = false, + onPathChange, + onMountChange, + onSelect, + } = props; + + const { session } = useAuth(); + const accessToken = session?.access_token; + + // ── Controlled / uncontrolled mode ──────────────────────────── + + const isControlled = !!onPathChange; + const [internalPath, setInternalPath] = useState(pathProp); + const [internalMount, setInternalMount] = useState(mountProp); + const mount = onMountChange ? mountProp : internalMount; + const currentPath = isControlled ? pathProp : internalPath; + + // Jail: normalize the root path for comparison + const jailRoot = pathProp.replace(/\/+$/, '') || '/'; + + const updatePath = useCallback((newPath: string) => { + if (jail) { + const norm = newPath.replace(/\/+$/, '') || '/'; + const root = pathProp.replace(/\/+$/, '') || '/'; + if (root !== '/' && !norm.startsWith(root) && norm !== root) return; + } + if (isControlled) onPathChange!(newPath); + else setInternalPath(newPath); + }, [isControlled, onPathChange, jail, pathProp]); + + const updateMount = useCallback((newMount: string) => { + if (onMountChange) onMountChange(newMount); + else setInternalMount(newMount); + updatePath('/'); + }, [onMountChange, updatePath]); + + // ── Available mounts ───────────────────────────────────────── + + const [availableMounts, setAvailableMounts] = useState([]); + useEffect(() => { + const headers: Record = {}; + 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]); + + // ── Core state ─────────────────────────────────────────────── + + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode); + const [sortBy, setSortBy] = useState(initialSort); + const [sortAsc, setSortAsc] = useState(true); + const [focusIdx, setFocusIdx] = useState(-1); + const [selected, setSelected] = useState(null); + const listRef = useRef(null); + const containerRef = useRef(null); + const returnTargetRef = useRef(null); + + useEffect(() => { + if (onSelect) onSelect(selected); + }, [selected, onSelect]); + + // ── Zoom (persisted) ───────────────────────────────────────── + + const [thumbSize, setThumbSize] = useState(() => { + const v = localStorage.getItem('fb-thumb-size'); + return v ? Math.max(60, Math.min(200, Number(v))) : 80; + }); + const [fontSize, setFontSize] = useState(() => { + const v = localStorage.getItem('fb-font-size'); + return v ? Math.max(10, Math.min(18, Number(v))) : 14; + }); + const zoomIn = () => { + if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); + else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; }); + }; + const zoomOut = () => { + if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); + else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; }); + }; + + // ── Fetch ──────────────────────────────────────────────────── + + const fetchDir = useCallback(async (dirPath: string) => { + setLoading(true); + setError(null); + setSelected(null); + setFocusIdx(0); + try { + const clean = dirPath.replace(/^\/+/, ''); + const base = vfsUrl('ls', mount, clean); + const url = glob && glob !== '*.*' ? `${base}?glob=${encodeURIComponent(glob)}` : base; + const headers: Record = {}; + if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; + const res = await fetch(url, { headers }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + setNodes(await res.json()); + } catch (e: any) { + setError(e.message || 'Failed to load directory'); + setNodes([]); + } finally { + setLoading(false); + } + }, [mount, glob, accessToken]); + + useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]); + + useEffect(() => { + if (!isControlled) setInternalPath(pathProp); + }, [pathProp, isControlled]); + + const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : ''; + + // ── Sorted items ───────────────────────────────────────────── + + const canGoUp = (() => { + if (currentPath === '/' || currentPath === '') return false; + if (jail) { + const normalized = currentPath.replace(/\/+$/, '') || '/'; + return normalized !== jailRoot && normalized !== jailRoot.replace(/\/+$/, ''); + } + return true; + })(); + const sorted = useMemo(() => sortNodes(nodes, sortBy, sortAsc), [nodes, sortBy, sortAsc]); + const itemCount = sorted.length + (canGoUp ? 1 : 0); + const getNode = (idx: number): INode | null => { + if (canGoUp && idx === 0) return null; + return sorted[canGoUp ? idx - 1 : idx] ?? null; + }; + + // ── Navigation ─────────────────────────────────────────────── + + const goUp = useCallback(() => { + if (!canGoUp) return; + const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean); + const leaving = parts.pop(); + if (leaving) returnTargetRef.current = leaving; + updatePath(parts.length ? parts.join('/') : '/'); + }, [currentPath, canGoUp, updatePath]); + + const getFileUrl = (node: INode) => { + const base = vfsUrl('get', mount, node.path); + return tokenParam ? `${base}?${tokenParam}` : base; + }; + + const handleView = () => { if (selected) window.open(getFileUrl(selected), '_blank'); }; + const handleDownload = () => { + if (!selected) return; + const a = document.createElement('a'); + a.href = getFileUrl(selected); + a.download = selected.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + // ── Sort toggle ────────────────────────────────────────────── + + const cycleSort = () => { + const keys: SortKey[] = ['name', 'ext', 'date', 'type']; + const i = keys.indexOf(sortBy); + if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); } + }; + + // ── Breadcrumbs ────────────────────────────────────────────── + + const breadcrumbs = useMemo(() => { + const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean); + const crumbs = [{ label: '/', path: '/' }]; + let acc = ''; + for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); } + if (jail) { + const root = jailRoot === '/' ? '/' : jailRoot; + const rootParts = root === '/' ? 0 : root.split('/').filter(Boolean).length; + return crumbs.slice(rootParts); + } + return crumbs; + }, [currentPath, mount, jail, jailRoot]); + + // Return-to-sender focus + useEffect(() => { + const target = returnTargetRef.current; + if (!target || sorted.length === 0) return; + const idx = sorted.findIndex(n => n.name === target); + if (idx >= 0) { + const realIdx = canGoUp ? idx + 1 : idx; + setFocusIdx(realIdx); + setSelected(sorted[idx]); + requestAnimationFrame(() => { + if (!listRef.current) return; + const items = listRef.current.querySelectorAll('[data-fb-idx]'); + const el = items[realIdx] as HTMLElement | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }); + } + returnTargetRef.current = null; + }, [sorted, canGoUp]); + + // ── Keyboard navigation ────────────────────────────────────── + + const scrollItemIntoView = useCallback((idx: number) => { + if (!listRef.current) return; + const items = listRef.current.querySelectorAll('[data-fb-idx]'); + const el = items[idx] as HTMLElement | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, []); + + const getGridCols = useCallback((): number => { + if (viewMode !== 'thumbs' || !listRef.current) return 1; + const style = getComputedStyle(listRef.current); + const cols = style.gridTemplateColumns.split(' ').length; + return Math.max(1, cols); + }, [viewMode]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (itemCount === 0) return; + + const moveFocus = (next: number) => { + next = Math.max(0, Math.min(itemCount - 1, next)); + setFocusIdx(next); + const node = getNode(next); + if (node) setSelected(node); else setSelected(null); + scrollItemIntoView(next); + }; + + const cols = getGridCols(); + + switch (e.key) { + case 'ArrowRight': + case 'j': { + e.preventDefault(); + moveFocus(focusIdx < itemCount - 1 ? focusIdx + 1 : 0); + break; + } + case 'ArrowLeft': + case 'k': { + e.preventDefault(); + moveFocus(focusIdx > 0 ? focusIdx - 1 : itemCount - 1); + break; + } + case 'ArrowDown': { + e.preventDefault(); + moveFocus(focusIdx + cols); + break; + } + case 'ArrowUp': { + e.preventDefault(); + moveFocus(focusIdx - cols); + break; + } + case 'Enter': + case ' ': + case 'l': { + e.preventDefault(); + if (focusIdx < 0) break; + const node = getNode(focusIdx); + if (!node) { goUp(); break; } + const cat = getMimeCategory(node); + if (cat === 'dir') updatePath(node.path || node.name); + else if ((cat === 'image' || cat === 'video') && allowLightbox) setLightboxNode(node); + else if (allowFileViewer) openTextLightbox(node); + break; + } + case 'Backspace': + case 'h': { + e.preventDefault(); + goUp(); + break; + } + case 'Home': { + e.preventDefault(); + moveFocus(0); + break; + } + case 'End': { + e.preventDefault(); + moveFocus(itemCount - 1); + break; + } + case 'Escape': { + e.preventDefault(); + setSelected(null); + setFocusIdx(-1); + break; + } + } + }, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp, getGridCols, allowLightbox, allowFileViewer, updatePath]); + + useEffect(() => { containerRef.current?.focus(); }, [currentPath]); + + const selectedFile = selected && getMimeCategory(selected) !== 'dir' ? selected : null; + + // ── Lightbox state ─────────────────────────────────────────── + + const [lightboxNode, setLightboxNode] = useState(null); + const mediaNodes = useMemo(() => sorted.filter(n => { const c = getMimeCategory(n); return c === 'image' || c === 'video'; }), [sorted]); + const lightboxIdx = lightboxNode ? mediaNodes.findIndex(n => n.path === lightboxNode.path) : -1; + + const lightboxPrev = () => { if (lightboxIdx > 0) setLightboxNode(mediaNodes[lightboxIdx - 1]); }; + const lightboxNext = () => { if (lightboxIdx < mediaNodes.length - 1) setLightboxNode(mediaNodes[lightboxIdx + 1]); }; + const closeLightbox = () => { setLightboxNode(null); setTimeout(() => containerRef.current?.focus(), 0); }; + + // ── Text lightbox state ────────────────────────────────────── + + const [textLightboxNode, setTextLightboxNode] = useState(null); + const openTextLightbox = (node: INode) => setTextLightboxNode(node); + const closeTextLightbox = () => { setTextLightboxNode(null); setTimeout(() => containerRef.current?.focus(), 0); }; + + // ── Click handlers ─────────────────────────────────────────── + + const onItemClick = (idx: number) => { + setFocusIdx(idx); + const node = getNode(idx); + if (!node) return; + setSelected(prev => prev?.path === node.path ? null : node); + }; + const onItemDoubleClick = (idx: number) => { + const node = getNode(idx); + if (!node) { goUp(); return; } + const cat = getMimeCategory(node); + if (cat === 'dir') { + updatePath(node.path || node.name); + } else if ((cat === 'image' || cat === 'video') && allowLightbox) { + setLightboxNode(node); + } else if (allowFileViewer) { + openTextLightbox(node); + } + }; + + // ── Render ─────────────────────────────────────────────────── + + return ( +
+ + + {/* ═══ Toolbar ═══════════════════════════════════ */} + {showToolbar && ( + + )} + + {/* ═══ Content ═══════════════════════════════════ */} + {loading ? ( +
+ + Loading… +
+ ) : error ? ( +
+ {error} +
+ ) : itemCount === 0 ? ( +
+ Empty directory +
+ ) : ( +
+ {viewMode === 'list' ? ( + + ) : ( + + )} + + {/* Detail panel (advanced, desktop only) */} + {mode === 'advanced' && selectedFile && ( + + )} +
+ )} + + {/* ═══ Status bar ════════════════════════════════ */} +
+ + {sorted.length} item{sorted.length !== 1 ? 's' : ''} + {' · '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))} + {selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''} + + {mount}:{currentPath || '/'} +
+ + {/* ═══ Lightboxes ════════════════════════════════ */} + dir === 'prev' ? lightboxPrev() : lightboxNext()} + showPrompt={false} + /> + +
+ ); +}; + +export { FileBrowserPanel }; +export default FileBrowserPanel; diff --git a/packages/ui/src/apps/filebrowser/LayoutToolbar.tsx b/packages/ui/src/apps/filebrowser/LayoutToolbar.tsx new file mode 100644 index 00000000..99021867 --- /dev/null +++ b/packages/ui/src/apps/filebrowser/LayoutToolbar.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Columns2, Square, Plus, Link, Unlink } from 'lucide-react'; +import { useFileBrowser, type LayoutMode } from './FileBrowserContext'; + +const LayoutToolbar: React.FC = () => { + const { layout, setLayout, linked, setLinked, activeSide, addPanel } = useFileBrowser(); + + const btnBase: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 4, + padding: '4px 10px', borderRadius: 4, + borderWidth: 1, borderStyle: 'solid', borderColor: 'var(--border)', + background: 'var(--background)', color: 'var(--muted-foreground)', + cursor: 'pointer', fontSize: 13, fontWeight: 500, + }; + const btnActive: React.CSSProperties = { + ...btnBase, + background: 'var(--accent)', color: 'var(--foreground)', + borderColor: 'var(--ring)', + }; + + const modes: { key: LayoutMode; label: string; icon: React.ReactNode }[] = [ + { key: 'single', label: 'Single', icon: }, + { key: 'dual', label: 'Dual', icon: }, + ]; + + return ( +
+ + Layout + + {modes.map(m => ( + + ))} + + {/* Link toggle — only in dual mode */} + {layout === 'dual' && ( + + )} + +
+ + {/* Add tab to active side */} + +
+ ); +}; + +export default LayoutToolbar; + diff --git a/packages/ui/src/apps/filebrowser/PanelSide.tsx b/packages/ui/src/apps/filebrowser/PanelSide.tsx new file mode 100644 index 00000000..d8dcdd70 --- /dev/null +++ b/packages/ui/src/apps/filebrowser/PanelSide.tsx @@ -0,0 +1,117 @@ +import React, { useCallback } from 'react'; +import { X } from 'lucide-react'; +import { useFileBrowser, type Side } from './FileBrowserContext'; +import FileBrowserPanel from './FileBrowserPanel'; + +interface PanelSideProps { + side: Side; +} + +const PanelSide: React.FC = ({ side }) => { + const { + leftPanels, rightPanels, + activeSide, activePanelIdx, + setActivePanel, updatePanel, removePanel, + } = useFileBrowser(); + + const panels = side === 'left' ? leftPanels : rightPanels; + const isActiveSide = activeSide === side; + const activeIdx = isActiveSide ? activePanelIdx : 0; + const currentIdx = Math.min(activeIdx, panels.length - 1); + + const panel = panels[currentIdx]; + + const handlePathChange = useCallback((path: string) => { + updatePanel(side, currentIdx, { path }); + }, [side, currentIdx, updatePanel]); + + const handleMountChange = useCallback((mount: string) => { + updatePanel(side, currentIdx, { mount, path: '/' }); + }, [side, currentIdx, updatePanel]); + + const handleSelect = useCallback((node: any) => { + updatePanel(side, currentIdx, { selected: node }); + }, [side, currentIdx, updatePanel]); + + return ( +
{ if (!isActiveSide) setActivePanel(side, currentIdx); }} + style={{ + height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', + minHeight: 0, + border: isActiveSide ? '2px solid var(--ring, #3b82f6)' : '2px solid transparent', + borderRadius: 6, + minWidth: 0, + }} + > + {/* ── Tabs ──────────────────────────────────── */} + {panels.length > 1 && ( +
+ {panels.map((p, i) => { + const isActive = i === currentIdx; + return ( +
{ e.stopPropagation(); setActivePanel(side, i); }} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + padding: '4px 10px', fontSize: 12, cursor: 'pointer', + borderRight: '1px solid var(--border)', + background: isActive ? 'var(--background)' : 'transparent', + color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', + fontWeight: isActive ? 600 : 400, + whiteSpace: 'nowrap', + minWidth: 0, + }} + > + + {p.mount}:{p.path || '/'} + + {panels.length > 1 && ( + + )} +
+ ); + })} +
+ )} + + {/* ── Active Panel ─────────────────────────── */} +
+ +
+
+ ); +}; + +export default PanelSide; diff --git a/packages/ui/src/apps/filebrowser/main.tsx b/packages/ui/src/apps/filebrowser/main.tsx new file mode 100644 index 00000000..f6925eea --- /dev/null +++ b/packages/ui/src/apps/filebrowser/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from "react-dom/client"; +import FileBrowserApp from "./FileBrowserApp"; +import "@/index.css"; +import { ThemeProvider } from "@/components/ThemeProvider"; + +createRoot(document.getElementById("root")!).render( + + + +);