From b10591ee60a8627d36f4931c0ad6bb0ac1cbfa4c Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 7 Jul 2025 18:14:14 +0200 Subject: [PATCH 01/21] Add file upload functionality to FileActions and FileTree components --- app/src/components/files/FileActions.tsx | 47 +++++++- app/src/components/files/FileTree.tsx | 131 +++++++++++++++++++++-- 2 files changed, 170 insertions(+), 8 deletions(-) diff --git a/app/src/components/files/FileActions.tsx b/app/src/components/files/FileActions.tsx index a3f0075..8ef048e 100644 --- a/app/src/components/files/FileActions.tsx +++ b/app/src/components/files/FileActions.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { ActionIcon, Tooltip, Group } from '@mantine/core'; import { IconPlus, IconTrash, IconGitPullRequest, IconGitCommit, + IconUpload, } from '@tabler/icons-react'; import { useModalContext } from '../../contexts/ModalContext'; import { useWorkspace } from '../../hooks/useWorkspace'; @@ -12,11 +13,13 @@ import { useWorkspace } from '../../hooks/useWorkspace'; interface FileActionsProps { handlePullChanges: () => Promise; selectedFile: string | null; + onFileUpload?: (files: FileList) => Promise; } const FileActions: React.FC = ({ handlePullChanges, selectedFile, + onFileUpload, }) => { const { currentWorkspace } = useWorkspace(); const { @@ -25,10 +28,30 @@ const FileActions: React.FC = ({ setCommitMessageModalVisible, } = useModalContext(); + // Hidden file input for upload + const fileInputRef = useRef(null); + const handleCreateFile = (): void => setNewFileModalVisible(true); const handleDeleteFile = (): void => setDeleteFileModalVisible(true); const handleCommitAndPush = (): void => setCommitMessageModalVisible(true); + const handleUploadClick = (): void => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = ( + event: React.ChangeEvent + ): void => { + const files = event.target.files; + if (files && files.length > 0 && onFileUpload) { + onFileUpload(files).catch((error) => { + console.error('Error uploading files:', error); + }); + // Reset the input so the same file can be selected again + event.target.value = ''; + } + }; + return ( @@ -43,6 +66,18 @@ const FileActions: React.FC = ({ + + + + + + @@ -104,6 +139,16 @@ const FileActions: React.FC = ({ + + {/* Hidden file input */} + ); }; diff --git a/app/src/components/files/FileTree.tsx b/app/src/components/files/FileTree.tsx index 0c7e141..cc69954 100644 --- a/app/src/components/files/FileTree.tsx +++ b/app/src/components/files/FileTree.tsx @@ -1,7 +1,12 @@ -import React, { useRef, useState, useLayoutEffect } from 'react'; +import React, { useRef, useState, useLayoutEffect, useCallback } from 'react'; import { Tree, type NodeApi } from 'react-arborist'; -import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react'; -import { Tooltip } from '@mantine/core'; +import { + IconFile, + IconFolder, + IconFolderOpen, + IconUpload, +} from '@tabler/icons-react'; +import { Tooltip, Text, Box } from '@mantine/core'; import useResizeObserver from '@react-hook/resize-observer'; import type { FileNode } from '@/types/models'; @@ -14,6 +19,12 @@ interface FileTreeProps { files: FileNode[]; handleFileSelect: (filePath: string | null) => Promise; showHiddenFiles: boolean; + onFileMove?: ( + dragIds: string[], + parentId: string | null, + index: number + ) => Promise; + onFileUpload?: (files: FileList, targetPath?: string) => Promise; } const useSize = (target: React.RefObject): Size | undefined => { @@ -40,7 +51,7 @@ const FileIcon = ({ node }: { node: NodeApi }) => { ); }; -// Define a Node component that matches what React-Arborist expects +// Enhanced Node component with drag handle function Node({ node, style, @@ -52,7 +63,6 @@ function Node({ style: React.CSSProperties; dragHandle?: React.Ref; onNodeClick?: (node: NodeApi) => void; - // Accept any extra props from Arborist, but do not use an index signature } & Record) { const handleClick = () => { if (node.isInternal) { @@ -65,7 +75,7 @@ function Node({ return (
= ({ files, handleFileSelect, showHiddenFiles, + onFileMove, + onFileUpload, }) => { const target = useRef(null); const size = useSize(target); + // State for drag and drop overlay + const [isDragOver, setIsDragOver] = useState(false); + const filteredFiles = files.filter((file) => { if (file.name.startsWith('.') && !showHiddenFiles) { return false; @@ -118,11 +135,110 @@ const FileTree: React.FC = ({ } }; + // Handle file movement within the tree + const handleMove = useCallback( + async ({ + dragIds, + parentId, + index, + }: { + dragIds: string[]; + parentId: string | null; + index: number; + }) => { + if (onFileMove) { + await onFileMove(dragIds, parentId, index); + } + }, + [onFileMove] + ); + + // External file drag and drop handlers + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Check if drag contains files (not internal tree nodes) + if (e.dataTransfer.types.includes('Files')) { + setIsDragOver(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only hide overlay when leaving the container itself + if (e.currentTarget === e.target) { + setIsDragOver(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Set the drop effect to indicate this is a valid drop target + e.dataTransfer.dropEffect = 'copy'; + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsDragOver(false); + + const { files } = e.dataTransfer; + if (files && files.length > 0 && onFileUpload) { + // Handle the upload without awaiting to avoid the eslint warning + onFileUpload(files).catch((error) => { + console.error('Error uploading files:', error); + }); + } + }, + [onFileUpload] + ); + return (
+ {/* Drag overlay */} + {isDragOver && ( + + + + Drop files here to upload + + + )} + {size && ( = ({ height={size.height} indent={24} rowHeight={28} + onMove={handleMove} onActivate={(node) => { const fileNode = node.data; if (!node.isInternal) { From 4a3df3a040b39704b2a8f5367f353f42c01ae2c2 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 7 Jul 2025 19:13:25 +0200 Subject: [PATCH 02/21] Implement file upload functionality in FileActions and FileTree components --- app/src/components/files/FileActions.tsx | 25 +++++-- app/src/components/files/FileTree.tsx | 48 +++++++------ app/src/components/layout/Sidebar.tsx | 7 +- app/src/hooks/useFileOperations.ts | 86 +++++++++++++++++++++++- 4 files changed, 139 insertions(+), 27 deletions(-) diff --git a/app/src/components/files/FileActions.tsx b/app/src/components/files/FileActions.tsx index 8ef048e..83bf7ae 100644 --- a/app/src/components/files/FileActions.tsx +++ b/app/src/components/files/FileActions.tsx @@ -9,17 +9,18 @@ import { } from '@tabler/icons-react'; import { useModalContext } from '../../contexts/ModalContext'; import { useWorkspace } from '../../hooks/useWorkspace'; +import { useFileOperations } from '../../hooks/useFileOperations'; interface FileActionsProps { handlePullChanges: () => Promise; selectedFile: string | null; - onFileUpload?: (files: FileList) => Promise; + loadFileList: () => Promise; } const FileActions: React.FC = ({ handlePullChanges, selectedFile, - onFileUpload, + loadFileList, }) => { const { currentWorkspace } = useWorkspace(); const { @@ -28,6 +29,8 @@ const FileActions: React.FC = ({ setCommitMessageModalVisible, } = useModalContext(); + const { handleUpload } = useFileOperations(); + // Hidden file input for upload const fileInputRef = useRef(null); @@ -43,10 +46,20 @@ const FileActions: React.FC = ({ event: React.ChangeEvent ): void => { const files = event.target.files; - if (files && files.length > 0 && onFileUpload) { - onFileUpload(files).catch((error) => { - console.error('Error uploading files:', error); - }); + if (files && files.length > 0) { + const uploadFiles = async () => { + try { + const success = await handleUpload(files); + if (success) { + await loadFileList(); + } + } catch (error) { + console.error('Error uploading files:', error); + } + }; + + void uploadFiles(); + // Reset the input so the same file can be selected again event.target.value = ''; } diff --git a/app/src/components/files/FileTree.tsx b/app/src/components/files/FileTree.tsx index cc69954..67f0c9e 100644 --- a/app/src/components/files/FileTree.tsx +++ b/app/src/components/files/FileTree.tsx @@ -8,6 +8,7 @@ import { } from '@tabler/icons-react'; import { Tooltip, Text, Box } from '@mantine/core'; import useResizeObserver from '@react-hook/resize-observer'; +import { useFileOperations } from '../../hooks/useFileOperations'; import type { FileNode } from '@/types/models'; interface Size { @@ -19,12 +20,7 @@ interface FileTreeProps { files: FileNode[]; handleFileSelect: (filePath: string | null) => Promise; showHiddenFiles: boolean; - onFileMove?: ( - dragIds: string[], - parentId: string | null, - index: number - ) => Promise; - onFileUpload?: (files: FileList, targetPath?: string) => Promise; + loadFileList: () => Promise; } const useSize = (target: React.RefObject): Size | undefined => { @@ -111,11 +107,11 @@ const FileTree: React.FC = ({ files, handleFileSelect, showHiddenFiles, - onFileMove, - onFileUpload, + loadFileList, }) => { const target = useRef(null); const size = useSize(target); + const { handleMove, handleUpload } = useFileOperations(); // State for drag and drop overlay const [isDragOver, setIsDragOver] = useState(false); @@ -136,7 +132,7 @@ const FileTree: React.FC = ({ }; // Handle file movement within the tree - const handleMove = useCallback( + const handleTreeMove = useCallback( async ({ dragIds, parentId, @@ -146,11 +142,16 @@ const FileTree: React.FC = ({ parentId: string | null; index: number; }) => { - if (onFileMove) { - await onFileMove(dragIds, parentId, index); + try { + const success = await handleMove(dragIds, parentId, index); + if (success) { + await loadFileList(); + } + } catch (error) { + console.error('Error moving files:', error); } }, - [onFileMove] + [handleMove, loadFileList] ); // External file drag and drop handlers @@ -189,14 +190,22 @@ const FileTree: React.FC = ({ setIsDragOver(false); const { files } = e.dataTransfer; - if (files && files.length > 0 && onFileUpload) { - // Handle the upload without awaiting to avoid the eslint warning - onFileUpload(files).catch((error) => { - console.error('Error uploading files:', error); - }); + if (files && files.length > 0) { + const uploadFiles = async () => { + try { + const success = await handleUpload(files); + if (success) { + await loadFileList(); + } + } catch (error) { + console.error('Error uploading files:', error); + } + }; + + void uploadFiles(); } }, - [onFileUpload] + [handleUpload, loadFileList] ); return ( @@ -247,7 +256,8 @@ const FileTree: React.FC = ({ height={size.height} indent={24} rowHeight={28} - onMove={handleMove} + // Enable drag and drop for moving files + onMove={handleTreeMove} onActivate={(node) => { const fileNode = node.data; if (!node.isInternal) { diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index 135dbf0..f108838 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -37,11 +37,16 @@ const Sidebar: React.FC = ({ overflow: 'hidden', }} > - + ); diff --git a/app/src/hooks/useFileOperations.ts b/app/src/hooks/useFileOperations.ts index f34e32c..64fc7d7 100644 --- a/app/src/hooks/useFileOperations.ts +++ b/app/src/hooks/useFileOperations.ts @@ -9,6 +9,12 @@ interface UseFileOperationsResult { handleSave: (filePath: string, content: string) => Promise; handleDelete: (filePath: string) => Promise; handleCreate: (fileName: string, initialContent?: string) => Promise; + handleUpload: (files: FileList, targetPath?: string) => Promise; + handleMove: ( + dragIds: string[], + parentId: string | null, + index: number + ) => Promise; } export const useFileOperations = (): UseFileOperationsResult => { @@ -109,5 +115,83 @@ export const useFileOperations = (): UseFileOperationsResult => { [currentWorkspace, autoCommit] ); - return { handleSave, handleDelete, handleCreate }; + // Add these to your hook implementation: + const handleUpload = useCallback( + async (files: FileList, targetPath?: string): Promise => { + if (!currentWorkspace) return false; + + try { + // TODO: Implement your file upload API call + // Example: + // const formData = new FormData(); + // Array.from(files).forEach((file, index) => { + // formData.append(`file${index}`, file); + // }); + // if (targetPath) { + // formData.append('targetPath', targetPath); + // } + // await uploadFiles(currentWorkspace.name, formData); + + notifications.show({ + title: 'Success', + message: `Successfully uploaded ${files.length} file(s)`, + color: 'green', + }); + + // Auto-commit if enabled + await autoCommit('multiple files', FileAction.Create); + return true; + } catch (error) { + console.error('Error uploading files:', error); + notifications.show({ + title: 'Error', + message: 'Failed to upload files', + color: 'red', + }); + return false; + } + }, + [currentWorkspace, autoCommit] + ); + + const handleMove = useCallback( + async ( + dragIds: string[], + parentId: string | null, + index: number + ): Promise => { + if (!currentWorkspace) return false; + + try { + // TODO: Implement your file move API call + // Example: + // await moveFiles(currentWorkspace.name, { + // sourceIds: dragIds, + // targetParentId: parentId, + // targetIndex: index + // }); + + notifications.show({ + title: 'Success', + message: `Successfully moved ${dragIds.length} file(s)`, + color: 'green', + }); + + // Auto-commit if enabled + await autoCommit('multiple files', FileAction.Update); + return true; + } catch (error) { + console.error('Error moving files:', error); + notifications.show({ + title: 'Error', + message: 'Failed to move files', + color: 'red', + }); + return false; + } + }, + [currentWorkspace, autoCommit] + ); + + return { handleSave, handleDelete, handleCreate, handleUpload, handleMove }; }; From a789c62a68b6e9e9ace3d36efae512b301535db7 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 7 Jul 2025 20:18:36 +0200 Subject: [PATCH 03/21] Add rename file functionality with modal support --- app/src/components/files/FileActions.tsx | 18 +++ app/src/components/layout/MainContent.tsx | 58 ++++++++-- .../modals/file/RenameFileModal.tsx | 109 ++++++++++++++++++ app/src/contexts/ModalContext.tsx | 5 + app/src/hooks/useFileOperations.ts | 38 +++++- 5 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 app/src/components/modals/file/RenameFileModal.tsx diff --git a/app/src/components/files/FileActions.tsx b/app/src/components/files/FileActions.tsx index 83bf7ae..268308e 100644 --- a/app/src/components/files/FileActions.tsx +++ b/app/src/components/files/FileActions.tsx @@ -6,6 +6,7 @@ import { IconGitPullRequest, IconGitCommit, IconUpload, + IconEdit, } from '@tabler/icons-react'; import { useModalContext } from '../../contexts/ModalContext'; import { useWorkspace } from '../../hooks/useWorkspace'; @@ -27,6 +28,7 @@ const FileActions: React.FC = ({ setNewFileModalVisible, setDeleteFileModalVisible, setCommitMessageModalVisible, + setRenameFileModalVisible, } = useModalContext(); const { handleUpload } = useFileOperations(); @@ -36,6 +38,7 @@ const FileActions: React.FC = ({ const handleCreateFile = (): void => setNewFileModalVisible(true); const handleDeleteFile = (): void => setDeleteFileModalVisible(true); + const handleRenameFile = (): void => setRenameFileModalVisible(true); const handleCommitAndPush = (): void => setCommitMessageModalVisible(true); const handleUploadClick = (): void => { @@ -91,6 +94,21 @@ const FileActions: React.FC = ({ + + + + + + diff --git a/app/src/components/layout/MainContent.tsx b/app/src/components/layout/MainContent.tsx index a6bfc87..67f30b6 100644 --- a/app/src/components/layout/MainContent.tsx +++ b/app/src/components/layout/MainContent.tsx @@ -5,11 +5,13 @@ import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react'; import ContentView from '../editor/ContentView'; import CreateFileModal from '../modals/file/CreateFileModal'; import DeleteFileModal from '../modals/file/DeleteFileModal'; +import RenameFileModal from '../modals/file/RenameFileModal'; import CommitMessageModal from '../modals/git/CommitMessageModal'; import { useFileContent } from '../../hooks/useFileContent'; import { useFileOperations } from '../../hooks/useFileOperations'; import { useGitOperations } from '../../hooks/useGitOperations'; +import { useModalContext } from '../../contexts/ModalContext'; type ViewTab = 'source' | 'preview'; @@ -31,8 +33,10 @@ const MainContent: React.FC = ({ setHasUnsavedChanges, handleContentChange, } = useFileContent(selectedFile); - const { handleSave, handleCreate, handleDelete } = useFileOperations(); + const { handleSave, handleCreate, handleDelete, handleRename } = + useFileOperations(); const { handleCommitAndPush } = useGitOperations(); + const { setRenameFileModalVisible } = useModalContext(); const handleTabChange = useCallback((value: string | null): void => { if (value) { @@ -73,14 +77,50 @@ const MainContent: React.FC = ({ [handleDelete, handleFileSelect, loadFileList] ); + const handleRenameFile = useCallback( + async (oldPath: string, newPath: string): Promise => { + const success = await handleRename(oldPath, newPath); + if (success) { + await loadFileList(); + // If we renamed the currently selected file, update the selection + if (selectedFile === oldPath) { + await handleFileSelect(newPath); + } + } + }, + [handleRename, handleFileSelect, loadFileList, selectedFile] + ); + + const handleBreadcrumbClick = useCallback(() => { + if (selectedFile) { + setRenameFileModalVisible(true); + } + }, [selectedFile, setRenameFileModalVisible]); + const renderBreadcrumbs = useMemo(() => { if (!selectedFile) return null; const pathParts = selectedFile.split('/'); - const items = pathParts.map((part, index) => ( - - {part} - - )); + const items = pathParts.map((part, index) => { + // Make the filename (last part) clickable for rename + const isFileName = index === pathParts.length - 1; + return ( + + {part} + + ); + }); return ( @@ -93,7 +133,7 @@ const MainContent: React.FC = ({ )} ); - }, [selectedFile, hasUnsavedChanges]); + }, [selectedFile, hasUnsavedChanges, handleBreadcrumbClick]); return ( = ({ onDeleteFile={handleDeleteFile} selectedFile={selectedFile} /> + ); diff --git a/app/src/components/modals/file/RenameFileModal.tsx b/app/src/components/modals/file/RenameFileModal.tsx new file mode 100644 index 0000000..5845fd0 --- /dev/null +++ b/app/src/components/modals/file/RenameFileModal.tsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; +import { useModalContext } from '../../../contexts/ModalContext'; + +interface RenameFileModalProps { + onRenameFile: (oldPath: string, newPath: string) => Promise; + selectedFile: string | null; +} + +const RenameFileModal: React.FC = ({ + onRenameFile, + selectedFile, +}) => { + const [newFileName, setNewFileName] = useState(''); + const { renameFileModalVisible, setRenameFileModalVisible } = + useModalContext(); + + // Extract just the filename from the full path for editing + const getCurrentFileName = (filePath: string | null): string => { + if (!filePath) return ''; + const parts = filePath.split('/'); + return parts[parts.length - 1] || ''; + }; + + // Get the directory path (everything except the filename) + const getDirectoryPath = (filePath: string | null): string => { + if (!filePath) return ''; + const parts = filePath.split('/'); + return parts.slice(0, -1).join('/'); + }; + + // Set the current filename when modal opens or selectedFile changes + useEffect(() => { + if (renameFileModalVisible && selectedFile) { + setNewFileName(getCurrentFileName(selectedFile)); + } + }, [renameFileModalVisible, selectedFile]); + + const handleSubmit = async (): Promise => { + if (newFileName && selectedFile) { + const directoryPath = getDirectoryPath(selectedFile); + const newPath = directoryPath + ? `${directoryPath}/${newFileName.trim()}` + : newFileName.trim(); + + await onRenameFile(selectedFile, newPath); + setNewFileName(''); + setRenameFileModalVisible(false); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleSubmit(); + } + }; + + const handleClose = (): void => { + setNewFileName(''); + setRenameFileModalVisible(false); + }; + + return ( + + + setNewFileName(event.currentTarget.value)} + onKeyDown={handleKeyDown} + mb="md" + w="100%" + autoFocus + /> + + + + + + + ); +}; + +export default RenameFileModal; diff --git a/app/src/contexts/ModalContext.tsx b/app/src/contexts/ModalContext.tsx index fb5bcc4..ec48017 100644 --- a/app/src/contexts/ModalContext.tsx +++ b/app/src/contexts/ModalContext.tsx @@ -10,6 +10,8 @@ interface ModalContextType { setNewFileModalVisible: React.Dispatch>; deleteFileModalVisible: boolean; setDeleteFileModalVisible: React.Dispatch>; + renameFileModalVisible: boolean; + setRenameFileModalVisible: React.Dispatch>; commitMessageModalVisible: boolean; setCommitMessageModalVisible: React.Dispatch>; settingsModalVisible: boolean; @@ -30,6 +32,7 @@ interface ModalProviderProps { export const ModalProvider: React.FC = ({ children }) => { const [newFileModalVisible, setNewFileModalVisible] = useState(false); const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false); + const [renameFileModalVisible, setRenameFileModalVisible] = useState(false); const [commitMessageModalVisible, setCommitMessageModalVisible] = useState(false); const [settingsModalVisible, setSettingsModalVisible] = useState(false); @@ -43,6 +46,8 @@ export const ModalProvider: React.FC = ({ children }) => { setNewFileModalVisible, deleteFileModalVisible, setDeleteFileModalVisible, + renameFileModalVisible, + setRenameFileModalVisible, commitMessageModalVisible, setCommitMessageModalVisible, settingsModalVisible, diff --git a/app/src/hooks/useFileOperations.ts b/app/src/hooks/useFileOperations.ts index 64fc7d7..540de4a 100644 --- a/app/src/hooks/useFileOperations.ts +++ b/app/src/hooks/useFileOperations.ts @@ -15,6 +15,7 @@ interface UseFileOperationsResult { parentId: string | null, index: number ) => Promise; + handleRename: (oldPath: string, newPath: string) => Promise; } export const useFileOperations = (): UseFileOperationsResult => { @@ -193,5 +194,40 @@ export const useFileOperations = (): UseFileOperationsResult => { [currentWorkspace, autoCommit] ); - return { handleSave, handleDelete, handleCreate, handleUpload, handleMove }; + const handleRename = useCallback( + async (oldPath: string, newPath: string): Promise => { + if (!currentWorkspace) return false; + + try { + // TODO: Replace with your actual rename API call + // await renameFile(currentWorkspace.name, oldPath, newPath); + + notifications.show({ + title: 'Success', + message: 'File renamed successfully', + color: 'green', + }); + await autoCommit(newPath, FileAction.Update); + return true; + } catch (error) { + console.error('Error renaming file:', error); + notifications.show({ + title: 'Error', + message: 'Failed to rename file', + color: 'red', + }); + return false; + } + }, + [currentWorkspace, autoCommit] + ); + + return { + handleSave, + handleDelete, + handleCreate, + handleUpload, + handleMove, + handleRename, + }; }; From 48d42a92c9c1c88b893cbe88853e141fd7118161 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 8 Jul 2025 20:16:46 +0200 Subject: [PATCH 04/21] Refactor API routes to include "_op" prefix for last workspace and file operations --- server/internal/app/routes.go | 10 +++++----- server/internal/handlers/file_handlers.go | 6 +++--- .../handlers/file_handlers_integration_test.go | 16 ++++++++-------- server/internal/handlers/workspace_handlers.go | 4 ++-- .../workspace_handlers_integration_test.go | 6 +++--- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go index 94ac6e4..5ecc275 100644 --- a/server/internal/app/routes.go +++ b/server/internal/app/routes.go @@ -114,8 +114,8 @@ func setupRouter(o Options) *chi.Mux { r.Route("/workspaces", func(r chi.Router) { r.Get("/", handler.ListWorkspaces()) r.Post("/", handler.CreateWorkspace()) - r.Get("/last", handler.GetLastWorkspaceName()) - r.Put("/last", handler.UpdateLastWorkspaceName()) + r.Get("/_op/last", handler.GetLastWorkspaceName()) + r.Put("/_op/last", handler.UpdateLastWorkspaceName()) // Single workspace routes r.Route("/{workspaceName}", func(r chi.Router) { @@ -129,9 +129,9 @@ func setupRouter(o Options) *chi.Mux { // File routes r.Route("/files", func(r chi.Router) { r.Get("/", handler.ListFiles()) - r.Get("/last", handler.GetLastOpenedFile()) - r.Put("/last", handler.UpdateLastOpenedFile()) - r.Get("/lookup", handler.LookupFileByName()) + r.Get("/_op/last", handler.GetLastOpenedFile()) + r.Put("/_op/last", handler.UpdateLastOpenedFile()) + r.Get("/_op/lookup", handler.LookupFileByName()) r.Post("/*", handler.SaveFile()) r.Get("/*", handler.GetFileContent()) diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index 538742e..92b26e2 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -90,7 +90,7 @@ func (h *Handler) ListFiles() http.HandlerFunc { // @Success 200 {object} LookupResponse // @Failure 400 {object} ErrorResponse "Filename is required" // @Failure 404 {object} ErrorResponse "File not found" -// @Router /workspaces/{workspace_name}/files/lookup [get] +// @Router /workspaces/{workspace_name}/files/_op/lookup [get] func (h *Handler) LookupFileByName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -382,7 +382,7 @@ func (h *Handler) DeleteFile() http.HandlerFunc { // @Success 200 {object} LastOpenedFileResponse // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 500 {object} ErrorResponse "Failed to get last opened file" -// @Router /workspaces/{workspace_name}/files/last [get] +// @Router /workspaces/{workspace_name}/files/_op/last [get] func (h *Handler) GetLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -433,7 +433,7 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc { // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 404 {object} ErrorResponse "File not found" // @Failure 500 {object} ErrorResponse "Failed to update file" -// @Router /workspaces/{workspace_name}/files/last [put] +// @Router /workspaces/{workspace_name}/files/_op/last [put] func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) diff --git a/server/internal/handlers/file_handlers_integration_test.go b/server/internal/handlers/file_handlers_integration_test.go index 962b8f9..ea94e31 100644 --- a/server/internal/handlers/file_handlers_integration_test.go +++ b/server/internal/handlers/file_handlers_integration_test.go @@ -124,7 +124,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { require.Equal(t, http.StatusOK, rr.Code) // Search for the file - rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename="+filename, nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/_op/lookup?filename="+filename, nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) var response struct { @@ -135,7 +135,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { assert.Len(t, response.Paths, 2) // Search for non-existent file - rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename=nonexistent.md", nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/_op/lookup?filename=nonexistent.md", nil, h.RegularTestUser) assert.Equal(t, http.StatusNotFound, rr.Code) }) @@ -158,7 +158,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { t.Run("last opened file", func(t *testing.T) { // Initially should be empty - rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser) + rr := h.makeRequest(t, http.MethodGet, baseURL+"/_op/last", nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) var response struct { @@ -174,11 +174,11 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { }{ FilePath: "docs/readme.md", } - rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodPut, baseURL+"/_op/last", updateReq, h.RegularTestUser) require.Equal(t, http.StatusNoContent, rr.Code) // Verify update - rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/_op/last", nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) err = json.NewDecoder(rr.Body).Decode(&response) @@ -187,7 +187,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { // Test invalid file path updateReq.FilePath = "nonexistent.md" - rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodPut, baseURL+"/_op/last", updateReq, h.RegularTestUser) assert.Equal(t, http.StatusNotFound, rr.Code) }) @@ -202,8 +202,8 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { {"get file", http.MethodGet, baseURL + "/test.md", nil}, {"save file", http.MethodPost, baseURL + "/test.md", "content"}, {"delete file", http.MethodDelete, baseURL + "/test.md", nil}, - {"get last file", http.MethodGet, baseURL + "/last", nil}, - {"update last file", http.MethodPut, baseURL + "/last", struct{ FilePath string }{"test.md"}}, + {"get last file", http.MethodGet, baseURL + "/_op/last", nil}, + {"update last file", http.MethodPut, baseURL + "/_op/last", struct{ FilePath string }{"test.md"}}, } for _, tc := range tests { diff --git a/server/internal/handlers/workspace_handlers.go b/server/internal/handlers/workspace_handlers.go index 663c966..788b76f 100644 --- a/server/internal/handlers/workspace_handlers.go +++ b/server/internal/handlers/workspace_handlers.go @@ -407,7 +407,7 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // @Produce json // @Success 200 {object} LastWorkspaceNameResponse // @Failure 500 {object} ErrorResponse "Failed to get last workspace" -// @Router /workspaces/last [get] +// @Router /workspaces/_op/last [get] func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -444,7 +444,7 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { // @Success 204 "No Content - Last workspace updated successfully" // @Failure 400 {object} ErrorResponse "Invalid request body" // @Failure 500 {object} ErrorResponse "Failed to update last workspace" -// @Router /workspaces/last [put] +// @Router /workspaces/_op/last [put] func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) diff --git a/server/internal/handlers/workspace_handlers_integration_test.go b/server/internal/handlers/workspace_handlers_integration_test.go index cb73efa..38cef62 100644 --- a/server/internal/handlers/workspace_handlers_integration_test.go +++ b/server/internal/handlers/workspace_handlers_integration_test.go @@ -211,7 +211,7 @@ func testWorkspaceHandlers(t *testing.T, dbConfig DatabaseConfig) { t.Run("last workspace", func(t *testing.T) { t.Run("get last workspace", func(t *testing.T) { - rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularTestUser) + rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/_op/last", nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) var response struct { @@ -229,11 +229,11 @@ func testWorkspaceHandlers(t *testing.T, dbConfig DatabaseConfig) { WorkspaceName: workspace.Name, } - rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/last", req, h.RegularTestUser) + rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/_op/last", req, h.RegularTestUser) require.Equal(t, http.StatusNoContent, rr.Code) // Verify the update - rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/_op/last", nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) var response struct { From 4b8ad359a4914abc130b5020a013d51c1bf8221f Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 8 Jul 2025 20:28:44 +0200 Subject: [PATCH 05/21] Refactor API endpoints to include "_op" prefix for file and workspace operations --- app/src/api/file.ts | 8 +++++--- app/src/api/workspace.ts | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/api/file.ts b/app/src/api/file.ts index c85d3a8..a5a9068 100644 --- a/app/src/api/file.ts +++ b/app/src/api/file.ts @@ -47,7 +47,7 @@ export const lookupFileByName = async ( const response = await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName - )}/files/lookup?filename=${encodeURIComponent(filename)}` + )}/files/_op/lookup?filename=${encodeURIComponent(filename)}` ); const data: unknown = await response.json(); if (!isLookupResponse(data)) { @@ -135,7 +135,9 @@ export const getLastOpenedFile = async ( workspaceName: string ): Promise => { const response = await apiCall( - `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files/last` + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/_op/last` ); const data: unknown = await response.json(); if ( @@ -161,7 +163,7 @@ export const updateLastOpenedFile = async ( await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName - )}/files/last`, + )}/files/_op/last`, { method: 'PUT', headers: { diff --git a/app/src/api/workspace.ts b/app/src/api/workspace.ts index 9ba8b0d..ca36d76 100644 --- a/app/src/api/workspace.ts +++ b/app/src/api/workspace.ts @@ -121,7 +121,7 @@ export const deleteWorkspace = async ( * @throws {Error} If the API call fails or returns an invalid response */ export const getLastWorkspaceName = async (): Promise => { - const response = await apiCall(`${API_BASE_URL}/workspaces/last`); + const response = await apiCall(`${API_BASE_URL}/workspaces/_op/last`); const data: unknown = await response.json(); if ( typeof data !== 'object' || @@ -139,7 +139,7 @@ export const getLastWorkspaceName = async (): Promise => { * @throws {Error} If the API call fails or returns an invalid response */ export const updateLastWorkspaceName = async (workspaceName: string) => { - const response = await apiCall(`${API_BASE_URL}/workspaces/last`, { + const response = await apiCall(`${API_BASE_URL}/workspaces/_op/last`, { method: 'PUT', headers: { 'Content-Type': 'application/json', From ae4e9c8db2017b60a11f5f11be94423e1b952cb7 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 10 Jul 2025 21:07:20 +0200 Subject: [PATCH 06/21] Add upload and move file functionalities to API with appropriate routes --- server/internal/app/routes.go | 3 + server/internal/handlers/file_handlers.go | 126 ++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go index 5ecc275..1dd17da 100644 --- a/server/internal/app/routes.go +++ b/server/internal/app/routes.go @@ -133,6 +133,9 @@ func setupRouter(o Options) *chi.Mux { r.Put("/_op/last", handler.UpdateLastOpenedFile()) r.Get("/_op/lookup", handler.LookupFileByName()) + r.Post("/_op/upload/*", handler.UploadFile()) + r.Put("/_op/move", handler.MoveFile()) + r.Post("/*", handler.SaveFile()) r.Get("/*", handler.GetFileContent()) r.Delete("/*", handler.DeleteFile()) diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index 92b26e2..b04fe09 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -302,6 +302,132 @@ func (h *Handler) SaveFile() http.HandlerFunc { } } +// UploadFile godoc +// @Summary Upload file +// @Description Uploads a file to the user's workspace +// @Tags files +// @ID uploadFile +// @Security CookieAuth +// @Accept multipart/form-data +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Param path path string true "Directory path" +// @Param file formData file true "File to upload" +// @Success 200 {object} SaveFileResponse +// @Failure 400 {object} ErrorResponse "Failed to get file from form" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 500 {object} ErrorResponse "Failed to read uploaded file" +// @Failure 500 {object} ErrorResponse "Failed to save file" +// @Router /workspaces/{workspace_name}/files/_op/upload/{path} [post] +func (h *Handler) UploadFile() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getFilesLogger().With( + "handler", "UploadFile", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) + + file, header, err := r.FormFile("file") + if err != nil { + log.Error("failed to get file from form", + "error", err.Error(), + ) + respondError(w, "Failed to get file from form", http.StatusBadRequest) + return + } + defer func() { + if err := file.Close(); err != nil { + log.Error("failed to close uploaded file", + "error", err.Error(), + ) + } + }() + + decodedPath, err := url.PathUnescape(chi.URLParam(r, "*")) + if err != nil { + log.Error("failed to decode file path", + "filePath", decodedPath, + "error", err.Error(), + ) + respondError(w, "Invalid file path", http.StatusBadRequest) + return + } + filePath := decodedPath + "/" + header.Filename + + content := make([]byte, header.Size) + _, err = file.Read(content) + if err != nil && err != io.EOF { + log.Error("failed to read uploaded file", + "filePath", filePath, + "error", err.Error(), + ) + respondError(w, "Failed to read uploaded file", http.StatusInternalServerError) + return + } + + err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) + if err != nil { + if storage.IsPathValidationError(err) { + log.Error("invalid file path attempted", + "filePath", filePath, + "error", err.Error(), + ) + respondError(w, "Invalid file path", http.StatusBadRequest) + return + } + + log.Error("failed to save file", + "filePath", filePath, + "contentSize", len(content), + "error", err.Error(), + ) + respondError(w, "Failed to save file", http.StatusInternalServerError) + return + } + + response := SaveFileResponse{ + FilePath: filePath, + Size: int64(len(content)), + UpdatedAt: time.Now().UTC(), + } + respondJSON(w, response) + } +} + +// MoveFile godoc +// @Summary Move file +// @Description Moves a file to a new location in the user's workspace +// @Tags files +// @ID moveFile +// @Security CookieAuth +// @Param workspace_name path string true "Workspace name" +// @Success 204 "No Content - File moved successfully" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 404 {object} ErrorResponse "File not found" +// @Failure 500 {object} ErrorResponse "Failed to move file" +// @Router /workspaces/{workspace_name}/files/_op/move [post] +func (h *Handler) MoveFile() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getFilesLogger().With( + "handler", "MoveFile", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) + // TODO: Implement MoveFile functionality + _ = log // Suppress unused variable warning + } +} + // DeleteFile godoc // @Summary Delete file // @Description Deletes a file in the user's workspace From 5a6895ecdc95b7ffe677cfb8a55b3395bc3a6ccf Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 10 Jul 2025 22:02:03 +0200 Subject: [PATCH 07/21] Enhance integration tests with file upload functionality and support for multipart requests --- server/internal/db/testdb.go | 2 +- .../file_handlers_integration_test.go | 31 ++++++++++++ server/internal/handlers/integration_test.go | 47 ++++++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/server/internal/db/testdb.go b/server/internal/db/testdb.go index 201d3fe..0c5334b 100644 --- a/server/internal/db/testdb.go +++ b/server/internal/db/testdb.go @@ -1,4 +1,4 @@ -//go:build test +//go:build test || integration package db diff --git a/server/internal/handlers/file_handlers_integration_test.go b/server/internal/handlers/file_handlers_integration_test.go index ea94e31..edef78b 100644 --- a/server/internal/handlers/file_handlers_integration_test.go +++ b/server/internal/handlers/file_handlers_integration_test.go @@ -239,5 +239,36 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { }) } }) + + t.Run("upload file", func(t *testing.T) { + t.Run("successful upload", func(t *testing.T) { + fileName := "uploaded-test.txt" + fileContent := "This is an uploaded file" + + rr := h.makeUploadRequest(t, baseURL+"/_op/upload/uploads", fileName, fileContent, h.RegularTestUser) + require.Equal(t, http.StatusOK, rr.Code) + + // Verify response structure + var response struct { + FilePath string `json:"filePath"` + Size int64 `json:"size"` + UpdatedAt string `json:"updatedAt"` + } + err := json.NewDecoder(rr.Body).Decode(&response) + require.NoError(t, err) + assert.Equal(t, "uploads/"+fileName, response.FilePath) + assert.Equal(t, int64(len(fileContent)), response.Size) + + // Verify file was saved + rr = h.makeRequest(t, http.MethodGet, baseURL+"/uploads/"+fileName, nil, h.RegularTestUser) + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, fileContent, rr.Body.String()) + }) + + t.Run("upload without file", func(t *testing.T) { + rr := h.makeUploadRequest(t, baseURL+"/_op/upload/test", "", "", h.RegularTestUser) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + }) }) } diff --git a/server/internal/handlers/integration_test.go b/server/internal/handlers/integration_test.go index 7e9894c..2e0acc5 100644 --- a/server/internal/handlers/integration_test.go +++ b/server/internal/handlers/integration_test.go @@ -6,6 +6,7 @@ import ( "bytes" "encoding/json" "io" + "mime/multipart" "net/http" "net/http/httptest" "os" @@ -303,11 +304,19 @@ func (h *testHarness) makeRequest(t *testing.T, method, path string, body any, t return h.executeRequest(req) } -// makeRequestRawWithHeaders adds support for custom headers with raw body -func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io.Reader, testUser *testUser) *httptest.ResponseRecorder { +// makeRequestRaw adds support for raw body requests with optional headers +func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io.Reader, testUser *testUser, headers ...map[string]string) *httptest.ResponseRecorder { t.Helper() req := h.newRequestRaw(t, method, path, body) + + // Set custom headers if provided + if len(headers) > 0 { + for key, value := range headers[0] { + req.Header.Set(key, value) + } + } + h.addAuthCookies(t, req, testUser) needsCSRF := method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions @@ -318,3 +327,37 @@ func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io. return h.executeRequest(req) } + +// makeUploadRequest creates a multipart form request for file uploads +// If fileName is empty, creates an empty multipart form without a file +func (h *testHarness) makeUploadRequest(t *testing.T, path, fileName, fileContent string, testUser *testUser) *httptest.ResponseRecorder { + t.Helper() + + // Create multipart form + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Only add file part if fileName is not empty + if fileName != "" { + part, err := writer.CreateFormFile("file", fileName) + if err != nil { + t.Fatalf("Failed to create form file: %v", err) + } + + _, err = part.Write([]byte(fileContent)) + if err != nil { + t.Fatalf("Failed to write file content: %v", err) + } + } + + err := writer.Close() + if err != nil { + t.Fatalf("Failed to close multipart writer: %v", err) + } + + headers := map[string]string{ + "Content-Type": writer.FormDataContentType(), + } + + return h.makeRequestRaw(t, http.MethodPost, path, &buf, testUser, headers) +} From 9bb95f603c2bf3b940627b932ab897e26cd84eb5 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Fri, 11 Jul 2025 19:49:08 +0200 Subject: [PATCH 08/21] Implement MoveFile functionality in FileManager and corresponding tests --- server/internal/storage/files.go | 29 ++++++ server/internal/storage/files_test.go | 102 +++++++++++++++++++++ server/internal/storage/filesystem.go | 16 ++++ server/internal/storage/filesystem_test.go | 11 +++ 4 files changed, 158 insertions(+) diff --git a/server/internal/storage/files.go b/server/internal/storage/files.go index 87c5632..867e33d 100644 --- a/server/internal/storage/files.go +++ b/server/internal/storage/files.go @@ -14,6 +14,7 @@ type FileManager interface { FindFileByName(userID, workspaceID int, filename string) ([]string, error) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) SaveFile(userID, workspaceID int, filePath string, content []byte) error + MoveFile(userID, workspaceID int, srcPath string, dstPath string) error DeleteFile(userID, workspaceID int, filePath string) error GetFileStats(userID, workspaceID int) (*FileCountStats, error) GetTotalFileStats() (*FileCountStats, error) @@ -174,6 +175,34 @@ func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []b return nil } +// MoveFile moves a file from srcPath to dstPath within the workspace directory. +// Both paths must be relative to the workspace directory given by userID and workspaceID. +// If the destination file already exists, it will be overwritten. +func (s *Service) MoveFile(userID, workspaceID int, srcPath string, dstPath string) error { + log := getLogger() + + srcFullPath, err := s.ValidatePath(userID, workspaceID, srcPath) + if err != nil { + return err + } + + dstFullPath, err := s.ValidatePath(userID, workspaceID, dstPath) + if err != nil { + return err + } + + if err := s.fs.MoveFile(srcFullPath, dstFullPath); err != nil { + return fmt.Errorf("failed to move file: %w", err) + } + + log.Debug("file moved", + "userID", userID, + "workspaceID", workspaceID, + "src", srcPath, + "dst", dstPath) + return nil +} + // DeleteFile deletes the file at the given filePath. // Path must be a relative path within the workspace directory given by userID and workspaceID. func (s *Service) DeleteFile(userID, workspaceID int, filePath string) error { diff --git a/server/internal/storage/files_test.go b/server/internal/storage/files_test.go index 2a23cff..d2123f4 100644 --- a/server/internal/storage/files_test.go +++ b/server/internal/storage/files_test.go @@ -407,3 +407,105 @@ func TestDeleteFile(t *testing.T) { }) } } + +func TestMoveFile(t *testing.T) { + mockFS := NewMockFS() + s := storage.NewServiceWithOptions("test-root", storage.Options{ + Fs: mockFS, + NewGitClient: nil, + }) + + testCases := []struct { + name string + userID int + workspaceID int + srcPath string + dstPath string + mockErr error + wantErr bool + }{ + { + name: "successful move", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "moved.md", + mockErr: nil, + wantErr: false, + }, + { + name: "move to subdirectory", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "subdir/test.md", + mockErr: nil, + wantErr: false, + }, + { + name: "invalid source path", + userID: 1, + workspaceID: 1, + srcPath: "../../../etc/passwd", + dstPath: "test.md", + mockErr: nil, + wantErr: true, + }, + { + name: "invalid destination path", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "../../../etc/passwd", + mockErr: nil, + wantErr: true, + }, + { + name: "filesystem move error", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "moved.md", + mockErr: fs.ErrPermission, + wantErr: true, + }, + { + name: "same source and destination", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "test.md", + mockErr: nil, + wantErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockFS.MoveFileError = tc.mockErr + err := s.MoveFile(tc.userID, tc.workspaceID, tc.srcPath, tc.dstPath) + + if tc.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedSrcPath := filepath.Join("test-root", "1", "1", tc.srcPath) + expectedDstPath := filepath.Join("test-root", "1", "1", tc.dstPath) + + if dstPath, ok := mockFS.MoveCalls[expectedSrcPath]; ok { + if dstPath != expectedDstPath { + t.Errorf("move destination = %q, want %q", dstPath, expectedDstPath) + } + } else { + t.Error("expected move call not made") + } + }) + } +} diff --git a/server/internal/storage/filesystem.go b/server/internal/storage/filesystem.go index 0fc3473..5350859 100644 --- a/server/internal/storage/filesystem.go +++ b/server/internal/storage/filesystem.go @@ -10,6 +10,7 @@ import ( type fileSystem interface { ReadFile(path string) ([]byte, error) WriteFile(path string, data []byte, perm fs.FileMode) error + MoveFile(src, dst string) error Remove(path string) error MkdirAll(path string, perm fs.FileMode) error RemoveAll(path string) error @@ -38,6 +39,21 @@ func (f *osFS) WriteFile(path string, data []byte, perm fs.FileMode) error { return os.WriteFile(path, data, perm) } +// MoveFile moves the file from src to dst, overwriting if necessary. +func (f *osFS) MoveFile(src, dst string) error { + if err := os.Rename(src, dst); err != nil { + if os.IsExist(err) { + // If the destination exists, remove it and try again + if err := os.Remove(dst); err != nil && !os.IsNotExist(err) { + return err + } + return os.Rename(src, dst) + } + return err + } + return nil +} + // Remove deletes the file at the given path. func (f *osFS) Remove(path string) error { return os.Remove(path) } diff --git a/server/internal/storage/filesystem_test.go b/server/internal/storage/filesystem_test.go index e717a16..4749e18 100644 --- a/server/internal/storage/filesystem_test.go +++ b/server/internal/storage/filesystem_test.go @@ -43,6 +43,7 @@ type mockFS struct { // Record operations for verification ReadCalls map[string]int WriteCalls map[string][]byte + MoveCalls map[string]string RemoveCalls []string MkdirCalls []string @@ -56,6 +57,7 @@ type mockFS struct { err error } WriteFileError error + MoveFileError error RemoveError error MkdirError error StatError error @@ -66,6 +68,7 @@ func NewMockFS() *mockFS { return &mockFS{ ReadCalls: make(map[string]int), WriteCalls: make(map[string][]byte), + MoveCalls: make(map[string]string), RemoveCalls: make([]string, 0), MkdirCalls: make([]string, 0), ReadFileReturns: make(map[string]struct { @@ -88,6 +91,14 @@ func (m *mockFS) WriteFile(path string, data []byte, _ fs.FileMode) error { return m.WriteFileError } +func (m *mockFS) MoveFile(src, dst string) error { + m.MoveCalls[src] = dst + if src == dst { + return nil // No-op if source and destination are the same + } + return m.MoveFileError +} + func (m *mockFS) Remove(path string) error { m.RemoveCalls = append(m.RemoveCalls, path) return m.RemoveError From e1760ccd8236502a2b68ac170f2abca76c1fde40 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Fri, 11 Jul 2025 23:35:00 +0200 Subject: [PATCH 09/21] Fix FileActions, FileTree, and MainContent tests by mocking context providers --- app/src/components/files/FileActions.test.tsx | 48 +++++++++++- app/src/components/files/FileTree.test.tsx | 75 ++++++++++++++++++- .../components/layout/MainContent.test.tsx | 41 +++++++++- 3 files changed, 161 insertions(+), 3 deletions(-) diff --git a/app/src/components/files/FileActions.test.tsx b/app/src/components/files/FileActions.test.tsx index 501a0d8..ca844e2 100644 --- a/app/src/components/files/FileActions.test.tsx +++ b/app/src/components/files/FileActions.test.tsx @@ -3,22 +3,59 @@ import { fireEvent } from '@testing-library/react'; import { render } from '../../test/utils'; import FileActions from './FileActions'; import { Theme } from '@/types/models'; +import { ModalProvider } from '../../contexts/ModalContext'; +import { ThemeProvider } from '../../contexts/ThemeContext'; +import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext'; // Mock the contexts and hooks vi.mock('../../contexts/ModalContext', () => ({ useModalContext: vi.fn(), + ModalProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), })); vi.mock('../../hooks/useWorkspace', () => ({ useWorkspace: vi.fn(), })); +// Mock contexts +vi.mock('../../contexts/ThemeContext', () => ({ + useTheme: () => ({ + colorScheme: 'light', + updateColorScheme: vi.fn(), + }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => ({ + currentWorkspace: { name: 'test-workspace', path: '/test' }, + workspaces: [], + settings: {}, + loading: false, + loadWorkspaces: vi.fn(), + loadWorkspaceData: vi.fn(), + setCurrentWorkspace: vi.fn(), + }), + WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + const TestWrapper = ({ children }: { children: React.ReactNode }) => ( -
{children}
+ + + {children} + + ); describe('FileActions', () => { const mockHandlePullChanges = vi.fn(); + const mockLoadFileList = vi.fn(); const mockSetNewFileModalVisible = vi.fn(); const mockSetDeleteFileModalVisible = vi.fn(); const mockSetCommitMessageModalVisible = vi.fn(); @@ -52,6 +89,8 @@ describe('FileActions', () => { setNewFileModalVisible: mockSetNewFileModalVisible, deleteFileModalVisible: false, setDeleteFileModalVisible: mockSetDeleteFileModalVisible, + renameFileModalVisible: false, + setRenameFileModalVisible: vi.fn(), commitMessageModalVisible: false, setCommitMessageModalVisible: mockSetCommitMessageModalVisible, settingsModalVisible: false, @@ -81,6 +120,7 @@ describe('FileActions', () => { ); @@ -97,6 +137,7 @@ describe('FileActions', () => { ); @@ -113,6 +154,7 @@ describe('FileActions', () => { ); @@ -129,6 +171,7 @@ describe('FileActions', () => { ); @@ -157,6 +200,7 @@ describe('FileActions', () => { ); @@ -174,6 +218,7 @@ describe('FileActions', () => { ); @@ -202,6 +247,7 @@ describe('FileActions', () => { ); diff --git a/app/src/components/files/FileTree.test.tsx b/app/src/components/files/FileTree.test.tsx index 09ec508..5a17311 100644 --- a/app/src/components/files/FileTree.test.tsx +++ b/app/src/components/files/FileTree.test.tsx @@ -3,6 +3,9 @@ import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../test/utils'; import FileTree from './FileTree'; import type { FileNode } from '../../types/models'; +import { ModalProvider } from '../../contexts/ModalContext'; +import { ThemeProvider } from '../../contexts/ThemeContext'; +import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext'; // Mock react-arborist vi.mock('react-arborist', () => ({ @@ -69,12 +72,76 @@ vi.mock('@react-hook/resize-observer', () => ({ default: vi.fn(), })); +// Mock contexts +vi.mock('../../contexts/ThemeContext', () => ({ + useTheme: () => ({ + colorScheme: 'light', + updateColorScheme: vi.fn(), + }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => ({ + currentWorkspace: { name: 'test-workspace', path: '/test' }, + workspaces: [], + settings: {}, + loading: false, + loadWorkspaces: vi.fn(), + loadWorkspaceData: vi.fn(), + setCurrentWorkspace: vi.fn(), + }), + WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../contexts/ModalContext', () => ({ + useModalContext: () => ({ + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + renameFileModalVisible: false, + setRenameFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: vi.fn(), + }), + ModalProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../hooks/useFileOperations', () => ({ + useFileOperations: () => ({ + handleSave: vi.fn(), + handleCreate: vi.fn(), + handleDelete: vi.fn(), + handleUpload: vi.fn(), + handleMove: vi.fn(), + handleRename: vi.fn(), + }), +})); + const TestWrapper = ({ children }: { children: React.ReactNode }) => ( -
{children}
+ + + {children} + + ); describe('FileTree', () => { const mockHandleFileSelect = vi.fn(); + const mockLoadFileList = vi.fn(); const mockFiles: FileNode[] = [ { @@ -112,6 +179,7 @@ describe('FileTree', () => { files={mockFiles} handleFileSelect={mockHandleFileSelect} showHiddenFiles={true} + loadFileList={mockLoadFileList} /> ); @@ -128,6 +196,7 @@ describe('FileTree', () => { files={mockFiles} handleFileSelect={mockHandleFileSelect} showHiddenFiles={true} + loadFileList={mockLoadFileList} /> ); @@ -147,6 +216,7 @@ describe('FileTree', () => { files={mockFiles} handleFileSelect={mockHandleFileSelect} showHiddenFiles={false} + loadFileList={mockLoadFileList} /> ); @@ -166,6 +236,7 @@ describe('FileTree', () => { files={mockFiles} handleFileSelect={mockHandleFileSelect} showHiddenFiles={true} + loadFileList={mockLoadFileList} /> ); @@ -183,6 +254,7 @@ describe('FileTree', () => { files={[]} handleFileSelect={mockHandleFileSelect} showHiddenFiles={true} + loadFileList={mockLoadFileList} /> ); @@ -199,6 +271,7 @@ describe('FileTree', () => { files={mockFiles} handleFileSelect={mockHandleFileSelect} showHiddenFiles={true} + loadFileList={mockLoadFileList} /> ); diff --git a/app/src/components/layout/MainContent.test.tsx b/app/src/components/layout/MainContent.test.tsx index 2972091..769206d 100644 --- a/app/src/components/layout/MainContent.test.tsx +++ b/app/src/components/layout/MainContent.test.tsx @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from '../../test/utils'; import MainContent from './MainContent'; +import { ModalProvider } from '../../contexts/ModalContext'; +import { ThemeProvider } from '../../contexts/ThemeContext'; +import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext'; // Mock child components vi.mock('../editor/ContentView', () => ({ @@ -31,6 +34,32 @@ vi.mock('../modals/git/CommitMessageModal', () => ({ ), })); +// Mock contexts +vi.mock('../../contexts/ThemeContext', () => ({ + useTheme: () => ({ + colorScheme: 'light', + updateColorScheme: vi.fn(), + }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => ({ + currentWorkspace: { name: 'test-workspace', path: '/test' }, + workspaces: [], + settings: {}, + loading: false, + loadWorkspaces: vi.fn(), + loadWorkspaceData: vi.fn(), + setCurrentWorkspace: vi.fn(), + }), + WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + // Mock hooks vi.mock('../../hooks/useFileContent', () => ({ useFileContent: vi.fn(), @@ -45,7 +74,11 @@ vi.mock('../../hooks/useGitOperations', () => ({ })); const TestWrapper = ({ children }: { children: React.ReactNode }) => ( -
{children}
+ + + {children} + + ); describe('MainContent', () => { @@ -56,6 +89,9 @@ describe('MainContent', () => { const mockHandleSave = vi.fn(); const mockHandleCreate = vi.fn(); const mockHandleDelete = vi.fn(); + const mockHandleUpload = vi.fn(); + const mockHandleMove = vi.fn(); + const mockHandleRename = vi.fn(); const mockHandleCommitAndPush = vi.fn(); beforeEach(async () => { @@ -76,6 +112,9 @@ describe('MainContent', () => { handleSave: mockHandleSave, handleCreate: mockHandleCreate, handleDelete: mockHandleDelete, + handleUpload: mockHandleUpload, + handleMove: mockHandleMove, + handleRename: mockHandleRename, }); const { useGitOperations } = await import('../../hooks/useGitOperations'); From 0b0cd9160eaea196f70b6ff8b029cb15a2cfbb63 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Fri, 11 Jul 2025 23:53:37 +0200 Subject: [PATCH 10/21] Refactor WorkspaceDataContext and useFileOperations to remove settings --- .../contexts/WorkspaceDataContext.test.tsx | 8 --- app/src/contexts/WorkspaceDataContext.tsx | 4 +- app/src/hooks/useFileOperations.test.ts | 58 +++++++++---------- app/src/hooks/useFileOperations.ts | 24 ++------ app/src/hooks/useGitOperations.test.ts | 21 ++++--- app/src/hooks/useGitOperations.ts | 14 +++-- 6 files changed, 53 insertions(+), 76 deletions(-) diff --git a/app/src/contexts/WorkspaceDataContext.test.tsx b/app/src/contexts/WorkspaceDataContext.test.tsx index 0a12efd..7ced34a 100644 --- a/app/src/contexts/WorkspaceDataContext.test.tsx +++ b/app/src/contexts/WorkspaceDataContext.test.tsx @@ -126,7 +126,6 @@ describe('WorkspaceDataContext', () => { expect(result.current.currentWorkspace).toBeNull(); expect(result.current.loading).toBe(true); expect(result.current.workspaces).toEqual([]); - expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); await waitFor(() => { expect(result.current.loading).toBe(false); @@ -171,7 +170,6 @@ describe('WorkspaceDataContext', () => { expect(result.current.currentWorkspace).toEqual(mockWorkspace); expect(result.current.workspaces).toEqual(mockWorkspaceList); - expect(result.current.settings).toEqual(mockWorkspace); expect(mockGetLastWorkspaceName).toHaveBeenCalledTimes(1); expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace'); expect(mockListWorkspaces).toHaveBeenCalledTimes(1); @@ -258,7 +256,6 @@ describe('WorkspaceDataContext', () => { expect(result.current.currentWorkspace).toBeNull(); expect(result.current.workspaces).toEqual([]); - expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); consoleSpy.mockRestore(); }); @@ -420,7 +417,6 @@ describe('WorkspaceDataContext', () => { }); expect(result.current.currentWorkspace).toEqual(mockWorkspace); - expect(result.current.settings).toEqual(mockWorkspace); expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace'); expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark'); }); @@ -500,7 +496,6 @@ describe('WorkspaceDataContext', () => { }); expect(result.current.currentWorkspace).toEqual(mockWorkspace); - expect(result.current.settings).toEqual(mockWorkspace); }); it('sets workspace to null', async () => { @@ -524,7 +519,6 @@ describe('WorkspaceDataContext', () => { }); expect(result.current.currentWorkspace).toBeNull(); - expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); }); }); @@ -603,7 +597,6 @@ describe('WorkspaceDataContext', () => { expect(result.current.currentWorkspace).toBeNull(); expect(result.current.workspaces).toEqual([]); - expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); expect(result.current.loading).toBe(false); expect(typeof result.current.loadWorkspaces).toBe('function'); @@ -631,7 +624,6 @@ describe('WorkspaceDataContext', () => { expect(result.current.currentWorkspace).toEqual(mockWorkspace); expect(result.current.workspaces).toEqual(mockWorkspaceList); - expect(result.current.settings).toEqual(mockWorkspace); expect(result.current.loading).toBe(false); expect(typeof result.current.loadWorkspaces).toBe('function'); diff --git a/app/src/contexts/WorkspaceDataContext.tsx b/app/src/contexts/WorkspaceDataContext.tsx index 4d9e7e4..aa02bbd 100644 --- a/app/src/contexts/WorkspaceDataContext.tsx +++ b/app/src/contexts/WorkspaceDataContext.tsx @@ -7,7 +7,7 @@ import React, { useCallback, } from 'react'; import { notifications } from '@mantine/notifications'; -import { DEFAULT_WORKSPACE_SETTINGS, type Workspace } from '@/types/models'; +import { type Workspace } from '@/types/models'; import { getWorkspace, listWorkspaces, @@ -19,7 +19,6 @@ import { useTheme } from './ThemeContext'; interface WorkspaceDataContextType { currentWorkspace: Workspace | null; workspaces: Workspace[]; - settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS; loading: boolean; loadWorkspaces: () => Promise; loadWorkspaceData: (workspaceName: string) => Promise; @@ -121,7 +120,6 @@ export const WorkspaceDataProvider: React.FC = ({ const value: WorkspaceDataContextType = { currentWorkspace, workspaces, - settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS, loading, loadWorkspaces, loadWorkspaceData, diff --git a/app/src/hooks/useFileOperations.test.ts b/app/src/hooks/useFileOperations.test.ts index 20b914d..1276bc3 100644 --- a/app/src/hooks/useFileOperations.test.ts +++ b/app/src/hooks/useFileOperations.test.ts @@ -13,21 +13,20 @@ vi.mock('@mantine/notifications', () => ({ // Mock the workspace context and git operations const mockWorkspaceData: { - currentWorkspace: { id: number; name: string } | null; - settings: { - gitAutoCommit: boolean; - gitEnabled: boolean; - gitCommitMsgTemplate: string; - }; + currentWorkspace: { + id: number; + name: string; + gitAutoCommit?: boolean; + gitEnabled?: boolean; + gitCommitMsgTemplate?: string; + } | null; } = { currentWorkspace: { id: 1, name: 'test-workspace', - }, - settings: { gitAutoCommit: false, gitEnabled: false, - gitCommitMsgTemplate: '${action} ${filename}', + gitCommitMsgTemplate: '${action}: ${filename}', }, }; @@ -53,8 +52,6 @@ describe('useFileOperations', () => { mockWorkspaceData.currentWorkspace = { id: 1, name: 'test-workspace', - }; - mockWorkspaceData.settings = { gitAutoCommit: false, gitEnabled: false, gitCommitMsgTemplate: '${action} ${filename}', @@ -155,8 +152,8 @@ describe('useFileOperations', () => { }); // Enable auto-commit - mockWorkspaceData.settings.gitAutoCommit = true; - mockWorkspaceData.settings.gitEnabled = true; + mockWorkspaceData.currentWorkspace!.gitAutoCommit = true; + mockWorkspaceData.currentWorkspace!.gitEnabled = true; const { result } = renderHook(() => useFileOperations()); @@ -178,9 +175,9 @@ describe('useFileOperations', () => { }); // Enable auto-commit with custom template - mockWorkspaceData.settings.gitAutoCommit = true; - mockWorkspaceData.settings.gitEnabled = true; - mockWorkspaceData.settings.gitCommitMsgTemplate = + mockWorkspaceData.currentWorkspace!.gitAutoCommit = true; + mockWorkspaceData.currentWorkspace!.gitEnabled = true; + mockWorkspaceData.currentWorkspace!.gitCommitMsgTemplate = 'Modified ${filename} - ${action}'; const { result } = renderHook(() => useFileOperations()); @@ -264,8 +261,8 @@ describe('useFileOperations', () => { mockDeleteFile.mockResolvedValue(undefined); // Enable auto-commit - mockWorkspaceData.settings.gitAutoCommit = true; - mockWorkspaceData.settings.gitEnabled = true; + mockWorkspaceData.currentWorkspace!.gitAutoCommit = true; + mockWorkspaceData.currentWorkspace!.gitEnabled = true; const { result } = renderHook(() => useFileOperations()); @@ -382,8 +379,8 @@ describe('useFileOperations', () => { }); // Enable auto-commit - mockWorkspaceData.settings.gitAutoCommit = true; - mockWorkspaceData.settings.gitEnabled = true; + mockWorkspaceData.currentWorkspace!.gitAutoCommit = true; + mockWorkspaceData.currentWorkspace!.gitEnabled = true; const { result } = renderHook(() => useFileOperations()); @@ -407,8 +404,8 @@ describe('useFileOperations', () => { }); // Enable auto-commit but disable git - mockWorkspaceData.settings.gitAutoCommit = true; - mockWorkspaceData.settings.gitEnabled = false; + mockWorkspaceData.currentWorkspace!.gitAutoCommit = true; + mockWorkspaceData.currentWorkspace!.gitEnabled = false; const { result } = renderHook(() => useFileOperations()); @@ -428,8 +425,8 @@ describe('useFileOperations', () => { }); // Enable git but disable auto-commit - mockWorkspaceData.settings.gitAutoCommit = false; - mockWorkspaceData.settings.gitEnabled = true; + mockWorkspaceData.currentWorkspace!.gitAutoCommit = false; + mockWorkspaceData.currentWorkspace!.gitEnabled = true; const { result } = renderHook(() => useFileOperations()); @@ -449,9 +446,10 @@ describe('useFileOperations', () => { }); // Enable auto-commit with lowercase template - mockWorkspaceData.settings.gitAutoCommit = true; - mockWorkspaceData.settings.gitEnabled = true; - mockWorkspaceData.settings.gitCommitMsgTemplate = 'updated ${filename}'; + mockWorkspaceData.currentWorkspace!.gitAutoCommit = true; + mockWorkspaceData.currentWorkspace!.gitEnabled = true; + mockWorkspaceData.currentWorkspace!.gitCommitMsgTemplate = + 'updated ${filename}'; const { result } = renderHook(() => useFileOperations()); @@ -476,9 +474,9 @@ describe('useFileOperations', () => { mockDeleteFile.mockResolvedValue(undefined); // Enable auto-commit - mockWorkspaceData.settings.gitAutoCommit = true; - mockWorkspaceData.settings.gitEnabled = true; - mockWorkspaceData.settings.gitCommitMsgTemplate = + mockWorkspaceData.currentWorkspace!.gitAutoCommit = true; + mockWorkspaceData.currentWorkspace!.gitEnabled = true; + mockWorkspaceData.currentWorkspace!.gitCommitMsgTemplate = '${action}: ${filename}'; const { result } = renderHook(() => useFileOperations()); diff --git a/app/src/hooks/useFileOperations.ts b/app/src/hooks/useFileOperations.ts index 540de4a..e2e5004 100644 --- a/app/src/hooks/useFileOperations.ts +++ b/app/src/hooks/useFileOperations.ts @@ -19,13 +19,14 @@ interface UseFileOperationsResult { } export const useFileOperations = (): UseFileOperationsResult => { - const { currentWorkspace, settings } = useWorkspaceData(); + const { currentWorkspace } = useWorkspaceData(); const { handleCommitAndPush } = useGitOperations(); const autoCommit = useCallback( async (filePath: string, action: FileAction): Promise => { - if (settings.gitAutoCommit && settings.gitEnabled) { - let commitMessage = settings.gitCommitMsgTemplate + if (!currentWorkspace || !currentWorkspace.gitEnabled) return; + if (currentWorkspace.gitAutoCommit && currentWorkspace.gitEnabled) { + let commitMessage = currentWorkspace.gitCommitMsgTemplate .replace('${filename}', filePath) .replace('${action}', action); @@ -35,7 +36,7 @@ export const useFileOperations = (): UseFileOperationsResult => { await handleCommitAndPush(commitMessage); } }, - [settings, handleCommitAndPush] + [currentWorkspace, handleCommitAndPush] ); const handleSave = useCallback( @@ -123,15 +124,6 @@ export const useFileOperations = (): UseFileOperationsResult => { try { // TODO: Implement your file upload API call - // Example: - // const formData = new FormData(); - // Array.from(files).forEach((file, index) => { - // formData.append(`file${index}`, file); - // }); - // if (targetPath) { - // formData.append('targetPath', targetPath); - // } - // await uploadFiles(currentWorkspace.name, formData); notifications.show({ title: 'Success', @@ -165,12 +157,6 @@ export const useFileOperations = (): UseFileOperationsResult => { try { // TODO: Implement your file move API call - // Example: - // await moveFiles(currentWorkspace.name, { - // sourceIds: dragIds, - // targetParentId: parentId, - // targetIndex: index - // }); notifications.show({ title: 'Success', diff --git a/app/src/hooks/useGitOperations.test.ts b/app/src/hooks/useGitOperations.test.ts index 1046fc7..b706f1e 100644 --- a/app/src/hooks/useGitOperations.test.ts +++ b/app/src/hooks/useGitOperations.test.ts @@ -13,14 +13,11 @@ vi.mock('@mantine/notifications', () => ({ // Mock the workspace context const mockWorkspaceData: { - currentWorkspace: { id: number; name: string } | null; - settings: { gitEnabled: boolean }; + currentWorkspace: { id: number; name: string; gitEnabled: boolean } | null; } = { currentWorkspace: { id: 1, name: 'test-workspace', - }, - settings: { gitEnabled: true, }, }; @@ -39,8 +36,6 @@ describe('useGitOperations', () => { mockWorkspaceData.currentWorkspace = { id: 1, name: 'test-workspace', - }; - mockWorkspaceData.settings = { gitEnabled: true, }; }); @@ -114,7 +109,7 @@ describe('useGitOperations', () => { }); it('returns false when git is disabled', async () => { - mockWorkspaceData.settings.gitEnabled = false; + mockWorkspaceData.currentWorkspace!.gitEnabled = false; const { result } = renderHook(() => useGitOperations()); @@ -208,7 +203,7 @@ describe('useGitOperations', () => { }); it('does nothing when git is disabled', async () => { - mockWorkspaceData.settings.gitEnabled = false; + mockWorkspaceData.currentWorkspace!.gitEnabled = false; const { result } = renderHook(() => useGitOperations()); @@ -306,6 +301,7 @@ describe('useGitOperations', () => { mockWorkspaceData.currentWorkspace = { id: 2, name: 'different-workspace', + gitEnabled: true, }; rerender(); @@ -321,10 +317,10 @@ describe('useGitOperations', () => { const { result, rerender } = renderHook(() => useGitOperations()); // Initially git is enabled - expect(mockWorkspaceData.settings.gitEnabled).toBe(true); + expect(mockWorkspaceData.currentWorkspace!.gitEnabled).toBe(true); // Disable git - mockWorkspaceData.settings.gitEnabled = false; + mockWorkspaceData.currentWorkspace!.gitEnabled = false; rerender(); let pullResult: boolean | undefined; @@ -381,6 +377,7 @@ describe('useGitOperations', () => { mockWorkspaceData.currentWorkspace = { id: 1, name: undefined!, + gitEnabled: true, }; const { result } = renderHook(() => useGitOperations()); @@ -395,7 +392,9 @@ describe('useGitOperations', () => { }); it('handles missing settings gracefully', async () => { - mockWorkspaceData.settings = { + mockWorkspaceData.currentWorkspace = { + id: 1, + name: 'test-workspace', gitEnabled: undefined!, }; diff --git a/app/src/hooks/useGitOperations.ts b/app/src/hooks/useGitOperations.ts index 0745606..fd4866c 100644 --- a/app/src/hooks/useGitOperations.ts +++ b/app/src/hooks/useGitOperations.ts @@ -10,10 +10,14 @@ interface UseGitOperationsResult { } export const useGitOperations = (): UseGitOperationsResult => { - const { currentWorkspace, settings } = useWorkspaceData(); + const { currentWorkspace } = useWorkspaceData(); const handlePull = useCallback(async (): Promise => { - if (!currentWorkspace || !settings.gitEnabled || !currentWorkspace.name) + if ( + !currentWorkspace || + !currentWorkspace.gitEnabled || + !currentWorkspace.name + ) return false; try { @@ -33,11 +37,11 @@ export const useGitOperations = (): UseGitOperationsResult => { }); return false; } - }, [currentWorkspace, settings.gitEnabled]); + }, [currentWorkspace]); const handleCommitAndPush = useCallback( async (message: string): Promise => { - if (!currentWorkspace || !settings.gitEnabled) return; + if (!currentWorkspace || !currentWorkspace.gitEnabled) return; try { const commitHash: CommitHash = await commitAndPush( @@ -60,7 +64,7 @@ export const useGitOperations = (): UseGitOperationsResult => { return; } }, - [currentWorkspace, settings.gitEnabled] + [currentWorkspace] ); return { handlePull, handleCommitAndPush }; From 3ac657486c7ec54c214db49ceb1eef1b2f44b32c Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 11:52:51 +0200 Subject: [PATCH 11/21] Implement MoveFile functionality --- server/internal/handlers/file_handlers.go | 65 ++++++++++++++++++++++- server/internal/storage/files.go | 2 +- server/internal/storage/filesystem.go | 6 +++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index b04fe09..b5695cc 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -423,8 +423,69 @@ func (h *Handler) MoveFile() http.HandlerFunc { "workspaceID", ctx.Workspace.ID, "clientIP", r.RemoteAddr, ) - // TODO: Implement MoveFile functionality - _ = log // Suppress unused variable warning + + srcPath := r.URL.Query().Get("src_path") + destPath := r.URL.Query().Get("dest_path") + if srcPath == "" || destPath == "" { + log.Debug("missing src_path or dest_path parameter") + respondError(w, "src_path and dest_path are required", http.StatusBadRequest) + return + } + + // URL-decode the source and destination paths + decodedSrcPath, err := url.PathUnescape(srcPath) + if err != nil { + log.Error("failed to decode source file path", + "srcPath", srcPath, + "error", err.Error(), + ) + respondError(w, "Invalid source file path", http.StatusBadRequest) + return + } + + decodedDestPath, err := url.PathUnescape(destPath) + if err != nil { + log.Error("failed to decode destination file path", + "destPath", destPath, + "error", err.Error(), + ) + respondError(w, "Invalid destination file path", http.StatusBadRequest) + return + } + + err = h.Storage.MoveFile(ctx.UserID, ctx.Workspace.ID, decodedSrcPath, decodedDestPath) + if err != nil { + if storage.IsPathValidationError(err) { + log.Error("invalid file path attempted", + "srcPath", decodedSrcPath, + "destPath", decodedDestPath, + "error", err.Error(), + ) + respondError(w, "Invalid file path", http.StatusBadRequest) + return + } + if os.IsNotExist(err) { + log.Debug("file not found", + "srcPath", decodedSrcPath, + ) + respondError(w, "File not found", http.StatusNotFound) + return + } + log.Error("failed to move file", + "srcPath", decodedSrcPath, + "destPath", decodedDestPath, + "error", err.Error(), + ) + respondError(w, "Failed to move file", http.StatusInternalServerError) + return + } + + response := SaveFileResponse{ + FilePath: decodedDestPath, + Size: -1, // Size is not applicable for move operation + UpdatedAt: time.Now().UTC(), + } + respondJSON(w, response) } } diff --git a/server/internal/storage/files.go b/server/internal/storage/files.go index 867e33d..3d91d8b 100644 --- a/server/internal/storage/files.go +++ b/server/internal/storage/files.go @@ -192,7 +192,7 @@ func (s *Service) MoveFile(userID, workspaceID int, srcPath string, dstPath stri } if err := s.fs.MoveFile(srcFullPath, dstFullPath); err != nil { - return fmt.Errorf("failed to move file: %w", err) + return err } log.Debug("file moved", diff --git a/server/internal/storage/filesystem.go b/server/internal/storage/filesystem.go index 5350859..1aaed4c 100644 --- a/server/internal/storage/filesystem.go +++ b/server/internal/storage/filesystem.go @@ -41,6 +41,12 @@ func (f *osFS) WriteFile(path string, data []byte, perm fs.FileMode) error { // MoveFile moves the file from src to dst, overwriting if necessary. func (f *osFS) MoveFile(src, dst string) error { + _, err := os.Stat(src) + if err != nil { + if os.IsNotExist(err) { + return os.ErrNotExist + } + } if err := os.Rename(src, dst); err != nil { if os.IsExist(err) { // If the destination exists, remove it and try again From f4d8a354c46ef6ebf20d83d2c595b1ff354e8fd2 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 11:58:50 +0200 Subject: [PATCH 12/21] Add parameters for source and destination paths in moveFile endpoint --- server/internal/handlers/file_handlers.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index b5695cc..ee419a5 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -406,6 +406,8 @@ func (h *Handler) UploadFile() http.HandlerFunc { // @ID moveFile // @Security CookieAuth // @Param workspace_name path string true "Workspace name" +// @Param src_path query string true "Source file path" +// @Param dest_path query string true "Destination file path" // @Success 204 "No Content - File moved successfully" // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 404 {object} ErrorResponse "File not found" From 4ea24f07d12cf02a2a984d4767c4820d872bde8b Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 12:34:17 +0200 Subject: [PATCH 13/21] Refactor file routes and update API endpoints for consistency --- server/internal/app/routes.go | 16 +- server/internal/handlers/file_handlers.go | 141 +++++++++--------- .../file_handlers_integration_test.go | 46 +++--- 3 files changed, 100 insertions(+), 103 deletions(-) diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go index 1dd17da..6bb2946 100644 --- a/server/internal/app/routes.go +++ b/server/internal/app/routes.go @@ -129,16 +129,16 @@ func setupRouter(o Options) *chi.Mux { // File routes r.Route("/files", func(r chi.Router) { r.Get("/", handler.ListFiles()) - r.Get("/_op/last", handler.GetLastOpenedFile()) - r.Put("/_op/last", handler.UpdateLastOpenedFile()) - r.Get("/_op/lookup", handler.LookupFileByName()) + r.Get("/last", handler.GetLastOpenedFile()) + r.Put("/last", handler.UpdateLastOpenedFile()) + r.Get("/lookup", handler.LookupFileByName()) - r.Post("/_op/upload/*", handler.UploadFile()) - r.Put("/_op/move", handler.MoveFile()) + r.Post("/upload", handler.UploadFile()) + r.Put("/move", handler.MoveFile()) - r.Post("/*", handler.SaveFile()) - r.Get("/*", handler.GetFileContent()) - r.Delete("/*", handler.DeleteFile()) + r.Post("/", handler.SaveFile()) + r.Get("/content", handler.GetFileContent()) + r.Delete("/", handler.DeleteFile()) }) // Git routes diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index ee419a5..16e3613 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "io" "net/http" "net/url" @@ -11,8 +10,6 @@ import ( "lemma/internal/context" "lemma/internal/logging" "lemma/internal/storage" - - "github.com/go-chi/chi/v5" ) // LookupResponse represents a response to a file lookup request @@ -32,11 +29,6 @@ type LastOpenedFileResponse struct { LastOpenedFilePath string `json:"lastOpenedFilePath"` } -// UpdateLastOpenedFileRequest represents a request to update the last opened file -type UpdateLastOpenedFileRequest struct { - FilePath string `json:"filePath"` -} - func getFilesLogger() logging.Logger { return getHandlersLogger().WithGroup("files") } @@ -90,7 +82,7 @@ func (h *Handler) ListFiles() http.HandlerFunc { // @Success 200 {object} LookupResponse // @Failure 400 {object} ErrorResponse "Filename is required" // @Failure 404 {object} ErrorResponse "File not found" -// @Router /workspaces/{workspace_name}/files/_op/lookup [get] +// @Router /workspaces/{workspace_name}/files/lookup [get] func (h *Handler) LookupFileByName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -150,13 +142,13 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { // @Security CookieAuth // @Produce plain // @Param workspace_name path string true "Workspace name" -// @Param file_path path string true "File path" +// @Param file_path query string true "File path" // @Success 200 {string} string "Raw file content" // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 404 {object} ErrorResponse "File not found" // @Failure 500 {object} ErrorResponse "Failed to read file" // @Failure 500 {object} ErrorResponse "Failed to write response" -// @Router /workspaces/{workspace_name}/files/{file_path} [get] +// @Router /workspaces/{workspace_name}/files/content [get] func (h *Handler) GetFileContent() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -170,8 +162,7 @@ func (h *Handler) GetFileContent() http.HandlerFunc { "clientIP", r.RemoteAddr, ) - filePath := chi.URLParam(r, "*") - // URL-decode the file path + filePath := r.URL.Query().Get("file_path") decodedPath, err := url.PathUnescape(filePath) if err != nil { log.Error("failed to decode file path", @@ -231,12 +222,12 @@ func (h *Handler) GetFileContent() http.HandlerFunc { // @Accept plain // @Produce json // @Param workspace_name path string true "Workspace name" -// @Param file_path path string true "File path" +// @Param file_path query string true "File path" // @Success 200 {object} SaveFileResponse // @Failure 400 {object} ErrorResponse "Failed to read request body" // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 500 {object} ErrorResponse "Failed to save file" -// @Router /workspaces/{workspace_name}/files/{file_path} [post] +// @Router /workspaces/{workspace_name}/files/ [post] func (h *Handler) SaveFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -250,7 +241,7 @@ func (h *Handler) SaveFile() http.HandlerFunc { "clientIP", r.RemoteAddr, ) - filePath := chi.URLParam(r, "*") + filePath := r.URL.Query().Get("file_path") // URL-decode the file path decodedPath, err := url.PathUnescape(filePath) if err != nil { @@ -311,14 +302,14 @@ func (h *Handler) SaveFile() http.HandlerFunc { // @Accept multipart/form-data // @Produce json // @Param workspace_name path string true "Workspace name" -// @Param path path string true "Directory path" +// @Param file_path query string true "Directory path" // @Param file formData file true "File to upload" // @Success 200 {object} SaveFileResponse // @Failure 400 {object} ErrorResponse "Failed to get file from form" // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 500 {object} ErrorResponse "Failed to read uploaded file" // @Failure 500 {object} ErrorResponse "Failed to save file" -// @Router /workspaces/{workspace_name}/files/_op/upload/{path} [post] +// @Router /workspaces/{workspace_name}/files/upload/ [post] func (h *Handler) UploadFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -348,33 +339,40 @@ func (h *Handler) UploadFile() http.HandlerFunc { } }() - decodedPath, err := url.PathUnescape(chi.URLParam(r, "*")) + filePath := r.URL.Query().Get("file_path") + if filePath == "" { + log.Debug("missing file_path parameter") + respondError(w, "file_path is required", http.StatusBadRequest) + return + } + + decodedPath, err := url.PathUnescape(filePath) if err != nil { log.Error("failed to decode file path", - "filePath", decodedPath, + "filePath", filePath, "error", err.Error(), ) respondError(w, "Invalid file path", http.StatusBadRequest) return } - filePath := decodedPath + "/" + header.Filename + decodedPath = decodedPath + "/" + header.Filename content := make([]byte, header.Size) _, err = file.Read(content) if err != nil && err != io.EOF { log.Error("failed to read uploaded file", - "filePath", filePath, + "filePath", decodedPath, "error", err.Error(), ) respondError(w, "Failed to read uploaded file", http.StatusInternalServerError) return } - err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) + err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, decodedPath, content) if err != nil { if storage.IsPathValidationError(err) { log.Error("invalid file path attempted", - "filePath", filePath, + "filePath", decodedPath, "error", err.Error(), ) respondError(w, "Invalid file path", http.StatusBadRequest) @@ -382,7 +380,7 @@ func (h *Handler) UploadFile() http.HandlerFunc { } log.Error("failed to save file", - "filePath", filePath, + "filePath", decodedPath, "contentSize", len(content), "error", err.Error(), ) @@ -391,7 +389,7 @@ func (h *Handler) UploadFile() http.HandlerFunc { } response := SaveFileResponse{ - FilePath: filePath, + FilePath: decodedPath, Size: int64(len(content)), UpdatedAt: time.Now().UTC(), } @@ -412,7 +410,7 @@ func (h *Handler) UploadFile() http.HandlerFunc { // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 404 {object} ErrorResponse "File not found" // @Failure 500 {object} ErrorResponse "Failed to move file" -// @Router /workspaces/{workspace_name}/files/_op/move [post] +// @Router /workspaces/{workspace_name}/files/move [post] func (h *Handler) MoveFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -498,12 +496,12 @@ func (h *Handler) MoveFile() http.HandlerFunc { // @ID deleteFile // @Security CookieAuth // @Param workspace_name path string true "Workspace name" -// @Param file_path path string true "File path" +// @Param file_path query string true "File path" // @Success 204 "No Content - File deleted successfully" // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 404 {object} ErrorResponse "File not found" // @Failure 500 {object} ErrorResponse "Failed to delete file" -// @Router /workspaces/{workspace_name}/files/{file_path} [delete] +// @Router /workspaces/{workspace_name}/files/ [delete] func (h *Handler) DeleteFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -517,7 +515,13 @@ func (h *Handler) DeleteFile() http.HandlerFunc { "clientIP", r.RemoteAddr, ) - filePath := chi.URLParam(r, "*") + filePath := r.URL.Query().Get("file_path") + if filePath == "" { + log.Debug("missing file_path parameter") + respondError(w, "file_path is required", http.StatusBadRequest) + return + } + // URL-decode the file path decodedPath, err := url.PathUnescape(filePath) if err != nil { @@ -571,7 +575,7 @@ func (h *Handler) DeleteFile() http.HandlerFunc { // @Success 200 {object} LastOpenedFileResponse // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 500 {object} ErrorResponse "Failed to get last opened file" -// @Router /workspaces/{workspace_name}/files/_op/last [get] +// @Router /workspaces/{workspace_name}/files/last [get] func (h *Handler) GetLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -616,13 +620,13 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc { // @Accept json // @Produce json // @Param workspace_name path string true "Workspace name" -// @Param body body UpdateLastOpenedFileRequest true "Update last opened file request" +// @Param file_path query string true "File path" // @Success 204 "No Content - Last opened file updated successfully" // @Failure 400 {object} ErrorResponse "Invalid request body" // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 404 {object} ErrorResponse "File not found" // @Failure 500 {object} ErrorResponse "Failed to update file" -// @Router /workspaces/{workspace_name}/files/_op/last [put] +// @Router /workspaces/{workspace_name}/files/last [put] func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -636,60 +640,53 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { "clientIP", r.RemoteAddr, ) - var requestBody UpdateLastOpenedFileRequest - if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - log.Error("failed to decode request body", - "error", err.Error(), - ) - respondError(w, "Invalid request body", http.StatusBadRequest) + filePath := r.URL.Query().Get("file_path") + if filePath == "" { + log.Debug("missing file_path parameter") + respondError(w, "file_path is required", http.StatusBadRequest) return } - // Validate the file path in the workspace - if requestBody.FilePath != "" { - // URL-decode the file path - decodedPath, err := url.PathUnescape(requestBody.FilePath) - if err != nil { - log.Error("failed to decode file path", - "filePath", requestBody.FilePath, + decodedPath, err := url.PathUnescape(filePath) + if err != nil { + log.Error("failed to decode file path", + "filePath", filePath, + "error", err.Error(), + ) + respondError(w, "Invalid file path", http.StatusBadRequest) + return + } + + _, err = h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, decodedPath) + if err != nil { + if storage.IsPathValidationError(err) { + log.Error("invalid file path attempted", + "filePath", decodedPath, "error", err.Error(), ) respondError(w, "Invalid file path", http.StatusBadRequest) return } - requestBody.FilePath = decodedPath - _, err = h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath) - if err != nil { - if storage.IsPathValidationError(err) { - log.Error("invalid file path attempted", - "filePath", requestBody.FilePath, - "error", err.Error(), - ) - respondError(w, "Invalid file path", http.StatusBadRequest) - return - } - - if os.IsNotExist(err) { - log.Debug("file not found", - "filePath", requestBody.FilePath, - ) - respondError(w, "File not found", http.StatusNotFound) - return - } - - log.Error("failed to validate file path", - "filePath", requestBody.FilePath, - "error", err.Error(), + if os.IsNotExist(err) { + log.Debug("file not found", + "filePath", decodedPath, ) - respondError(w, "Failed to update last opened file", http.StatusInternalServerError) + respondError(w, "File not found", http.StatusNotFound) return } + + log.Error("failed to validate file path", + "filePath", decodedPath, + "error", err.Error(), + ) + respondError(w, "Failed to update last opened file", http.StatusInternalServerError) + return } - if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil { + if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, decodedPath); err != nil { log.Error("failed to update last opened file in database", - "filePath", requestBody.FilePath, + "filePath", decodedPath, "error", err.Error(), ) respondError(w, "Failed to update last opened file", http.StatusInternalServerError) diff --git a/server/internal/handlers/file_handlers_integration_test.go b/server/internal/handlers/file_handlers_integration_test.go index edef78b..3d64e40 100644 --- a/server/internal/handlers/file_handlers_integration_test.go +++ b/server/internal/handlers/file_handlers_integration_test.go @@ -55,11 +55,11 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { filePath := "test.md" // Save file - rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"/"+filePath, strings.NewReader(content), h.RegularTestUser) + rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape(filePath), strings.NewReader(content), h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) // Get file content - rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(filePath), nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, content, rr.Body.String()) @@ -84,7 +84,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { // Create all files for path, content := range files { - rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+path, content, h.RegularTestUser) + rr := h.makeRequest(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape(path), content, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) } @@ -120,11 +120,11 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { // Look up a file that exists in multiple locations filename := "readme.md" dupContent := "Another readme" - rr := h.makeRequest(t, http.MethodPost, baseURL+"/projects/"+filename, dupContent, h.RegularTestUser) + rr := h.makeRequest(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape("projects/"+filename), dupContent, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) // Search for the file - rr = h.makeRequest(t, http.MethodGet, baseURL+"/_op/lookup?filename="+filename, nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename="+url.QueryEscape(filename), nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) var response struct { @@ -135,7 +135,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { assert.Len(t, response.Paths, 2) // Search for non-existent file - rr = h.makeRequest(t, http.MethodGet, baseURL+"/_op/lookup?filename=nonexistent.md", nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename="+url.QueryEscape("nonexistent.md"), nil, h.RegularTestUser) assert.Equal(t, http.StatusNotFound, rr.Code) }) @@ -144,21 +144,21 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { content := "This file will be deleted" // Create file - rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+filePath, content, h.RegularTestUser) + rr := h.makeRequest(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape(filePath), content, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) // Delete file - rr = h.makeRequest(t, http.MethodDelete, baseURL+"/"+filePath, nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodDelete, baseURL+"?file_path="+url.QueryEscape(filePath), nil, h.RegularTestUser) require.Equal(t, http.StatusNoContent, rr.Code) // Verify file is gone - rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(filePath), nil, h.RegularTestUser) assert.Equal(t, http.StatusNotFound, rr.Code) }) t.Run("last opened file", func(t *testing.T) { // Initially should be empty - rr := h.makeRequest(t, http.MethodGet, baseURL+"/_op/last", nil, h.RegularTestUser) + rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) var response struct { @@ -174,11 +174,11 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { }{ FilePath: "docs/readme.md", } - rr = h.makeRequest(t, http.MethodPut, baseURL+"/_op/last", updateReq, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodPut, baseURL+"/last?file_path="+url.QueryEscape(updateReq.FilePath), nil, h.RegularTestUser) require.Equal(t, http.StatusNoContent, rr.Code) // Verify update - rr = h.makeRequest(t, http.MethodGet, baseURL+"/_op/last", nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) err = json.NewDecoder(rr.Body).Decode(&response) @@ -187,7 +187,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { // Test invalid file path updateReq.FilePath = "nonexistent.md" - rr = h.makeRequest(t, http.MethodPut, baseURL+"/_op/last", updateReq, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodPut, baseURL+"/last?file_path="+url.QueryEscape(updateReq.FilePath), nil, h.RegularTestUser) assert.Equal(t, http.StatusNotFound, rr.Code) }) @@ -199,11 +199,11 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { body any }{ {"list files", http.MethodGet, baseURL, nil}, - {"get file", http.MethodGet, baseURL + "/test.md", nil}, - {"save file", http.MethodPost, baseURL + "/test.md", "content"}, - {"delete file", http.MethodDelete, baseURL + "/test.md", nil}, - {"get last file", http.MethodGet, baseURL + "/_op/last", nil}, - {"update last file", http.MethodPut, baseURL + "/_op/last", struct{ FilePath string }{"test.md"}}, + {"get file", http.MethodGet, baseURL + "/content?file_path=" + url.QueryEscape("test.md"), nil}, + {"save file", http.MethodPost, baseURL + "?file_path=" + url.QueryEscape("test.md"), "content"}, + {"delete file", http.MethodDelete, baseURL + "?file_path=" + url.QueryEscape("test.md"), nil}, + {"get last file", http.MethodGet, baseURL + "/last", nil}, + {"update last file", http.MethodPut, baseURL + "/last?file_path=" + url.QueryEscape("test.md"), nil}, } for _, tc := range tests { @@ -230,11 +230,11 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { for _, path := range maliciousPaths { t.Run(path, func(t *testing.T) { // Try to read - rr := h.makeRequest(t, http.MethodGet, baseURL+"/"+path, nil, h.RegularTestUser) + rr := h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(path), nil, h.RegularTestUser) assert.Equal(t, http.StatusBadRequest, rr.Code) // Try to write - rr = h.makeRequest(t, http.MethodPost, baseURL+"/"+path, "malicious content", h.RegularTestUser) + rr = h.makeRequest(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape(path), "malicious content", h.RegularTestUser) assert.Equal(t, http.StatusBadRequest, rr.Code) }) } @@ -245,7 +245,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { fileName := "uploaded-test.txt" fileContent := "This is an uploaded file" - rr := h.makeUploadRequest(t, baseURL+"/_op/upload/uploads", fileName, fileContent, h.RegularTestUser) + rr := h.makeUploadRequest(t, baseURL+"/upload?file_path="+url.QueryEscape("uploads"), fileName, fileContent, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) // Verify response structure @@ -260,13 +260,13 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { assert.Equal(t, int64(len(fileContent)), response.Size) // Verify file was saved - rr = h.makeRequest(t, http.MethodGet, baseURL+"/uploads/"+fileName, nil, h.RegularTestUser) + rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape("uploads/"+fileName), nil, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, fileContent, rr.Body.String()) }) t.Run("upload without file", func(t *testing.T) { - rr := h.makeUploadRequest(t, baseURL+"/_op/upload/test", "", "", h.RegularTestUser) + rr := h.makeUploadRequest(t, baseURL+"/upload?file_path="+url.QueryEscape("test"), "", "", h.RegularTestUser) assert.Equal(t, http.StatusBadRequest, rr.Code) }) }) From 1a7c97fb080d442701f6c09643c41ca4a54b0250 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 12:39:51 +0200 Subject: [PATCH 14/21] Refactor file API endpoints for consistency and add moveFile functionality --- app/src/api/file.ts | 80 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/app/src/api/file.ts b/app/src/api/file.ts index a5a9068..df387ce 100644 --- a/app/src/api/file.ts +++ b/app/src/api/file.ts @@ -47,7 +47,7 @@ export const lookupFileByName = async ( const response = await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName - )}/files/_op/lookup?filename=${encodeURIComponent(filename)}` + )}/files/lookup?filename=${encodeURIComponent(filename)}` ); const data: unknown = await response.json(); if (!isLookupResponse(data)) { @@ -71,7 +71,7 @@ export const getFileContent = async ( const response = await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName - )}/files/${encodeURIComponent(filePath)}` + )}/files/content?file_path=${encodeURIComponent(filePath)}` ); return response.text(); }; @@ -92,7 +92,7 @@ export const saveFile = async ( const response = await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName - )}/files/${encodeURIComponent(filePath)}`, + )}/files?file_path=${encodeURIComponent(filePath)}`, { method: 'POST', headers: { @@ -118,7 +118,7 @@ export const deleteFile = async (workspaceName: string, filePath: string) => { await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName - )}/files/${encodeURIComponent(filePath)}`, + )}/files?file_path=${encodeURIComponent(filePath)}`, { method: 'DELETE', } @@ -135,9 +135,7 @@ export const getLastOpenedFile = async ( workspaceName: string ): Promise => { const response = await apiCall( - `${API_BASE_URL}/workspaces/${encodeURIComponent( - workspaceName - )}/files/_op/last` + `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files/last` ); const data: unknown = await response.json(); if ( @@ -163,13 +161,71 @@ export const updateLastOpenedFile = async ( await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName - )}/files/_op/last`, + )}/files/last?file_path=${encodeURIComponent(filePath)}`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ filePath }), } ); }; + +/** + * moveFile moves a file to a new location in a workspace + * @param workspaceName - The name of the workspace + * @param srcPath - The source path of the file to move + * @param destPath - The destination path for the file + * @returns {Promise} A promise that resolves to the move file response + * @throws {Error} If the API call fails or returns an invalid response + */ +export const moveFile = async ( + workspaceName: string, + srcPath: string, + destPath: string +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/move?src_path=${encodeURIComponent( + srcPath + )}&dest_path=${encodeURIComponent(destPath)}`, + { + method: 'POST', + } + ); + const data: unknown = await response.json(); + if (!isSaveFileResponse(data)) { + throw new Error('Invalid move file response received from API'); + } + return data; +}; + +/** + * uploadFile uploads a file to a workspace + * @param workspaceName - The name of the workspace + * @param directoryPath - The directory path where the file should be uploaded + * @param file - The file to upload + * @returns {Promise} A promise that resolves to the upload file response + * @throws {Error} If the API call fails or returns an invalid response + */ +export const uploadFile = async ( + workspaceName: string, + directoryPath: string, + file: File +): Promise => { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/upload?file_path=${encodeURIComponent(directoryPath)}`, + { + method: 'POST', + body: formData, + } + ); + const data: unknown = await response.json(); + if (!isSaveFileResponse(data)) { + throw new Error('Invalid upload file response received from API'); + } + return data; +}; From 51c6f62c44dcbec58df5fd69d7ca01ce38a60101 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 13:59:30 +0200 Subject: [PATCH 15/21] Enhance uploadFile to support multiple files and update handleMove to process file paths for moving files --- app/src/api/file.ts | 14 ++++--- app/src/components/files/FileTree.tsx | 56 +++++++++++++++++++++++++-- app/src/hooks/useFileOperations.ts | 40 ++++++++++++------- 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/app/src/api/file.ts b/app/src/api/file.ts index df387ce..862c4ce 100644 --- a/app/src/api/file.ts +++ b/app/src/api/file.ts @@ -199,20 +199,24 @@ export const moveFile = async ( }; /** - * uploadFile uploads a file to a workspace + * uploadFile uploads multiple files to a workspace * @param workspaceName - The name of the workspace - * @param directoryPath - The directory path where the file should be uploaded - * @param file - The file to upload + * @param directoryPath - The directory path where files should be uploaded + * @param files - Multiple files to upload * @returns {Promise} A promise that resolves to the upload file response * @throws {Error} If the API call fails or returns an invalid response */ export const uploadFile = async ( workspaceName: string, directoryPath: string, - file: File + files: FileList ): Promise => { const formData = new FormData(); - formData.append('file', file); + + // Add all files to the form data + Array.from(files).forEach((file) => { + formData.append('file', file); + }); const response = await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( diff --git a/app/src/components/files/FileTree.tsx b/app/src/components/files/FileTree.tsx index 67f0c9e..f940016 100644 --- a/app/src/components/files/FileTree.tsx +++ b/app/src/components/files/FileTree.tsx @@ -103,7 +103,49 @@ function Node({ ); } -const FileTree: React.FC = ({ +// Utility function to recursively find file paths by IDs +const findFilePathsById = (files: FileNode[], ids: string[]): string[] => { + const paths: string[] = []; + + const searchFiles = (nodes: FileNode[]) => { + for (const node of nodes) { + if (ids.includes(node.id)) { + paths.push(node.path); + } + if (node.children) { + searchFiles(node.children); + } + } + }; + + searchFiles(files); + return paths; +}; + +// Utility function to find parent path by ID +const findParentPathById = ( + files: FileNode[], + parentId: string | null +): string => { + if (!parentId) return ''; + + const searchFiles = (nodes: FileNode[]): string | null => { + for (const node of nodes) { + if (node.id === parentId) { + return node.path; + } + if (node.children) { + const result = searchFiles(node.children); + if (result) return result; + } + } + return null; + }; + + return searchFiles(files) || ''; +}; + +export const FileTree: React.FC = ({ files, handleFileSelect, showHiddenFiles, @@ -143,7 +185,14 @@ const FileTree: React.FC = ({ index: number; }) => { try { - const success = await handleMove(dragIds, parentId, index); + // Map dragged file IDs to their corresponding paths + const dragPaths = findFilePathsById(filteredFiles, dragIds); + + // Find the parent path where files will be moved + const targetParentPath = findParentPathById(filteredFiles, parentId); + + // Move files to the new location + const success = await handleMove(dragPaths, targetParentPath, index); if (success) { await loadFileList(); } @@ -151,7 +200,7 @@ const FileTree: React.FC = ({ console.error('Error moving files:', error); } }, - [handleMove, loadFileList] + [handleMove, loadFileList, filteredFiles] ); // External file drag and drop handlers @@ -256,7 +305,6 @@ const FileTree: React.FC = ({ height={size.height} indent={24} rowHeight={28} - // Enable drag and drop for moving files onMove={handleTreeMove} onActivate={(node) => { const fileNode = node.data; diff --git a/app/src/hooks/useFileOperations.ts b/app/src/hooks/useFileOperations.ts index e2e5004..0bec62c 100644 --- a/app/src/hooks/useFileOperations.ts +++ b/app/src/hooks/useFileOperations.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; -import { saveFile, deleteFile } from '../api/file'; +import { saveFile, deleteFile, uploadFile, moveFile } from '../api/file'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; import { useGitOperations } from './useGitOperations'; import { FileAction } from '@/types/models'; @@ -11,9 +11,9 @@ interface UseFileOperationsResult { handleCreate: (fileName: string, initialContent?: string) => Promise; handleUpload: (files: FileList, targetPath?: string) => Promise; handleMove: ( - dragIds: string[], - parentId: string | null, - index: number + filePaths: string[], + destinationParentPath: string, + index?: number ) => Promise; handleRename: (oldPath: string, newPath: string) => Promise; } @@ -117,13 +117,13 @@ export const useFileOperations = (): UseFileOperationsResult => { [currentWorkspace, autoCommit] ); - // Add these to your hook implementation: const handleUpload = useCallback( async (files: FileList, targetPath?: string): Promise => { if (!currentWorkspace) return false; try { - // TODO: Implement your file upload API call + // Use unified upload API that handles both single and multiple files + await uploadFile(currentWorkspace.name, targetPath || '', files); notifications.show({ title: 'Success', @@ -149,18 +149,32 @@ export const useFileOperations = (): UseFileOperationsResult => { const handleMove = useCallback( async ( - dragIds: string[], - parentId: string | null, - index: number + filePaths: string[], + destinationParentPath: string, + _index?: number ): Promise => { if (!currentWorkspace) return false; try { - // TODO: Implement your file move API call + // Move each file to the destination directory + const movePromises = filePaths.map(async (filePath) => { + // Extract the filename from the path + const fileName = filePath.split('/').pop() || ''; + + // Construct the destination path + const destinationPath = destinationParentPath + ? `${destinationParentPath}/${fileName}` + : fileName; + + // Call the API to move the file + await moveFile(currentWorkspace.name, filePath, destinationPath); + }); + + await Promise.all(movePromises); notifications.show({ title: 'Success', - message: `Successfully moved ${dragIds.length} file(s)`, + message: `Successfully moved ${filePaths.length} file(s)`, color: 'green', }); @@ -185,8 +199,8 @@ export const useFileOperations = (): UseFileOperationsResult => { if (!currentWorkspace) return false; try { - // TODO: Replace with your actual rename API call - // await renameFile(currentWorkspace.name, oldPath, newPath); + // Use moveFile API for renaming (rename is essentially a move operation) + await moveFile(currentWorkspace.name, oldPath, newPath); notifications.show({ title: 'Success', From ff4d1de2b79d4c7b483e44d3f06ffa466f730228 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 14:25:03 +0200 Subject: [PATCH 16/21] Refactor uploadFile to support multiple file uploads and update related types and handlers --- app/src/api/file.ts | 10 +- app/src/hooks/useFileOperations.ts | 1 - app/src/types/api.ts | 18 +++ server/internal/handlers/file_handlers.go | 132 +++++++++++++--------- 4 files changed, 105 insertions(+), 56 deletions(-) diff --git a/app/src/api/file.ts b/app/src/api/file.ts index 862c4ce..785c0f8 100644 --- a/app/src/api/file.ts +++ b/app/src/api/file.ts @@ -4,7 +4,9 @@ import { API_BASE_URL, isLookupResponse, isSaveFileResponse, + isUploadFilesResponse, type SaveFileResponse, + type UploadFilesResponse, } from '@/types/api'; /** @@ -203,19 +205,19 @@ export const moveFile = async ( * @param workspaceName - The name of the workspace * @param directoryPath - The directory path where files should be uploaded * @param files - Multiple files to upload - * @returns {Promise} A promise that resolves to the upload file response + * @returns {Promise} A promise that resolves to the upload file response * @throws {Error} If the API call fails or returns an invalid response */ export const uploadFile = async ( workspaceName: string, directoryPath: string, files: FileList -): Promise => { +): Promise => { const formData = new FormData(); // Add all files to the form data Array.from(files).forEach((file) => { - formData.append('file', file); + formData.append('files', file); }); const response = await apiCall( @@ -228,7 +230,7 @@ export const uploadFile = async ( } ); const data: unknown = await response.json(); - if (!isSaveFileResponse(data)) { + if (!isUploadFilesResponse(data)) { throw new Error('Invalid upload file response received from API'); } return data; diff --git a/app/src/hooks/useFileOperations.ts b/app/src/hooks/useFileOperations.ts index 0bec62c..6317c47 100644 --- a/app/src/hooks/useFileOperations.ts +++ b/app/src/hooks/useFileOperations.ts @@ -122,7 +122,6 @@ export const useFileOperations = (): UseFileOperationsResult => { if (!currentWorkspace) return false; try { - // Use unified upload API that handles both single and multiple files await uploadFile(currentWorkspace.name, targetPath || '', files); notifications.show({ diff --git a/app/src/types/api.ts b/app/src/types/api.ts index 1783cbb..d60b71d 100644 --- a/app/src/types/api.ts +++ b/app/src/types/api.ts @@ -98,6 +98,24 @@ export function isSaveFileResponse(obj: unknown): obj is SaveFileResponse { ); } +export interface UploadFilesResponse { + filePaths: string[]; +} + +export function isUploadFilesResponse( + obj: unknown +): obj is UploadFilesResponse { + return ( + typeof obj === 'object' && + obj !== null && + 'filePaths' in obj && + Array.isArray((obj as UploadFilesResponse).filePaths) && + (obj as UploadFilesResponse).filePaths.every( + (path) => typeof path === 'string' + ) + ); +} + export interface UpdateLastOpenedFileRequest { filePath: string; } diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index 16e3613..a2ecb51 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -24,6 +24,10 @@ type SaveFileResponse struct { UpdatedAt time.Time `json:"updatedAt"` } +type UploadFilesResponse struct { + FilePaths []string `json:"filePaths"` +} + // LastOpenedFileResponse represents a response to a last opened file request type LastOpenedFileResponse struct { LastOpenedFilePath string `json:"lastOpenedFilePath"` @@ -294,8 +298,8 @@ func (h *Handler) SaveFile() http.HandlerFunc { } // UploadFile godoc -// @Summary Upload file -// @Description Uploads a file to the user's workspace +// @Summary Upload files +// @Description Uploads one or more files to the user's workspace // @Tags files // @ID uploadFile // @Security CookieAuth @@ -303,10 +307,13 @@ func (h *Handler) SaveFile() http.HandlerFunc { // @Produce json // @Param workspace_name path string true "Workspace name" // @Param file_path query string true "Directory path" -// @Param file formData file true "File to upload" -// @Success 200 {object} SaveFileResponse -// @Failure 400 {object} ErrorResponse "Failed to get file from form" +// @Param files formData file true "Files to upload" +// @Success 200 {object} UploadFilesResponse +// @Failure 400 {object} ErrorResponse "No files found in form" +// @Failure 400 {object} ErrorResponse "file_path is required" // @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 400 {object} ErrorResponse "Empty file uploaded" +// @Failure 400 {object} ErrorResponse "Failed to get file from form" // @Failure 500 {object} ErrorResponse "Failed to read uploaded file" // @Failure 500 {object} ErrorResponse "Failed to save file" // @Router /workspaces/{workspace_name}/files/upload/ [post] @@ -323,75 +330,98 @@ func (h *Handler) UploadFile() http.HandlerFunc { "clientIP", r.RemoteAddr, ) - file, header, err := r.FormFile("file") - if err != nil { - log.Error("failed to get file from form", - "error", err.Error(), - ) - respondError(w, "Failed to get file from form", http.StatusBadRequest) + form := r.MultipartForm + if form == nil || len(form.File) == 0 { + log.Debug("no files found in form") + respondError(w, "No files found in form", http.StatusBadRequest) return } - defer func() { - if err := file.Close(); err != nil { - log.Error("failed to close uploaded file", - "error", err.Error(), - ) - } - }() - filePath := r.URL.Query().Get("file_path") - if filePath == "" { + uploadPath := r.URL.Query().Get("file_path") + if uploadPath == "" { log.Debug("missing file_path parameter") respondError(w, "file_path is required", http.StatusBadRequest) return } - decodedPath, err := url.PathUnescape(filePath) + decodedPath, err := url.PathUnescape(uploadPath) if err != nil { log.Error("failed to decode file path", - "filePath", filePath, + "filePath", uploadPath, "error", err.Error(), ) respondError(w, "Invalid file path", http.StatusBadRequest) return } - decodedPath = decodedPath + "/" + header.Filename - content := make([]byte, header.Size) - _, err = file.Read(content) - if err != nil && err != io.EOF { - log.Error("failed to read uploaded file", - "filePath", decodedPath, - "error", err.Error(), - ) - respondError(w, "Failed to read uploaded file", http.StatusInternalServerError) - return - } + uploadedPaths := []string{} - err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, decodedPath, content) - if err != nil { - if storage.IsPathValidationError(err) { - log.Error("invalid file path attempted", - "filePath", decodedPath, - "error", err.Error(), + for _, formFile := range form.File["files"] { + + if formFile.Filename == "" || formFile.Size == 0 { + log.Debug("empty file uploaded", + "fileName", formFile.Filename, + "fileSize", formFile.Size, ) - respondError(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Empty file uploaded", http.StatusBadRequest) return } - log.Error("failed to save file", - "filePath", decodedPath, - "contentSize", len(content), - "error", err.Error(), - ) - respondError(w, "Failed to save file", http.StatusInternalServerError) - return + // Open the uploaded file + file, err := formFile.Open() + if err != nil { + log.Error("failed to get file from form", + "error", err.Error(), + ) + respondError(w, "Failed to get file from form", http.StatusBadRequest) + return + } + defer func() { + if err := file.Close(); err != nil { + log.Error("failed to close uploaded file", + "error", err.Error(), + ) + } + }() + + filePath := decodedPath + "/" + formFile.Filename + + content := make([]byte, formFile.Size) + _, err = file.Read(content) + if err != nil && err != io.EOF { + log.Error("failed to read uploaded file", + "filePath", filePath, + "error", err.Error(), + ) + respondError(w, "Failed to read uploaded file", http.StatusInternalServerError) + return + } + + err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) + if err != nil { + if storage.IsPathValidationError(err) { + log.Error("invalid file path attempted", + "filePath", filePath, + "error", err.Error(), + ) + respondError(w, "Invalid file path", http.StatusBadRequest) + return + } + + log.Error("failed to save file", + "filePath", filePath, + "contentSize", len(content), + "error", err.Error(), + ) + respondError(w, "Failed to save file", http.StatusInternalServerError) + return + } + + uploadedPaths = append(uploadedPaths, filePath) } - response := SaveFileResponse{ - FilePath: decodedPath, - Size: int64(len(content)), - UpdatedAt: time.Now().UTC(), + response := UploadFilesResponse{ + FilePaths: uploadedPaths, } respondJSON(w, response) } From 41d526af4cab949c598d5db77cc9506aeee18465 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 14:34:04 +0200 Subject: [PATCH 17/21] Remove redundant CSRF token tests and clean up related assertions in apiCall tests --- app/src/api/api.test.ts | 111 ---------------------------------------- 1 file changed, 111 deletions(-) diff --git a/app/src/api/api.test.ts b/app/src/api/api.test.ts index 9b2f2c3..cdc3689 100644 --- a/app/src/api/api.test.ts +++ b/app/src/api/api.test.ts @@ -157,25 +157,6 @@ describe('apiCall', () => { expect(calledOptions['headers']).not.toHaveProperty('X-CSRF-Token'); }); - it('handles URL-encoded CSRF tokens', async () => { - const encodedToken = 'token%20with%20spaces'; - setCookie('csrf_token', encodedToken); - mockFetch.mockResolvedValue(createMockResponse(200, {})); - - await apiCall('https://api.example.com/create', { - method: 'POST', - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': encodedToken, // We shouldn't expect it to be decoded since our api.ts is not decoding it - }, - }); - }); - it('handles missing CSRF token gracefully', async () => { // No CSRF token in cookies mockFetch.mockResolvedValue(createMockResponse(200, {})); @@ -193,47 +174,6 @@ describe('apiCall', () => { }, }); }); - - it('handles multiple cookies and extracts CSRF token correctly', async () => { - Object.defineProperty(document, 'cookie', { - writable: true, - value: - 'session_id=abc123; csrf_token=my-csrf-token; other_cookie=value', - configurable: true, - }); - mockFetch.mockResolvedValue(createMockResponse(200, {})); - - await apiCall('https://api.example.com/create', { - method: 'POST', - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': 'my-csrf-token', - }, - }); - }); - - it('handles empty CSRF token value', async () => { - setCookie('csrf_token', ''); - mockFetch.mockResolvedValue(createMockResponse(200, {})); - - await apiCall('https://api.example.com/create', { - method: 'POST', - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - // No X-CSRF-Token header when token is empty - }, - }); - }); }); describe('error handling', () => { @@ -510,47 +450,9 @@ describe('apiCall', () => { }); } }); - - it('defaults to GET method when method is omitted', async () => { - setCookie('csrf_token', 'test-token'); - mockFetch.mockResolvedValue(createMockResponse(200, {})); - - await apiCall('https://api.example.com/test', {}); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', { - method: undefined, - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - // No CSRF token for undefined (GET) method - }, - }); - }); }); describe('edge cases', () => { - it('handles very long URLs', async () => { - const longUrl = 'https://api.example.com/' + 'a'.repeat(2000); - mockFetch.mockResolvedValue(createMockResponse(200, {})); - - await apiCall(longUrl); - - expect(mockFetch).toHaveBeenCalledWith(longUrl, expect.any(Object)); - }); - - it('handles special characters in URL', async () => { - const urlWithSpecialChars = - 'https://api.example.com/test?param=value&other=test%20value'; - mockFetch.mockResolvedValue(createMockResponse(200, {})); - - await apiCall(urlWithSpecialChars); - - expect(mockFetch).toHaveBeenCalledWith( - urlWithSpecialChars, - expect.any(Object) - ); - }); - it('handles null response body', async () => { const mockResponse = { status: 200, @@ -563,18 +465,5 @@ describe('apiCall', () => { expect(result.status).toBe(200); }); - - it('handles empty string response body', async () => { - const mockResponse = { - status: 200, - ok: true, - json: vi.fn().mockResolvedValue(''), - } as unknown as Response; - mockFetch.mockResolvedValue(mockResponse); - - const result = await apiCall('https://api.example.com/test'); - - expect(result.status).toBe(200); - }); }); }); From 491d056dd4f33f6087f653e508b8355a0960f4a9 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 14:40:07 +0200 Subject: [PATCH 18/21] Add isUploadFilesResponse type guard and related tests; remove prototype pollution tests --- app/src/types/api.test.ts | 242 +++++++++++++++++--------------------- 1 file changed, 107 insertions(+), 135 deletions(-) diff --git a/app/src/types/api.test.ts b/app/src/types/api.test.ts index 5c78066..54270ae 100644 --- a/app/src/types/api.test.ts +++ b/app/src/types/api.test.ts @@ -3,9 +3,11 @@ import { isLoginResponse, isLookupResponse, isSaveFileResponse, + isUploadFilesResponse, type LoginResponse, type LookupResponse, type SaveFileResponse, + type UploadFilesResponse, } from './api'; import { UserRole, type User } from './models'; @@ -139,16 +141,6 @@ describe('API Type Guards', () => { expect(isLoginResponse(invalidResponse)).toBe(false); }); - - it('handles objects with prototype pollution attempts', () => { - const maliciousObj = { - user: mockUser, - __proto__: { malicious: true }, - constructor: { prototype: { polluted: true } }, - }; - - expect(isLoginResponse(maliciousObj)).toBe(true); - }); }); describe('isLookupResponse', () => { @@ -243,31 +235,6 @@ describe('API Type Guards', () => { expect(isLookupResponse(responseWithExtra)).toBe(true); }); - - it('handles objects with prototype pollution attempts', () => { - const maliciousObj = { - paths: ['path1.md', 'path2.md'], - __proto__: { malicious: true }, - constructor: { prototype: { polluted: true } }, - }; - - expect(isLookupResponse(maliciousObj)).toBe(true); - }); - - it('handles complex path strings', () => { - const validLookupResponse: LookupResponse = { - paths: [ - 'simple.md', - 'folder/nested.md', - 'deep/nested/path/file.md', - 'file with spaces.md', - 'special-chars_123.md', - 'unicode-文件.md', - ], - }; - - expect(isLookupResponse(validLookupResponse)).toBe(true); - }); }); describe('isSaveFileResponse', () => { @@ -387,18 +354,6 @@ describe('API Type Guards', () => { expect(isSaveFileResponse(invalidResponse)).toBe(true); // Note: Type guard doesn't validate negative numbers }); - it('handles objects with prototype pollution attempts', () => { - const maliciousObj = { - filePath: 'test.md', - size: 1024, - updatedAt: '2024-01-01T10:00:00Z', - __proto__: { malicious: true }, - constructor: { prototype: { polluted: true } }, - }; - - expect(isSaveFileResponse(maliciousObj)).toBe(true); - }); - it('handles objects with extra properties', () => { const responseWithExtra = { filePath: 'test.md', @@ -409,105 +364,122 @@ describe('API Type Guards', () => { expect(isSaveFileResponse(responseWithExtra)).toBe(true); }); + }); - it('handles complex file paths', () => { - const validSaveFileResponse: SaveFileResponse = { - filePath: 'deep/nested/path/file with spaces & symbols.md', - size: 2048, - updatedAt: '2024-01-01T10:00:00Z', + describe('isUploadFilesResponse', () => { + it('returns true for valid upload files response', () => { + const validUploadFilesResponse: UploadFilesResponse = { + filePaths: [ + 'documents/file1.md', + 'images/photo.jpg', + 'notes/readme.txt', + ], }; - expect(isSaveFileResponse(validSaveFileResponse)).toBe(true); + expect(isUploadFilesResponse(validUploadFilesResponse)).toBe(true); }); - it('handles various ISO date formats', () => { - const dateFormats = [ - '2024-01-01T10:00:00Z', - '2024-01-01T10:00:00.000Z', - '2024-01-01T10:00:00+00:00', - '2024-01-01T10:00:00.123456Z', - ]; + it('returns true for upload files response with empty array', () => { + const validUploadFilesResponse: UploadFilesResponse = { + filePaths: [], + }; - dateFormats.forEach((dateString) => { - const validResponse: SaveFileResponse = { - filePath: 'test.md', - size: 1024, - updatedAt: dateString, - }; + expect(isUploadFilesResponse(validUploadFilesResponse)).toBe(true); + }); - expect(isSaveFileResponse(validResponse)).toBe(true); - }); + it('returns true for single file upload', () => { + const validUploadFilesResponse: UploadFilesResponse = { + filePaths: ['single-file.md'], + }; + + expect(isUploadFilesResponse(validUploadFilesResponse)).toBe(true); + }); + + it('returns false for null', () => { + expect(isUploadFilesResponse(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isUploadFilesResponse(undefined)).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isUploadFilesResponse('string')).toBe(false); + expect(isUploadFilesResponse(123)).toBe(false); + expect(isUploadFilesResponse(true)).toBe(false); + expect(isUploadFilesResponse([])).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isUploadFilesResponse({})).toBe(false); + }); + + it('returns false when filePaths field is missing', () => { + const invalidResponse = { + otherField: 'value', + }; + + expect(isUploadFilesResponse(invalidResponse)).toBe(false); + }); + + it('returns false when filePaths is not an array', () => { + const invalidResponse = { + filePaths: 'not-an-array', + }; + + expect(isUploadFilesResponse(invalidResponse)).toBe(false); + }); + + it('returns false when filePaths contains non-string values', () => { + const invalidResponse = { + filePaths: ['valid-file.md', 123, 'another-file.md'], + }; + + expect(isUploadFilesResponse(invalidResponse)).toBe(false); + }); + + it('returns false when filePaths contains null values', () => { + const invalidResponse = { + filePaths: ['file1.md', null, 'file2.md'], + }; + + expect(isUploadFilesResponse(invalidResponse)).toBe(false); + }); + + it('returns false when filePaths contains undefined values', () => { + const invalidResponse = { + filePaths: ['file1.md', undefined, 'file2.md'], + }; + + expect(isUploadFilesResponse(invalidResponse)).toBe(false); + }); + + it('handles objects with extra properties', () => { + const responseWithExtra = { + filePaths: ['file1.md', 'file2.md'], + extraField: 'should be ignored', + }; + + expect(isUploadFilesResponse(responseWithExtra)).toBe(true); }); }); describe('edge cases and error conditions', () => { - it('handles circular references gracefully', () => { - const circularObj: { paths: string[]; self?: unknown } = { paths: [] }; - circularObj.self = circularObj; - - // Should not throw an error - expect(isLookupResponse(circularObj)).toBe(true); - }); - - it('handles deeply nested objects', () => { - const deeplyNested = { - user: { - ...mockUser, - nested: { - deep: { - deeper: { - value: 'test', - }, - }, - }, - }, - }; - - expect(isLoginResponse(deeplyNested)).toBe(true); - }); - - it('handles frozen objects', () => { - const frozenResponse = Object.freeze({ - paths: Object.freeze(['path1.md', 'path2.md']), - }); - - expect(isLookupResponse(frozenResponse)).toBe(true); - }); - - it('handles objects created with null prototype', () => { - const nullProtoObj = Object.create(null) as Record; - nullProtoObj['filePath'] = 'test.md'; - nullProtoObj['size'] = 1024; - nullProtoObj['updatedAt'] = '2024-01-01T10:00:00Z'; - - expect(isSaveFileResponse(nullProtoObj)).toBe(true); - }); - }); - - describe('performance with large data', () => { - it('handles large paths arrays efficiently', () => { - const largePaths = Array.from({ length: 10000 }, (_, i) => `path${i}.md`); - const largeResponse = { - paths: largePaths, - }; - - const start = performance.now(); - const result = isLookupResponse(largeResponse); - const end = performance.now(); - - expect(result).toBe(true); - expect(end - start).toBeLessThan(100); // Should complete in under 100ms - }); - - it('handles very long file paths', () => { - const longPath = 'a'.repeat(10000); - const responseWithLongPath: SaveFileResponse = { - filePath: longPath, - size: 1024, - updatedAt: '2024-01-01T10:00:00Z', - }; - - expect(isSaveFileResponse(responseWithLongPath)).toBe(true); + it('handles objects with extra properties across different type guards', () => { + // Test that all type guards handle extra properties correctly + expect(isLoginResponse({ user: mockUser, extra: 'field' })).toBe(true); + expect(isLookupResponse({ paths: [], extra: 'field' })).toBe(true); + expect( + isSaveFileResponse({ + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T10:00:00Z', + extra: 'field', + }) + ).toBe(true); + expect( + isUploadFilesResponse({ filePaths: ['file1.md'], extra: 'field' }) + ).toBe(true); }); }); }); From 9f01c64e5e829f715ac42148bcb6fdbcb85b9d73 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 14:58:32 +0200 Subject: [PATCH 19/21] Enhance file upload functionality to support multiple files; update related tests and response structure --- server/internal/handlers/file_handlers.go | 17 +++-- .../file_handlers_integration_test.go | 71 ++++++++++++++++--- server/internal/handlers/integration_test.go | 14 ++-- 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index a2ecb51..dfbbbd8 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -24,6 +24,7 @@ type SaveFileResponse struct { UpdatedAt time.Time `json:"updatedAt"` } +// UploadFilesResponse represents a response to an upload files request type UploadFilesResponse struct { FilePaths []string `json:"filePaths"` } @@ -330,6 +331,16 @@ func (h *Handler) UploadFile() http.HandlerFunc { "clientIP", r.RemoteAddr, ) + // Parse multipart form (max 32MB in memory) + err := r.ParseMultipartForm(32 << 20) + if err != nil { + log.Error("failed to parse multipart form", + "error", err.Error(), + ) + respondError(w, "Failed to parse form", http.StatusBadRequest) + return + } + form := r.MultipartForm if form == nil || len(form.File) == 0 { log.Debug("no files found in form") @@ -338,12 +349,6 @@ func (h *Handler) UploadFile() http.HandlerFunc { } uploadPath := r.URL.Query().Get("file_path") - if uploadPath == "" { - log.Debug("missing file_path parameter") - respondError(w, "file_path is required", http.StatusBadRequest) - return - } - decodedPath, err := url.PathUnescape(uploadPath) if err != nil { log.Error("failed to decode file path", diff --git a/server/internal/handlers/file_handlers_integration_test.go b/server/internal/handlers/file_handlers_integration_test.go index 3d64e40..116e612 100644 --- a/server/internal/handlers/file_handlers_integration_test.go +++ b/server/internal/handlers/file_handlers_integration_test.go @@ -241,23 +241,22 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { }) t.Run("upload file", func(t *testing.T) { - t.Run("successful upload", func(t *testing.T) { + t.Run("successful single file upload", func(t *testing.T) { fileName := "uploaded-test.txt" fileContent := "This is an uploaded file" - rr := h.makeUploadRequest(t, baseURL+"/upload?file_path="+url.QueryEscape("uploads"), fileName, fileContent, h.RegularTestUser) + files := map[string]string{fileName: fileContent} + rr := h.makeUploadRequest(t, baseURL+"/upload?file_path="+url.QueryEscape("uploads"), files, h.RegularTestUser) require.Equal(t, http.StatusOK, rr.Code) - // Verify response structure + // Verify response structure for multiple files API var response struct { - FilePath string `json:"filePath"` - Size int64 `json:"size"` - UpdatedAt string `json:"updatedAt"` + FilePaths []string `json:"filePaths"` } err := json.NewDecoder(rr.Body).Decode(&response) require.NoError(t, err) - assert.Equal(t, "uploads/"+fileName, response.FilePath) - assert.Equal(t, int64(len(fileContent)), response.Size) + require.Len(t, response.FilePaths, 1) + assert.Equal(t, "uploads/"+fileName, response.FilePaths[0]) // Verify file was saved rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape("uploads/"+fileName), nil, h.RegularTestUser) @@ -265,8 +264,62 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) { assert.Equal(t, fileContent, rr.Body.String()) }) + t.Run("successful multiple files upload", func(t *testing.T) { + files := map[string]string{ + "file1.txt": "Content of first file", + "file2.md": "# Content of second file", + "file3.py": "print('Content of third file')", + } + + rr := h.makeUploadRequest(t, baseURL+"/upload?file_path="+url.QueryEscape("batch"), files, h.RegularTestUser) + require.Equal(t, http.StatusOK, rr.Code) + + // Verify response structure + var response struct { + FilePaths []string `json:"filePaths"` + } + err := json.NewDecoder(rr.Body).Decode(&response) + require.NoError(t, err) + require.Len(t, response.FilePaths, 3) + + // Verify all files were saved with correct paths + expectedPaths := []string{"batch/file1.txt", "batch/file2.md", "batch/file3.py"} + for _, expectedPath := range expectedPaths { + assert.Contains(t, response.FilePaths, expectedPath) + } + + // Verify file contents + for fileName, expectedContent := range files { + filePath := "batch/" + fileName + rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(filePath), nil, h.RegularTestUser) + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, expectedContent, rr.Body.String()) + } + }) + t.Run("upload without file", func(t *testing.T) { - rr := h.makeUploadRequest(t, baseURL+"/upload?file_path="+url.QueryEscape("test"), "", "", h.RegularTestUser) + // Empty map means no files + files := map[string]string{} + rr := h.makeUploadRequest(t, baseURL+"/upload?file_path="+url.QueryEscape("test"), files, h.RegularTestUser) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("upload with missing file_path parameter", func(t *testing.T) { + fileName := "test.txt" + fileContent := "test content" + files := map[string]string{fileName: fileContent} + + rr := h.makeUploadRequest(t, baseURL+"/upload", files, h.RegularTestUser) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("upload with invalid file_path", func(t *testing.T) { + fileName := "test.txt" + fileContent := "test content" + invalidPath := "../../../etc/passwd" + files := map[string]string{fileName: fileContent} + + rr := h.makeUploadRequest(t, baseURL+"/upload?file_path="+url.QueryEscape(invalidPath), files, h.RegularTestUser) assert.Equal(t, http.StatusBadRequest, rr.Code) }) }) diff --git a/server/internal/handlers/integration_test.go b/server/internal/handlers/integration_test.go index 2e0acc5..64efca0 100644 --- a/server/internal/handlers/integration_test.go +++ b/server/internal/handlers/integration_test.go @@ -328,18 +328,20 @@ func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io. return h.executeRequest(req) } -// makeUploadRequest creates a multipart form request for file uploads -// If fileName is empty, creates an empty multipart form without a file -func (h *testHarness) makeUploadRequest(t *testing.T, path, fileName, fileContent string, testUser *testUser) *httptest.ResponseRecorder { +// makeUploadRequest creates a multipart form request for file uploads (single or multiple) +// For single file: use map with one entry, e.g., map[string]string{"file.txt": "content"} +// For multiple files: use map with multiple entries +// For empty form (no files): pass empty map +func (h *testHarness) makeUploadRequest(t *testing.T, path string, files map[string]string, testUser *testUser) *httptest.ResponseRecorder { t.Helper() // Create multipart form var buf bytes.Buffer writer := multipart.NewWriter(&buf) - // Only add file part if fileName is not empty - if fileName != "" { - part, err := writer.CreateFormFile("file", fileName) + // Add all files + for fileName, fileContent := range files { + part, err := writer.CreateFormFile("files", fileName) if err != nil { t.Fatalf("Failed to create form file: %v", err) } From 005288b3d87b2be646f4bdb6660da0dc69a661a2 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 15:06:01 +0200 Subject: [PATCH 20/21] Add file size validation in UploadFile handler to prevent excessive memory allocation --- server/internal/handlers/file_handlers.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index dfbbbd8..5d79b21 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -372,6 +372,19 @@ func (h *Handler) UploadFile() http.HandlerFunc { return } + // Validate file size to prevent excessive memory allocation + // TODO: Make this configurable + const maxFileSize = 100 * 1024 * 1024 // 100MB + if formFile.Size > maxFileSize { + log.Debug("file too large", + "fileName", formFile.Filename, + "fileSize", formFile.Size, + "maxSize", maxFileSize, + ) + respondError(w, "File too large", http.StatusBadRequest) + return + } + // Open the uploaded file file, err := formFile.Open() if err != nil { @@ -391,9 +404,8 @@ func (h *Handler) UploadFile() http.HandlerFunc { filePath := decodedPath + "/" + formFile.Filename - content := make([]byte, formFile.Size) - _, err = file.Read(content) - if err != nil && err != io.EOF { + content, err := io.ReadAll(file) + if err != nil { log.Error("failed to read uploaded file", "filePath", filePath, "error", err.Error(), From 69fa105291b11947042f38e1fc79e54c1966b592 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 12 Jul 2025 15:44:26 +0200 Subject: [PATCH 21/21] Add rename file modal visibility state and handler to CreateWorkspaceModal and WorkspaceSwitcher tests --- .../modals/workspace/CreateWorkspaceModal.test.tsx | 2 ++ app/src/components/navigation/WorkspaceSwitcher.test.tsx | 2 ++ app/src/contexts/WorkspaceDataContext.test.tsx | 6 +----- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx b/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx index de8eaa7..43b6db7 100644 --- a/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx +++ b/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx @@ -52,6 +52,8 @@ describe('CreateWorkspaceModal', () => { setNewFileModalVisible: vi.fn(), deleteFileModalVisible: false, setDeleteFileModalVisible: vi.fn(), + renameFileModalVisible: false, + setRenameFileModalVisible: vi.fn(), commitMessageModalVisible: false, setCommitMessageModalVisible: vi.fn(), settingsModalVisible: false, diff --git a/app/src/components/navigation/WorkspaceSwitcher.test.tsx b/app/src/components/navigation/WorkspaceSwitcher.test.tsx index 00677f6..935d80b 100644 --- a/app/src/components/navigation/WorkspaceSwitcher.test.tsx +++ b/app/src/components/navigation/WorkspaceSwitcher.test.tsx @@ -107,6 +107,8 @@ describe('WorkspaceSwitcher', () => { setNewFileModalVisible: vi.fn(), deleteFileModalVisible: false, setDeleteFileModalVisible: vi.fn(), + renameFileModalVisible: false, + setRenameFileModalVisible: vi.fn(), commitMessageModalVisible: false, setCommitMessageModalVisible: vi.fn(), settingsModalVisible: false, diff --git a/app/src/contexts/WorkspaceDataContext.test.tsx b/app/src/contexts/WorkspaceDataContext.test.tsx index 7ced34a..03fc06b 100644 --- a/app/src/contexts/WorkspaceDataContext.test.tsx +++ b/app/src/contexts/WorkspaceDataContext.test.tsx @@ -5,11 +5,7 @@ import { WorkspaceDataProvider, useWorkspaceData, } from './WorkspaceDataContext'; -import { - DEFAULT_WORKSPACE_SETTINGS, - type Workspace, - Theme, -} from '@/types/models'; +import { type Workspace, Theme } from '@/types/models'; // Set up mocks before imports are used vi.mock('@/api/workspace', () => {