diff --git a/web/src/components/ContextMenu.jsx b/web/src/components/ContextMenu.jsx index 406b710..b9df6db 100644 --- a/web/src/components/ContextMenu.jsx +++ b/web/src/components/ContextMenu.jsx @@ -5,6 +5,9 @@ import { PencilIcon, TrashIcon, EyeIcon, + ArrowDownTrayIcon, + ArrowUpTrayIcon, + ClipboardDocumentIcon, } from '@heroicons/react/24/outline'; import { isFileOrPathInsideSymlink } from '../utils/helpers'; @@ -18,6 +21,9 @@ export default function ContextMenu({ onRename, onDelete, onView, + onDownload, + onUpload, + onCopyPath, canModify, childrenCache = {}, agentId = 'coo', @@ -74,8 +80,58 @@ export default function ContextMenu({ )} + {/* Download option - files only */} + {!isDirectory && ( + + )} + + {/* Upload option - directories only */} + {isDirectory && ( + + )} + + {/* Copy path option - available to all users */} + + {/* Separator */} - {!isDirectory &&
} +
{/* Modification options - visible to all, disabled for non-admin or symlinks */} {/* New File - only for directories */} diff --git a/web/src/components/WorkspaceExplorer.jsx b/web/src/components/WorkspaceExplorer.jsx index 5bd0696..bc67953 100644 --- a/web/src/components/WorkspaceExplorer.jsx +++ b/web/src/components/WorkspaceExplorer.jsx @@ -14,6 +14,7 @@ import { ChevronUpDownIcon, EyeIcon, EyeSlashIcon, + ArrowUpTrayIcon, } from '@heroicons/react/24/outline'; import { useWorkspaceStore } from '../stores/workspaceStore'; import { useAuthStore } from '../stores/authStore'; @@ -27,6 +28,10 @@ import CreateFolderModal from './CreateFolderModal'; import RenameModal from './RenameModal'; import DeleteConfirmModal from './DeleteConfirmModal'; import { classNames, isPathInsideSymlink, isFileOrPathInsideSymlink } from '../utils/helpers'; +import { + extractAgentIdFromWorkspacePath, + isAbsoluteWorkspacePath, +} from '../utils/workspacePaths'; import { useAgentStore } from '../stores/agentStore'; /** @@ -74,6 +79,9 @@ export default function WorkspaceExplorer({ setWorkspaceRootPath, clearErrors, moveFile, + createFile, + updateFile, + fetchFileContent, } = useWorkspaceStore(); const { isAdmin } = useAuthStore(); @@ -101,6 +109,8 @@ export default function WorkspaceExplorer({ // Context menu state const [contextMenu, setContextMenu] = useState(null); + const [uploadTargetPath, setUploadTargetPath] = useState('/'); + const fileUploadInputRef = useRef(null); const canModify = isAdmin(); @@ -550,6 +560,192 @@ export default function WorkspaceExplorer({ setContextMenu(null); }; + const readBrowserFileAsBase64 = (browserFile) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result || ''); + const base64 = result.includes(',') ? result.split(',')[1] : result; + resolve(base64); + }; + reader.onerror = () => reject(new Error(`Failed to read ${browserFile.name}`)); + reader.readAsDataURL(browserFile); + }); + + const triggerUpload = (file) => { + const targetPath = file?.type === 'directory' ? file.path : currentPath; + + if (isPathInsideSymlink(targetPath, childrenCache, agentId)) { + showToast('Cannot upload files inside symlink directories', 'error'); + return; + } + + if (!canModify) { + showToast('Admin access required to upload files', 'error'); + return; + } + + setUploadTargetPath(targetPath); + fileUploadInputRef.current?.click(); + }; + + const handleUploadFiles = async (event) => { + const fileList = Array.from(event.target.files || []); + event.target.value = ''; + + if (!fileList.length) return; + + if (isPathInsideSymlink(uploadTargetPath, childrenCache, agentId)) { + showToast('Cannot upload files inside symlink directories', 'error'); + return; + } + + let uploadedCount = 0; + let failedCount = 0; + + for (const browserFile of fileList) { + const destinationPath = + uploadTargetPath === '/' + ? `/${browserFile.name}` + : `${uploadTargetPath}/${browserFile.name}`; + const rawPath = isAbsoluteWorkspacePath(destinationPath); + const destinationAgentId = extractAgentIdFromWorkspacePath(destinationPath) || agentId; + + try { + const base64Content = await readBrowserFileAsBase64(browserFile); + + try { + await createFile({ + path: destinationPath, + content: base64Content, + encoding: 'base64', + agentId: destinationAgentId, + rawPath, + }); + } catch (error) { + const isConflict = + error?.message?.includes('already exists') || error?.message?.includes('FILE_EXISTS'); + + if (!isConflict) { + throw error; + } + + const shouldOverwrite = window.confirm( + `"${browserFile.name}" already exists. Overwrite it?`, + ); + if (!shouldOverwrite) { + failedCount += 1; + continue; + } + + await updateFile({ + path: destinationPath, + content: base64Content, + encoding: 'base64', + agentId: destinationAgentId, + rawPath, + }); + } + + uploadedCount += 1; + } catch { + failedCount += 1; + } + } + + // Refresh absolute workspace directories without re-prepending workspaceRootPath. + await fetchListing({ + path: uploadTargetPath, + recursive: false, + force: true, + agentId: extractAgentIdFromWorkspacePath(uploadTargetPath) || agentId, + rawPath: isAbsoluteWorkspacePath(uploadTargetPath), + }); + + if (uploadedCount > 0) { + showToast( + failedCount > 0 + ? `Uploaded ${uploadedCount} file(s), ${failedCount} failed` + : `Uploaded ${uploadedCount} file(s)`, + failedCount > 0 ? 'info' : 'success', + ); + } else { + showToast('No files were uploaded', 'error'); + } + }; + + const decodeBase64ToUint8Array = (value) => { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + }; + + const handleDownloadFile = async (file) => { + if (!file || file.type !== 'file') return; + + try { + const isWorkspacePath = isAbsoluteWorkspacePath(file.path); + const fileAgentId = + file.agentId || + (isWorkspacePath ? extractAgentIdFromWorkspacePath(file.path) : null) || + agentId; + const fileData = await fetchFileContent({ + path: file.path, + force: true, + agentId: fileAgentId, + rawPath: !!file.fullPath || isWorkspacePath, + }); + const encoding = fileData?.encoding || 'utf8'; + const rawContent = fileData?.content || ''; + + const blob = + encoding === 'base64' + ? new Blob([decodeBase64ToUint8Array(rawContent)], { + type: 'application/octet-stream', + }) + : new Blob([rawContent], { type: 'text/plain;charset=utf-8' }); + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + showToast(`Downloaded ${file.name}`, 'success'); + } catch (error) { + showToast(error?.message || 'Failed to download file', 'error'); + } + }; + + const toWorkspaceRelativePath = (filePath) => { + if (!filePath || typeof filePath !== 'string') return null; + + // Preserve workspace-root relative paths as-is (e.g. /docs/README.md) + if (!filePath.startsWith('/workspace')) return filePath; + + // If path includes a virtual workspace root prefix (e.g. /workspace-main/foo), + // strip it to keep clipboard values portable across pods/hosts. + const stripped = filePath.replace(/^\/workspace(?:-[^/]+)?/, ''); + return stripped || '/'; + }; + + const handleCopyPath = async (file) => { + const workspaceRelativePath = toWorkspaceRelativePath(file?.path); + if (!workspaceRelativePath) return; + + try { + await navigator.clipboard.writeText(workspaceRelativePath); + showToast('Workspace path copied to clipboard', 'success'); + } catch { + showToast('Failed to copy workspace path', 'error'); + } + }; + // CRUD handlers const handleNewFile = (file) => { const targetPath = file?.type === 'directory' ? file.path : currentPath; @@ -867,6 +1063,21 @@ export default function WorkspaceExplorer({ New Folder +
{/* Search */} @@ -1115,12 +1326,23 @@ export default function WorkspaceExplorer({ onRename={handleRename} onDelete={handleDelete} onView={handleView} + onDownload={handleDownloadFile} + onUpload={triggerUpload} + onCopyPath={handleCopyPath} canModify={canModify} childrenCache={childrenCache} agentId={agentId} /> )} + + {/* Modals */} ({ }, // Fetch workspace file listing - fetchListing: async ({ path = '/', recursive = false, force = false, agentId = 'coo' }) => { + fetchListing: async ({ + path = '/', + recursive = false, + force = false, + agentId = 'coo', + rawPath = false, + }) => { const state = get(); const rootPath = state.workspaceRootPath; - const fullPath = rootPath && !path.startsWith(rootPath) ? `${rootPath}${path}` : path; + const fullPath = + rawPath || !rootPath || path.startsWith(rootPath) ? path : `${rootPath}${path}`; const cacheKey = `${agentId}:${path}:${recursive}`; // Return cached if available and not forced @@ -265,11 +272,12 @@ export const useWorkspaceStore = create((set, get) => ({ }, // Create a new file - createFile: async ({ path, content = '', encoding = 'utf8', agentId = 'coo' }) => { + createFile: async ({ path, content = '', encoding = 'utf8', agentId = 'coo', rawPath = false }) => { try { const state = get(); const rootPath = state.workspaceRootPath; - const fullPath = rootPath && !path.startsWith(rootPath) ? `${rootPath}${path}` : path; + const fullPath = + rawPath || !rootPath || path.startsWith(rootPath) ? path : `${rootPath}${path}`; const response = await api.post('/openclaw/workspace/files', { path: fullPath, @@ -291,11 +299,12 @@ export const useWorkspaceStore = create((set, get) => ({ }, // Update an existing file - updateFile: async ({ path, content, encoding = 'utf8', agentId = 'coo' }) => { + updateFile: async ({ path, content, encoding = 'utf8', agentId = 'coo', rawPath = false }) => { try { const state = get(); const rootPath = state.workspaceRootPath; - const fullPath = rootPath && !path.startsWith(rootPath) ? `${rootPath}${path}` : path; + const fullPath = + rawPath || !rootPath || path.startsWith(rootPath) ? path : `${rootPath}${path}`; const response = await api.put('/openclaw/workspace/files', { path: fullPath, diff --git a/web/src/stores/workspaceStore.test.js b/web/src/stores/workspaceStore.test.js index 85c469f..2b22d29 100644 --- a/web/src/stores/workspaceStore.test.js +++ b/web/src/stores/workspaceStore.test.js @@ -114,6 +114,20 @@ describe('workspaceStore', () => { expect(result.files[1].path).toBe('/external/file.txt'); }); + it('honors rawPath for absolute listing refreshes', async () => { + useWorkspaceStore.getState().setWorkspaceRootPath('/workspaces/main'); + api.get.mockResolvedValue({ data: { data: { files: [] } } }); + + await useWorkspaceStore.getState().fetchListing({ + path: '/workspace-coo/docs', + rawPath: true, + }); + + expect(api.get).toHaveBeenCalledWith('/openclaw/workspace/files', { + params: { path: '/workspace-coo/docs', recursive: 'false' }, + }); + }); + it('sets listing error with fallback message when request fails', async () => { api.get.mockRejectedValue(new Error('Network down')); @@ -263,6 +277,23 @@ describe('workspaceStore', () => { expect(useWorkspaceStore.getState().listings['coo:/src:true']).toBeUndefined(); }); + it('createFile honors rawPath for absolute workspace destinations', async () => { + useWorkspaceStore.getState().setWorkspaceRootPath('/workspaces/main'); + api.post.mockResolvedValue({ data: { data: { path: '/workspace-coo/src/new.js' } } }); + + await useWorkspaceStore.getState().createFile({ + path: '/workspace-coo/src/new.js', + content: 'console.log(1)', + rawPath: true, + }); + + expect(api.post).toHaveBeenCalledWith('/openclaw/workspace/files', { + path: '/workspace-coo/src/new.js', + content: 'console.log(1)', + encoding: 'utf8', + }); + }); + it('updateFile updates content and invalidates related caches', async () => { useWorkspaceStore.setState({ listings: { @@ -291,6 +322,23 @@ describe('workspaceStore', () => { expect(useWorkspaceStore.getState().listings['coo:/src:true']).toBeUndefined(); }); + it('updateFile honors rawPath for absolute workspace destinations', async () => { + useWorkspaceStore.getState().setWorkspaceRootPath('/workspaces/main'); + api.put.mockResolvedValue({ data: { data: { updated: true } } }); + + await useWorkspaceStore.getState().updateFile({ + path: '/workspace-coo/src/new.js', + content: 'new', + rawPath: true, + }); + + expect(api.put).toHaveBeenCalledWith('/openclaw/workspace/files', { + path: '/workspace-coo/src/new.js', + content: 'new', + encoding: 'utf8', + }); + }); + it('deleteFile clears selected file and caches', async () => { useWorkspaceStore.setState({ selectedFile: { path: '/src/new.js' }, diff --git a/web/src/utils/workspacePaths.js b/web/src/utils/workspacePaths.js new file mode 100644 index 0000000..2c392b9 --- /dev/null +++ b/web/src/utils/workspacePaths.js @@ -0,0 +1,11 @@ +// Absolute workspace paths bypass the store's workspaceRootPath prefixing. +export const isAbsoluteWorkspacePath = (value) => + typeof value === 'string' && + (value.startsWith('/workspace-') || value.startsWith('/workspace/')); + +export const extractAgentIdFromWorkspacePath = (value) => { + if (typeof value !== 'string') return null; + + const match = value.match(/^\/workspace-([^/]+)(?:\/|$)/); + return match ? match[1] : null; +}; diff --git a/web/src/utils/workspacePaths.test.js b/web/src/utils/workspacePaths.test.js new file mode 100644 index 0000000..9ee45a7 --- /dev/null +++ b/web/src/utils/workspacePaths.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { extractAgentIdFromWorkspacePath, isAbsoluteWorkspacePath } from './workspacePaths'; + +describe('workspacePaths', () => { + describe('isAbsoluteWorkspacePath', () => { + it('detects absolute agent workspace prefixes', () => { + expect(isAbsoluteWorkspacePath('/workspace-coo/docs/guide.md')).toBe(true); + expect(isAbsoluteWorkspacePath('/workspace/docs/guide.md')).toBe(true); + }); + + it('rejects workspace-relative paths', () => { + expect(isAbsoluteWorkspacePath('/docs/guide.md')).toBe(false); + expect(isAbsoluteWorkspacePath('docs/guide.md')).toBe(false); + expect(isAbsoluteWorkspacePath(null)).toBe(false); + }); + }); + + describe('extractAgentIdFromWorkspacePath', () => { + it('extracts the agent id from absolute agent workspace paths', () => { + expect(extractAgentIdFromWorkspacePath('/workspace-coo/docs/guide.md')).toBe('coo'); + expect(extractAgentIdFromWorkspacePath('/workspace-lead')).toBe('lead'); + }); + + it('returns null for shared or relative paths', () => { + expect(extractAgentIdFromWorkspacePath('/workspace/docs/guide.md')).toBe(null); + expect(extractAgentIdFromWorkspacePath('/docs/guide.md')).toBe(null); + expect(extractAgentIdFromWorkspacePath(undefined)).toBe(null); + }); + }); +});