diff --git a/flocks/server/routes/workspace.py b/flocks/server/routes/workspace.py index 42019bd71..51aed456d 100644 --- a/flocks/server/routes/workspace.py +++ b/flocks/server/routes/workspace.py @@ -57,6 +57,7 @@ # Upload size limit read at request time so env-var changes take effect # without restarting the process. _DEFAULT_MAX_UPLOAD_MB = 100 +_DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024 _ALLOWED_UPLOAD_EXTENSIONS = { ".txt", ".md", ".json", ".yaml", ".yml", ".xml", ".csv", ".pdf", ".doc", ".docx", ".html", ".htm", ".ppt", ".pptx", ".xls", ".xlsx", @@ -68,6 +69,10 @@ def _max_upload_bytes() -> int: return int(os.getenv("FLOCKS_WORKSPACE_MAX_UPLOAD_MB", str(_DEFAULT_MAX_UPLOAD_MB))) * 1024 * 1024 +def _max_read_bytes() -> int: + return int(os.getenv("FLOCKS_WORKSPACE_MAX_READ_BYTES", str(_DEFAULT_MAX_READ_BYTES))) + + # ─── helpers ──────────────────────────────────────────────────────────────── def _get_manager() -> WorkspaceManager: @@ -144,6 +149,16 @@ def _dir_stats_sync(root: Path): return file_count, dir_count, total_size +def _read_text_preview_sync(path: Path, max_bytes: int) -> tuple[str, bool]: + """Read at most ``max_bytes`` from a text file for safe preview.""" + with path.open("rb") as handle: + data = handle.read(max_bytes + 1) + truncated = len(data) > max_bytes + if truncated: + data = data[:max_bytes] + return data.decode("utf-8", errors="replace"), truncated + + # ─── directory operations ─────────────────────────────────────────────────── @router.get("/tree", response_model=WorkspaceNode, summary="List directory tree") @@ -314,11 +329,22 @@ async def read_file( status_code=400, detail="Binary file — use /download endpoint instead", ) + max_read_bytes = _max_read_bytes() try: - content = target.read_text(encoding="utf-8", errors="replace") + content, truncated = await asyncio.to_thread( + _read_text_preview_sync, + target, + max_read_bytes, + ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - return {"path": path, "content": content} + return { + "path": path, + "content": content, + "truncated": truncated, + "size": target.stat().st_size, + "preview_limit_bytes": max_read_bytes, + } class FileWriteRequest(BaseModel): @@ -470,11 +496,22 @@ async def read_memory_file( raise HTTPException(status_code=404, detail=f"Memory file not found: {path}") if not target.is_file(): raise HTTPException(status_code=400, detail=f"Not a file: {path}") + max_read_bytes = _max_read_bytes() try: - content = target.read_text(encoding="utf-8", errors="replace") + content, truncated = await asyncio.to_thread( + _read_text_preview_sync, + target, + max_read_bytes, + ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - return {"path": path, "content": content} + return { + "path": path, + "content": content, + "truncated": truncated, + "size": target.stat().st_size, + "preview_limit_bytes": max_read_bytes, + } # ─── stats ────────────────────────────────────────────────────────────────── diff --git a/flocks/workspace/manager.py b/flocks/workspace/manager.py index df8b19465..8e57f9e69 100644 --- a/flocks/workspace/manager.py +++ b/flocks/workspace/manager.py @@ -24,7 +24,7 @@ # Note: dotfiles like .gitignore have suffix='' in Python, so they are NOT # matched here; they will fall through to the binary-file path (download only). TEXT_EXTENSIONS = { - ".md", ".txt", ".log", ".json", ".yaml", ".yml", + ".md", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".py", ".js", ".ts", ".sh", ".bash", ".csv", ".xml", ".html", ".css", ".tsx", ".jsx", ".env", diff --git a/tests/workspace/test_workspace_manager.py b/tests/workspace/test_workspace_manager.py index cbcee1e2a..87677bff9 100644 --- a/tests/workspace/test_workspace_manager.py +++ b/tests/workspace/test_workspace_manager.py @@ -151,6 +151,7 @@ class TestIsTextFile: ("notes.txt", True), ("server.log", True), ("config.json", True), + ("events.jsonl", True), ("settings.yaml", True), ("settings.yml", True), ("pyproject.toml", True), diff --git a/tests/workspace/test_workspace_routes.py b/tests/workspace/test_workspace_routes.py index 69b404d4b..5192ec606 100644 --- a/tests/workspace/test_workspace_routes.py +++ b/tests/workspace/test_workspace_routes.py @@ -344,6 +344,29 @@ def test_read_text_file(self, workspace_client): assert data["path"] == "outputs/note.md" assert data["content"] == "# Hello\nWorld" + def test_read_jsonl_file(self, workspace_client): + ws = _ws(workspace_client) + (ws / "outputs" / "events.jsonl").write_text('{"id": 1}\n{"id": 2}\n') + r = _client(workspace_client).get("/api/workspace/file?path=outputs/events.jsonl") + assert r.status_code == 200 + data = r.json() + assert data["path"] == "outputs/events.jsonl" + assert data["content"] == '{"id": 1}\n{"id": 2}\n' + assert data["truncated"] is False + + def test_read_large_text_file_returns_truncated_preview(self, workspace_client, monkeypatch): + monkeypatch.setenv("FLOCKS_WORKSPACE_MAX_READ_BYTES", "10") + ws = _ws(workspace_client) + (ws / "outputs" / "large.jsonl").write_text("0123456789ABCDEF", encoding="utf-8") + r = _client(workspace_client).get("/api/workspace/file?path=outputs/large.jsonl") + assert r.status_code == 200 + data = r.json() + assert data["path"] == "outputs/large.jsonl" + assert data["content"] == "0123456789" + assert data["truncated"] is True + assert data["size"] == 16 + assert data["preview_limit_bytes"] == 10 + def test_read_nonexistent_returns_404(self, workspace_client): r = _client(workspace_client).get("/api/workspace/file?path=ghost.txt") assert r.status_code == 404 @@ -545,6 +568,19 @@ def test_read_memory_file(self, workspace_client): data = r.json() assert data["path"] == "MEMORY.md" assert "Key facts" in data["content"] + assert data["truncated"] is False + + def test_read_large_memory_file_returns_truncated_preview(self, workspace_client, monkeypatch): + monkeypatch.setenv("FLOCKS_WORKSPACE_MAX_READ_BYTES", "8") + mem = _mem(workspace_client) + (mem / "large.md").write_text("abcdefghijk", encoding="utf-8") + r = _client(workspace_client).get("/api/workspace/memory/file?path=large.md") + assert r.status_code == 200 + data = r.json() + assert data["content"] == "abcdefgh" + assert data["truncated"] is True + assert data["size"] == 11 + assert data["preview_limit_bytes"] == 8 def test_read_memory_nonexistent_returns_404(self, workspace_client): r = _client(workspace_client).get("/api/workspace/memory/file?path=ghost.md") diff --git a/webui/src/api/workspace.ts b/webui/src/api/workspace.ts index 3a1299725..d8f3d712d 100644 --- a/webui/src/api/workspace.ts +++ b/webui/src/api/workspace.ts @@ -30,6 +30,14 @@ export interface UploadResult { error?: string; } +export interface WorkspaceFileContentResponse { + path: string; + content: string; + truncated?: boolean; + size?: number; + preview_limit_bytes?: number; +} + export type UploadPurpose = 'chat'; // ─── API ─────────────────────────────────────────────────────────────────── @@ -66,7 +74,7 @@ export const workspaceAPI = { }, readFile: (path: string) => - client.get<{ path: string; content: string }>('/api/workspace/file', { params: { path } }), + client.get('/api/workspace/file', { params: { path } }), writeFile: (path: string, content: string) => client.put<{ path: string; written: boolean }>('/api/workspace/file', { path, content }), @@ -92,7 +100,7 @@ export const workspaceAPI = { client.get('/api/workspace/memory/list'), readMemoryFile: (path: string) => - client.get<{ path: string; content: string }>('/api/workspace/memory/file', { params: { path } }), + client.get('/api/workspace/memory/file', { params: { path } }), // Stats stats: () => @@ -118,7 +126,7 @@ export function fileIcon(node: WorkspaceNode): string { if (node.type === 'directory') return '📁'; const ext = node.name.split('.').pop()?.toLowerCase() ?? ''; const map: Record = { - md: '📝', txt: '📄', log: '📋', json: '🔧', yaml: '🔧', yml: '🔧', + md: '📝', txt: '📄', log: '📋', json: '🔧', jsonl: '🔧', yaml: '🔧', yml: '🔧', py: '🐍', js: '🟨', ts: '🔷', tsx: '🔷', jsx: '🟨', sh: '⚙️', bash: '⚙️', csv: '📊', pdf: '📕', png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', zip: '🗜️', tar: '🗜️', gz: '🗜️', diff --git a/webui/src/locales/en-US/workspace.json b/webui/src/locales/en-US/workspace.json index 599d18161..afaf03fb8 100644 --- a/webui/src/locales/en-US/workspace.json +++ b/webui/src/locales/en-US/workspace.json @@ -26,6 +26,7 @@ "close": "Close", "uploading": "Uploading...", "binaryPreview": "Binary files cannot be previewed", + "truncatedPreview": "This file is large, so only the first {{limit}} is previewed. Inline editing is disabled to avoid saving truncated content; download the file for the full contents.", "downloadFile": "Download File", "toast": { "loadDirFailed": "Failed to load directory", diff --git a/webui/src/locales/zh-CN/workspace.json b/webui/src/locales/zh-CN/workspace.json index 8066f22d7..dbdc9fa52 100644 --- a/webui/src/locales/zh-CN/workspace.json +++ b/webui/src/locales/zh-CN/workspace.json @@ -26,6 +26,7 @@ "close": "关闭", "uploading": "上传中...", "binaryPreview": "二进制文件无法预览", + "truncatedPreview": "文件较大,当前仅预览前 {{limit}} 内容。为避免误保存截断内容,已禁用在线编辑;如需完整内容请下载文件。", "downloadFile": "下载文件", "toast": { "loadDirFailed": "加载目录失败", diff --git a/webui/src/pages/Workspace/index.test.tsx b/webui/src/pages/Workspace/index.test.tsx new file mode 100644 index 000000000..678bc0ab7 --- /dev/null +++ b/webui/src/pages/Workspace/index.test.tsx @@ -0,0 +1,210 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import WorkspacePage from './index'; +import { renderWithRouter } from '@/test/helpers'; + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + deleteFile: vi.fn(), + deleteDir: vi.fn(), + upload: vi.fn(), + createDir: vi.fn(), + listMemory: vi.fn(), + readMemoryFile: vi.fn(), + confirm: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +const translations: Record = { + description: 'Workspace files', + 'tabs.files': 'Files', + 'tabs.memory': 'Memory', + 'files.columns.name': 'Name', + 'files.columns.size': 'Size', + 'files.columns.modified': 'Modified', + 'files.refresh': 'Refresh', + 'files.newDir': 'New directory', + 'files.upload': 'Upload', + 'files.back': 'Back', + 'files.delete': 'Delete', + 'files.download': 'Download', + 'files.downloadFile': 'Download file', + 'files.binaryPreview': 'Binary file cannot be previewed', + 'files.truncatedPreview': 'Preview truncated to first {{limit}}', + 'files.emptyDir': 'Empty directory', + 'files.dropHere': 'Drop files here', + 'files.uploading': 'Uploading', + 'files.edit': 'Edit', + 'files.save': 'Save', + 'files.cancel': 'Cancel', + 'files.close': 'Close', + 'files.create': 'Create', + 'files.dirNamePlaceholder': 'Folder name', + 'files.confirm.deleteTitle': 'Delete file', + 'files.confirm.deleteBtn': 'Delete', + 'files.toast.deleteSuccess': 'Deleted', + 'files.toast.deleteFailed': 'Delete failed', + 'files.toast.loadDirFailed': 'Load directory failed', +}; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + // Return a fresh function every render to mimic unstable hook dependencies. + t: (key: string, params?: Record) => { + if (key === 'files.confirm.deleteDesc') { + return `Delete ${params?.name ?? ''}`; + } + if (key === 'files.truncatedPreview') { + return `Preview truncated to first ${params?.limit ?? ''}`; + } + return translations[key] ?? key; + }, + i18n: { language: 'en-US' }, + }), +})); + +vi.mock('@/components/common/Toast', () => ({ + useToast: () => ({ + success: mocks.toastSuccess, + error: mocks.toastError, + }), +})); + +vi.mock('@/components/common/ConfirmDialog', () => ({ + useConfirm: () => mocks.confirm, +})); + +vi.mock('@/components/common/PageHeader', () => ({ + default: ({ title, description }: { title: string; description: string }) => ( +
+

