From 89f23f9426cc64342a364e77b24fe98738a03407 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Wed, 18 Feb 2026 12:39:46 +0000 Subject: [PATCH] corrected folder upload and virtualization --- bun.lock | 5 + package.json | 1 + .../Authentication/LandingPage/Login.tsx | 6 +- .../Dashboard/Files/Screen/FileGrid.tsx | 101 +++++++++++++++--- .../Screens/Dashboard/Files/Screen/index.tsx | 4 +- .../Screens/Dashboard/Home/index.tsx | 8 +- .../Settings/ProfileSettings/index.tsx | 2 +- .../api/terminal/Dockerfile.terminal-sidecar | 2 +- .../components/Workspace/WorkspaceView.tsx | 4 +- src/workspaces/components/Workspace/index.ts | 2 +- .../components/Workspace/layout-utils.ts | 5 + .../widgets/FileBrowser/Toolbar.tsx | 63 ++++++----- 12 files changed, 146 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index dc3de56e..5b199c84 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "@simplewebauthn/server": "^13.2.2", "@tabler/icons-react": "^3.36.0", "@tanstack/react-query": "^5.90.5", + "@tanstack/react-virtual": "^3.13.18", "@tiptap/extension-text-align": "^3.15.3", "@types/three": "^0.182.0", "@typescript/native-preview": "^7.0.0-dev.20260107.1", @@ -905,6 +906,10 @@ "@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], diff --git a/package.json b/package.json index 896551a0..e19dafb6 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@simplewebauthn/server": "^13.2.2", "@tabler/icons-react": "^3.36.0", "@tanstack/react-query": "^5.90.5", + "@tanstack/react-virtual": "^3.13.18", "@tiptap/extension-text-align": "^3.15.3", "@types/three": "^0.182.0", "@typescript/native-preview": "^7.0.0-dev.20260107.1", diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx index 6ff9aab2..b4279c42 100644 --- a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx +++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx @@ -9,9 +9,13 @@ import { useForm } from 'hooks/useForm'; import { useAuth } from 'hooks/useAuth'; import { useGlobal } from 'hooks/useGlobal'; +const initialState: LoginFormState = { + email: 'pastilhas@pastilhas.dev', + password: '1234567890', +}; export function Login() { const [isSubmitting, setIsSubmitting] = useState(false); - const { state, formRef, update, isValid } = useForm({}, validateForm); + const { state, formRef, update, isValid } = useForm(initialState, validateForm); const { signin } = useAuth(); const [, setDuckHidden] = useGlobal('DUCK_HIDDEN', false); diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx index e90e9067..4f1f9ac1 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx @@ -1,4 +1,5 @@ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo, useState, useEffect } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import type { DirEntry, TaskSummary } from 'widgets/FileBrowser'; import { FileItem } from './FileItem'; @@ -21,6 +22,35 @@ type FileGridProps = { onRenamingChange: (name: string | null) => void; getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; + scrollRef: React.RefObject; +}; + +const LIST_ROW_HEIGHT = 42; +const GRID_ROW_HEIGHT = 130; +const GRID_GAP = 12; + +const useColumnCount = (scrollRef: React.RefObject) => { + const [cols, setCols] = useState(4); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const measure = () => { + const width = el.clientWidth - 32; // subtract p-4 (16px each side) + // Breakpoints matching grid-cols-2 sm:3 md:4 lg:5 xl:6 + if (width >= 1280) setCols(6); + else if (width >= 1024) setCols(5); + else if (width >= 768) setCols(4); + else if (width >= 640) setCols(3); + else setCols(2); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [scrollRef]); + + return cols; }; export const FileGrid = ({ @@ -40,14 +70,18 @@ export const FileGrid = ({ onRenamingChange, getMatchingTasks, onRunTask, + scrollRef, }: FileGridProps) => { const lastClickedIdx = useRef(-1); - // Sort: directories first, then files, alphabetically within each group - const sorted = [...entries].sort((a, b) => { - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.localeCompare(b.name); - }); + const sorted = useMemo( + () => + [...entries].sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }), + [entries], + ); const handleSelect = useCallback( (entry: DirEntry, ev: React.MouseEvent) => { @@ -71,7 +105,6 @@ export const FileGrid = ({ onSelect(next); lastClickedIdx.current = idx; } else { - // Plain select (from context menu or checkbox click without modifiers) onSelect(new Set([entry.name])); lastClickedIdx.current = idx; } @@ -79,16 +112,25 @@ export const FileGrid = ({ [sorted, selected, onSelect], ); + const cols = useColumnCount(scrollRef); const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set(); const anySelected = selected.size > 0; + const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`); + + const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols); + + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP), + overscan: 10, + }); if (entries.length === 0) { return
This folder is empty
; } - const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`); - - const items = sorted.map((entry) => ( + const renderItem = (entry: DirEntry) => ( - )); - - if (viewMode === 'list') { - return
{items}
; - } + ); return ( -
- {items} +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + if (viewMode === 'list') { + const entry = sorted[virtualRow.index]!; + return ( +
+ {renderItem(entry)} +
+ ); + } + + const startIdx = virtualRow.index * cols; + const rowEntries = sorted.slice(startIdx, startIdx + cols); + return ( +
+ {rowEntries.map(renderItem)} +
+ ); + })}
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx index 310c40f7..0320944b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx @@ -59,6 +59,7 @@ export const Files = () => { const { getMatchingTasks } = useTasks(); const searchTimerRef = useRef | null>(null); const searchInputRef = useRef(null); + const fileScrollRef = useRef(null); const viewPath = searchParams.get('view'); const viewerFileName = viewPath ? viewPath.split('/').pop()! : ''; const files = useFiles(homeRoot); @@ -586,7 +587,7 @@ export const Files = () => { ) : ( -
+
{loading ? (
@@ -609,6 +610,7 @@ export const Files = () => { onRenamingChange={setRenamingName} getMatchingTasks={getMatchingTasks} onRunTask={handleRunTask} + scrollRef={fileScrollRef} /> )}
diff --git a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx index c7e28f23..03c519e8 100644 --- a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx @@ -15,8 +15,8 @@ const DEFAULT_HOME_LAYOUT: LayoutNode = { id: 'home-left', direction: 'vertical', children: [ - { size: 50, node: { type: 'panel', id: 'home-tl', widgetType: 'chat' } }, - { size: 50, node: { type: 'panel', id: 'home-bl', widgetType: 'file-browser' } }, + { size: 50, node: { type: 'panel', id: 'home-tl', widgetType: null } }, + { size: 50, node: { type: 'panel', id: 'home-bl', widgetType: null } }, ], }, }, @@ -27,8 +27,8 @@ const DEFAULT_HOME_LAYOUT: LayoutNode = { id: 'home-right', direction: 'vertical', children: [ - { size: 50, node: { type: 'panel', id: 'home-tr', widgetType: 'chat-history' } }, - { size: 50, node: { type: 'panel', id: 'home-br', widgetType: 'sound-library' } }, + { size: 50, node: { type: 'panel', id: 'home-tr', widgetType: null } }, + { size: 50, node: { type: 'panel', id: 'home-br', widgetType: null } }, ], }, }, diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx index ae111ad1..d2756505 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx @@ -95,7 +95,7 @@ export const ProfileSettings = () => {
{section.title}
- {section.content} + {section.content}
))} diff --git a/src/servers/api/terminal/Dockerfile.terminal-sidecar b/src/servers/api/terminal/Dockerfile.terminal-sidecar index e7ecd7d4..6cb836ac 100644 --- a/src/servers/api/terminal/Dockerfile.terminal-sidecar +++ b/src/servers/api/terminal/Dockerfile.terminal-sidecar @@ -1,4 +1,4 @@ -FROM node:20-bookworm-slim +FROM imbios/bun-node:22-slim RUN apt-get update \ && apt-get install -y python3 make g++ zsh git curl ca-certificates fortune-mod cowsay \ diff --git a/src/workspaces/components/Workspace/WorkspaceView.tsx b/src/workspaces/components/Workspace/WorkspaceView.tsx index 675a551d..3ef0c968 100644 --- a/src/workspaces/components/Workspace/WorkspaceView.tsx +++ b/src/workspaces/components/Workspace/WorkspaceView.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import type { LayoutNode, WorkspaceDefinition, WidgetRegistry } from './types'; -import { splitPanel, removePanel, setWidget, updateSizes, countPanels } from './layout-utils'; +import { splitPanel, removePanel, setWidget, updateSizes, countPanels, hasAnyWidget } from './layout-utils'; import { WorkspaceProvider } from './WorkspaceContext'; import { WorkspaceHeader } from './WorkspaceHeader'; import { WorkspaceRenderer } from './WorkspaceRenderer'; @@ -14,7 +14,7 @@ type WorkspaceViewProps = { }; export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => { - const [editing, setEditing] = useState(false); + const [editing, setEditing] = useState(() => !hasAnyWidget(layout)); const handleSetWidget = useCallback( (panelId: string, widgetType: string | null) => { diff --git a/src/workspaces/components/Workspace/index.ts b/src/workspaces/components/Workspace/index.ts index 8b905ced..4e130dd1 100644 --- a/src/workspaces/components/Workspace/index.ts +++ b/src/workspaces/components/Workspace/index.ts @@ -1,4 +1,4 @@ export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WidgetRegistry, WidgetRegistryEntry } from './types'; -export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels } from './layout-utils'; +export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels, hasAnyWidget } from './layout-utils'; export { WorkspaceProvider, useWorkspace } from './WorkspaceContext'; export { WorkspaceView } from './WorkspaceView'; diff --git a/src/workspaces/components/Workspace/layout-utils.ts b/src/workspaces/components/Workspace/layout-utils.ts index efe9d1a7..25b465a5 100644 --- a/src/workspaces/components/Workspace/layout-utils.ts +++ b/src/workspaces/components/Workspace/layout-utils.ts @@ -156,3 +156,8 @@ export function countPanels(node: LayoutNode): number { if (node.type === 'panel') return 1; return node.children.reduce((sum, child) => sum + countPanels(child.node), 0); } + +export function hasAnyWidget(node: LayoutNode): boolean { + if (node.type === 'panel') return node.widgetType !== null; + return node.children.some((child) => hasAnyWidget(child.node)); +} diff --git a/src/workspaces/widgets/FileBrowser/Toolbar.tsx b/src/workspaces/widgets/FileBrowser/Toolbar.tsx index e5a2717b..571b5a5d 100644 --- a/src/workspaces/widgets/FileBrowser/Toolbar.tsx +++ b/src/workspaces/widgets/FileBrowser/Toolbar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState, useCallback } from 'react'; import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react'; type ToolbarProps = { @@ -28,11 +28,9 @@ export const Toolbar = ({ const [folderName, setFolderName] = useState(''); const fileInputRef = useRef(null); const folderInputRef = useRef(null); - - useEffect(() => { - const input = folderInputRef.current; - if (!input) return; - input.setAttribute('webkitdirectory', ''); + const setFolderInputRef = useCallback((input: HTMLInputElement | null) => { + folderInputRef.current = input; + if (input) input.setAttribute('webkitdirectory', ''); }, []); const handleCreate = () => { @@ -43,6 +41,34 @@ export const Toolbar = ({ setShowInput(false); }; + const hiddenInputs = ( + <> + { + if (ev.target.files?.length) { + onUpload(ev.target.files); + ev.target.value = ''; + } + }} + /> + { + if (ev.target.files?.length) { + onUpload(ev.target.files); + ev.target.value = ''; + } + }} + /> + + ); + if (selectionCount > 0) { return (
@@ -87,6 +113,7 @@ export const Toolbar = ({ > + {hiddenInputs}
); } @@ -169,29 +196,7 @@ export const Toolbar = ({ )} - { - if (ev.target.files?.length) { - onUpload(ev.target.files); - ev.target.value = ''; - } - }} - /> - { - if (ev.target.files?.length) { - onUpload(ev.target.files); - ev.target.value = ''; - } - }} - /> + {hiddenInputs}
); };