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); - }); }); }); diff --git a/app/src/api/file.ts b/app/src/api/file.ts index c85d3a8..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'; /** @@ -71,7 +73,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 +94,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 +120,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', } @@ -161,13 +163,75 @@ export const updateLastOpenedFile = async ( await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName - )}/files/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 multiple files to a workspace + * @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 + * @throws {Error} If the API call fails or returns an invalid response + */ +export const uploadFile = async ( + workspaceName: string, + directoryPath: string, + files: FileList +): Promise => { + const formData = new FormData(); + + // Add all files to the form data + Array.from(files).forEach((file) => { + formData.append('files', 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 (!isUploadFilesResponse(data)) { + throw new Error('Invalid upload file response received from API'); + } + return data; +}; 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', 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/FileActions.tsx b/app/src/components/files/FileActions.tsx index a3f0075..268308e 100644 --- a/app/src/components/files/FileActions.tsx +++ b/app/src/components/files/FileActions.tsx @@ -1,34 +1,73 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { ActionIcon, Tooltip, Group } from '@mantine/core'; import { IconPlus, IconTrash, IconGitPullRequest, IconGitCommit, + IconUpload, + IconEdit, } 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; + loadFileList: () => Promise; } const FileActions: React.FC = ({ handlePullChanges, selectedFile, + loadFileList, }) => { const { currentWorkspace } = useWorkspace(); const { setNewFileModalVisible, setDeleteFileModalVisible, setCommitMessageModalVisible, + setRenameFileModalVisible, } = useModalContext(); + const { handleUpload } = useFileOperations(); + + // Hidden file input for upload + const fileInputRef = useRef(null); + const handleCreateFile = (): void => setNewFileModalVisible(true); const handleDeleteFile = (): void => setDeleteFileModalVisible(true); + const handleRenameFile = (): void => setRenameFileModalVisible(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) { + 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 = ''; + } + }; + return ( @@ -43,6 +82,33 @@ const FileActions: React.FC = ({ + + + + + + + + + + + + @@ -104,6 +170,16 @@ const FileActions: React.FC = ({ + + {/* Hidden file input */} + ); }; 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/files/FileTree.tsx b/app/src/components/files/FileTree.tsx index 0c7e141..f940016 100644 --- a/app/src/components/files/FileTree.tsx +++ b/app/src/components/files/FileTree.tsx @@ -1,8 +1,14 @@ -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 { useFileOperations } from '../../hooks/useFileOperations'; import type { FileNode } from '@/types/models'; interface Size { @@ -14,6 +20,7 @@ interface FileTreeProps { files: FileNode[]; handleFileSelect: (filePath: string | null) => Promise; showHiddenFiles: boolean; + loadFileList: () => Promise; } const useSize = (target: React.RefObject): Size | undefined => { @@ -40,7 +47,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 +59,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 +71,7 @@ function Node({ return (
= ({ +// 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, + loadFileList, }) => { const target = useRef(null); const size = useSize(target); + const { handleMove, handleUpload } = useFileOperations(); + + // State for drag and drop overlay + const [isDragOver, setIsDragOver] = useState(false); const filteredFiles = files.filter((file) => { if (file.name.startsWith('.') && !showHiddenFiles) { @@ -118,11 +173,130 @@ const FileTree: React.FC = ({ } }; + // Handle file movement within the tree + const handleTreeMove = useCallback( + async ({ + dragIds, + parentId, + index, + }: { + dragIds: string[]; + parentId: string | null; + index: number; + }) => { + try { + // 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(); + } + } catch (error) { + console.error('Error moving files:', error); + } + }, + [handleMove, loadFileList, filteredFiles] + ); + + // 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) { + const uploadFiles = async () => { + try { + const success = await handleUpload(files); + if (success) { + await loadFileList(); + } + } catch (error) { + console.error('Error uploading files:', error); + } + }; + + void uploadFiles(); + } + }, + [handleUpload, loadFileList] + ); + return (
+ {/* Drag overlay */} + {isDragOver && ( + + + + Drop files here to upload + + + )} + {size && ( = ({ height={size.height} indent={24} rowHeight={28} + onMove={handleTreeMove} onActivate={(node) => { const fileNode = node.data; if (!node.isInternal) { 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'); 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/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/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/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/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/contexts/WorkspaceDataContext.test.tsx b/app/src/contexts/WorkspaceDataContext.test.tsx index 0a12efd..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', () => { @@ -126,7 +122,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 +166,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 +252,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 +413,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 +492,6 @@ describe('WorkspaceDataContext', () => { }); expect(result.current.currentWorkspace).toEqual(mockWorkspace); - expect(result.current.settings).toEqual(mockWorkspace); }); it('sets workspace to null', async () => { @@ -524,7 +515,6 @@ describe('WorkspaceDataContext', () => { }); expect(result.current.currentWorkspace).toBeNull(); - expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); }); }); @@ -603,7 +593,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 +620,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 f34e32c..6317c47 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'; @@ -9,16 +9,24 @@ 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: ( + filePaths: string[], + destinationParentPath: string, + index?: number + ) => Promise; + handleRename: (oldPath: string, newPath: string) => Promise; } 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); @@ -28,7 +36,7 @@ export const useFileOperations = (): UseFileOperationsResult => { await handleCommitAndPush(commitMessage); } }, - [settings, handleCommitAndPush] + [currentWorkspace, handleCommitAndPush] ); const handleSave = useCallback( @@ -109,5 +117,116 @@ export const useFileOperations = (): UseFileOperationsResult => { [currentWorkspace, autoCommit] ); - return { handleSave, handleDelete, handleCreate }; + const handleUpload = useCallback( + async (files: FileList, targetPath?: string): Promise => { + if (!currentWorkspace) return false; + + try { + await uploadFile(currentWorkspace.name, targetPath || '', files); + + 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 ( + filePaths: string[], + destinationParentPath: string, + _index?: number + ): Promise => { + if (!currentWorkspace) return false; + + try { + // 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 ${filePaths.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] + ); + + const handleRename = useCallback( + async (oldPath: string, newPath: string): Promise => { + if (!currentWorkspace) return false; + + try { + // Use moveFile API for renaming (rename is essentially a move operation) + await moveFile(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, + }; }; 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 }; 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); }); }); }); 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/app/routes.go b/server/internal/app/routes.go index 94ac6e4..6bb2946 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) { @@ -133,9 +133,12 @@ func setupRouter(o Options) *chi.Mux { r.Put("/last", handler.UpdateLastOpenedFile()) r.Get("/lookup", handler.LookupFileByName()) - r.Post("/*", handler.SaveFile()) - r.Get("/*", handler.GetFileContent()) - r.Delete("/*", handler.DeleteFile()) + r.Post("/upload", handler.UploadFile()) + r.Put("/move", handler.MoveFile()) + + r.Post("/", handler.SaveFile()) + r.Get("/content", handler.GetFileContent()) + r.Delete("/", handler.DeleteFile()) }) // Git routes 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.go b/server/internal/handlers/file_handlers.go index 538742e..5d79b21 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 @@ -27,16 +24,16 @@ type SaveFileResponse struct { UpdatedAt time.Time `json:"updatedAt"` } +// UploadFilesResponse represents a response to an upload files request +type UploadFilesResponse struct { + FilePaths []string `json:"filePaths"` +} + // LastOpenedFileResponse represents a response to a last opened file request 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") } @@ -150,13 +147,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 +167,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 +227,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 +246,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 { @@ -302,6 +298,244 @@ func (h *Handler) SaveFile() http.HandlerFunc { } } +// UploadFile godoc +// @Summary Upload files +// @Description Uploads one or more files 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 file_path query string true "Directory path" +// @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] +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, + ) + + // 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") + respondError(w, "No files found in form", http.StatusBadRequest) + return + } + + uploadPath := r.URL.Query().Get("file_path") + decodedPath, err := url.PathUnescape(uploadPath) + if err != nil { + log.Error("failed to decode file path", + "filePath", uploadPath, + "error", err.Error(), + ) + respondError(w, "Invalid file path", http.StatusBadRequest) + return + } + + uploadedPaths := []string{} + + 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, "Empty file uploaded", http.StatusBadRequest) + 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 { + 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, err := io.ReadAll(file) + if err != nil { + 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 := UploadFilesResponse{ + FilePaths: uploadedPaths, + } + 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" +// @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" +// @Failure 500 {object} ErrorResponse "Failed to move file" +// @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) + if !ok { + return + } + log := getFilesLogger().With( + "handler", "MoveFile", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) + + 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) + } +} + // DeleteFile godoc // @Summary Delete file // @Description Deletes a file in the user's workspace @@ -309,12 +543,12 @@ func (h *Handler) SaveFile() 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) @@ -328,7 +562,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 { @@ -427,7 +667,7 @@ 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" @@ -447,60 +687,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 962b8f9..116e612 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+"/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+"/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,15 +144,15 @@ 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) }) @@ -174,7 +174,7 @@ 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+"/last?file_path="+url.QueryEscape(updateReq.FilePath), nil, h.RegularTestUser) require.Equal(t, http.StatusNoContent, rr.Code) // Verify update @@ -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+"/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 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", struct{ FilePath string }{"test.md"}}, + {"update last file", http.MethodPut, baseURL + "/last?file_path=" + url.QueryEscape("test.md"), nil}, } for _, tc := range tests { @@ -230,14 +230,98 @@ 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) }) } }) + + t.Run("upload file", func(t *testing.T) { + t.Run("successful single file upload", func(t *testing.T) { + fileName := "uploaded-test.txt" + fileContent := "This is an uploaded file" + + 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 for multiple files API + var response struct { + FilePaths []string `json:"filePaths"` + } + err := json.NewDecoder(rr.Body).Decode(&response) + require.NoError(t, err) + 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) + require.Equal(t, http.StatusOK, rr.Code) + 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) { + // 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 7e9894c..64efca0 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,39 @@ 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 (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) + + // 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) + } + + _, 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) +} 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 { diff --git a/server/internal/storage/files.go b/server/internal/storage/files.go index 87c5632..3d91d8b 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 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..1aaed4c 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,27 @@ 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 { + _, 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 + 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