{title}

+

{description}

+
+ ), +})); + +vi.mock('@/components/common/LoadingSpinner', () => ({ + default: () =>
Loading...
, +})); + +vi.mock('@/api/workspace', async () => { + const actual = await vi.importActual('@/api/workspace'); + return { + ...actual, + workspaceAPI: { + ...actual.workspaceAPI, + list: mocks.list, + readFile: mocks.readFile, + writeFile: mocks.writeFile, + deleteFile: mocks.deleteFile, + deleteDir: mocks.deleteDir, + upload: mocks.upload, + createDir: mocks.createDir, + listMemory: mocks.listMemory, + readMemoryFile: mocks.readMemoryFile, + downloadUrl: (path: string) => `/api/workspace/download?path=${encodeURIComponent(path)}`, + }, + }; +}); + +function directory(name: string, path: string) { + return { + name, + path, + type: 'directory' as const, + modified_at: 1710000000, + }; +} + +function file(name: string, path: string, isTextFile = true) { + return { + name, + path, + type: 'file' as const, + size: 24, + modified_at: 1710000000, + is_text_file: isTextFile, + }; +} + +describe('WorkspacePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.readFile.mockResolvedValue({ data: { content: '' } }); + mocks.writeFile.mockResolvedValue({ data: { written: true } }); + mocks.deleteFile.mockResolvedValue({ data: { deleted: true } }); + mocks.deleteDir.mockResolvedValue({ data: { deleted: true } }); + mocks.upload.mockResolvedValue({ data: { uploaded: [] } }); + mocks.createDir.mockResolvedValue({ data: { created: true } }); + mocks.listMemory.mockResolvedValue({ data: [] }); + mocks.readMemoryFile.mockResolvedValue({ data: { content: '' } }); + mocks.confirm.mockResolvedValue(true); + }); + + it('删除子目录文件后保持在当前目录,不会重新加载根目录', async () => { + let reportsListCount = 0; + mocks.list.mockImplementation((path = '') => { + if (path === '') { + return Promise.resolve({ data: [directory('reports', 'reports')] }); + } + if (path === 'reports') { + reportsListCount += 1; + return Promise.resolve({ + data: reportsListCount === 1 + ? [file('triage_result_001.jsonl', 'reports/triage_result_001.jsonl')] + : [], + }); + } + return Promise.resolve({ data: [] }); + }); + + const user = userEvent.setup(); + renderWithRouter(); + + await user.click(await screen.findByText('reports')); + expect(await screen.findByText('triage_result_001.jsonl')).toBeInTheDocument(); + + await user.click(screen.getByTitle('Delete')); + + await waitFor(() => { + expect(mocks.deleteFile).toHaveBeenCalledWith('reports/triage_result_001.jsonl'); + }); + + await waitFor(() => { + expect(screen.getByText('Empty directory')).toBeInTheDocument(); + }); + + expect(mocks.list.mock.calls.filter(([path]) => path === '')).toHaveLength(1); + expect(mocks.list.mock.calls.filter(([path]) => path === 'reports')).toHaveLength(2); + expect(mocks.toastSuccess).toHaveBeenCalledWith('Deleted'); + }); + + it('大文件预览被截断时显示提示并禁用编辑', async () => { + mocks.list.mockResolvedValue({ + data: [file('events.jsonl', 'events.jsonl')], + }); + mocks.readFile.mockResolvedValue({ + data: { + path: 'events.jsonl', + content: '{"id":1}\n', + truncated: true, + preview_limit_bytes: 16, + size: 1024, + }, + }); + + const user = userEvent.setup(); + renderWithRouter(); + + await user.click(await screen.findByText('events.jsonl')); + + expect(await screen.findByText('Preview truncated to first 16 B')).toBeInTheDocument(); + expect(screen.getByText('{"id":1}')).toBeInTheDocument(); + expect(screen.queryByTitle('Edit')).not.toBeInTheDocument(); + }); +}); diff --git a/webui/src/pages/Workspace/index.tsx b/webui/src/pages/Workspace/index.tsx index 35af60cbd..7f20a6652 100644 --- a/webui/src/pages/Workspace/index.tsx +++ b/webui/src/pages/Workspace/index.tsx @@ -22,17 +22,19 @@ interface PanelState { node: WorkspaceNode | null; content: string | null; editContent: string | null; + truncated: boolean; + previewLimitBytes: number | null; editing: boolean; saving: boolean; } const PANEL_INIT: PanelState = { - node: null, content: null, editContent: null, editing: false, saving: false, + node: null, content: null, editContent: null, truncated: false, previewLimitBytes: null, editing: false, saving: false, }; type PanelAction = | { type: 'select'; node: WorkspaceNode } - | { type: 'content_loaded'; content: string } + | { type: 'content_loaded'; content: string; truncated?: boolean; previewLimitBytes?: number | null } | { type: 'start_edit' } | { type: 'edit_change'; text: string } | { type: 'save_start' } @@ -45,7 +47,12 @@ function panelReducer(state: PanelState, action: PanelAction): PanelState { case 'select': return { ...PANEL_INIT, node: action.node }; case 'content_loaded': - return { ...state, content: action.content }; + return { + ...state, + content: action.content, + truncated: action.truncated ?? false, + previewLimitBytes: action.previewLimitBytes ?? null, + }; case 'start_edit': return { ...state, editing: true, editContent: state.content ?? '' }; case 'edit_change': @@ -108,7 +115,7 @@ function TabButton({ active, onClick, icon, label }: { // ─── Files Tab ──────────────────────────────────────────────────────────── function FilesTab() { - const toast = useToast(); + const { success: toastSuccess, error: toastError } = useToast(); const confirm = useConfirm(); const { t } = useTranslation('workspace'); @@ -126,29 +133,50 @@ function FilesTab() { const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); + const latestDirRequestIdRef = useRef(0); + const didInitRef = useRef(false); const loadFileContent = useCallback(async (path: string) => { const res = await workspaceAPI.readFile(path); - dispatchPanel({ type: 'content_loaded', content: res.data.content }); + dispatchPanel({ + type: 'content_loaded', + content: res.data.content, + truncated: res.data.truncated, + previewLimitBytes: res.data.preview_limit_bytes, + }); }, []); const loadDir = useCallback(async (path: string, options?: { preservePanel?: boolean }) => { + const requestId = latestDirRequestIdRef.current + 1; + latestDirRequestIdRef.current = requestId; setLoading(true); if (!options?.preservePanel) { dispatchPanel({ type: 'close' }); } try { const res = await workspaceAPI.list(path); + if (requestId !== latestDirRequestIdRef.current) { + return; + } setItems(Array.isArray(res.data) ? res.data : []); setCurrentPath(path); } catch (e: any) { - toast.error(t('files.toast.loadDirFailed'), e?.response?.data?.detail ?? e.message); + if (requestId !== latestDirRequestIdRef.current) { + return; + } + toastError(t('files.toast.loadDirFailed'), e?.response?.data?.detail ?? e.message); } finally { - setLoading(false); + if (requestId === latestDirRequestIdRef.current) { + setLoading(false); + } } - }, [toast, t]); + }, [t, toastError]); useEffect(() => { + if (didInitRef.current) { + return; + } + didInitRef.current = true; loadDir(''); }, [loadDir]); @@ -162,10 +190,10 @@ function FilesTab() { try { await loadFileContent(node.path); } catch (e: any) { - toast.error(t('files.toast.readFileFailed'), e?.response?.data?.detail ?? e.message); + toastError(t('files.toast.readFileFailed'), e?.response?.data?.detail ?? e.message); } } - }, [loadDir, loadFileContent, toast, t]); + }, [loadDir, loadFileContent, toastError, t]); const handleRefresh = useCallback(async () => { await loadDir(currentPath, { preservePanel: true }); @@ -174,24 +202,24 @@ function FilesTab() { try { await loadFileContent(panel.node.path); } catch (e: any) { - toast.error(t('files.toast.readFileFailed'), e?.response?.data?.detail ?? e.message); + toastError(t('files.toast.readFileFailed'), e?.response?.data?.detail ?? e.message); } } - }, [currentPath, loadDir, loadFileContent, panel.node, toast, t]); + }, [currentPath, loadDir, loadFileContent, panel.node, toastError, t]); const handleSave = useCallback(async () => { - if (!panel.node || panel.editContent === null) return; + if (!panel.node || panel.editContent === null || panel.truncated) return; dispatchPanel({ type: 'save_start' }); try { await workspaceAPI.writeFile(panel.node.path, panel.editContent); dispatchPanel({ type: 'save_done', content: panel.editContent }); - toast.success(t('files.toast.saveSuccess')); + toastSuccess(t('files.toast.saveSuccess')); loadDir(currentPath); } catch (e: any) { dispatchPanel({ type: 'cancel_edit' }); - toast.error(t('files.toast.saveFailed'), e?.response?.data?.detail ?? e.message); + toastError(t('files.toast.saveFailed'), e?.response?.data?.detail ?? e.message); } - }, [panel.node, panel.editContent, currentPath, loadDir, toast]); + }, [panel.node, panel.editContent, currentPath, loadDir, toastError, toastSuccess, t]); const handleDelete = useCallback(async (node: WorkspaceNode) => { const ok = await confirm({ @@ -207,13 +235,13 @@ function FilesTab() { } else { await workspaceAPI.deleteDir(node.path); } - toast.success(t('files.toast.deleteSuccess')); + toastSuccess(t('files.toast.deleteSuccess')); if (panel.node?.path === node.path) dispatchPanel({ type: 'close' }); loadDir(currentPath); } catch (e: any) { - toast.error(t('files.toast.deleteFailed'), e?.response?.data?.detail ?? e.message); + toastError(t('files.toast.deleteFailed'), e?.response?.data?.detail ?? e.message); } - }, [confirm, panel.node, currentPath, loadDir, toast]); + }, [confirm, panel.node, currentPath, loadDir, toastError, toastSuccess, t]); const handleUpload = useCallback(async (files: FileList | null) => { if (!files || files.length === 0) return; @@ -223,15 +251,15 @@ function FilesTab() { const uploaded = res.data.uploaded; const errors = uploaded.filter((u) => u.error); const ok = uploaded.filter((u) => !u.error); - if (ok.length > 0) toast.success(t('files.toast.uploadSuccess', { count: ok.length })); - if (errors.length > 0) toast.error(t('files.toast.uploadPartialFail', { count: errors.length }), errors.map((e) => e.error).join('; ')); + if (ok.length > 0) toastSuccess(t('files.toast.uploadSuccess', { count: ok.length })); + if (errors.length > 0) toastError(t('files.toast.uploadPartialFail', { count: errors.length }), errors.map((e) => e.error).join('; ')); loadDir(currentPath); } catch (e: any) { - toast.error(t('files.toast.uploadFailed'), e?.response?.data?.detail ?? e.message); + toastError(t('files.toast.uploadFailed'), e?.response?.data?.detail ?? e.message); } finally { setUploading(false); } - }, [currentPath, loadDir, toast]); + }, [currentPath, loadDir, toastError, toastSuccess, t]); const handleCreateDir = useCallback(async () => { const name = newDir.name.trim(); @@ -242,9 +270,9 @@ function FilesTab() { setNewDir({ show: false, name: '' }); loadDir(currentPath); } catch (e: any) { - toast.error(t('files.toast.createDirFailed'), e?.response?.data?.detail ?? e.message); + toastError(t('files.toast.createDirFailed'), e?.response?.data?.detail ?? e.message); } - }, [newDir.name, currentPath, loadDir, toast]); + }, [newDir.name, currentPath, loadDir, toastError, t]); const breadcrumbs = currentPath ? ['', ...currentPath.split('/')] : ['']; @@ -407,7 +435,7 @@ function FilesTab() { {fileIcon(panel.node)} {panel.node.name}
- {panel.node.is_text_file && !panel.editing && ( + {panel.node.is_text_file && !panel.editing && !panel.truncated && ( @@ -438,18 +466,25 @@ function FilesTab() {
{panel.node.is_text_file ? ( - panel.editing ? ( -