Merge pull request #55 from lordmathis/feat/file-actions

Implement file upload, move and rename
This commit is contained in:
2025-07-12 15:49:52 +02:00
committed by GitHub
33 changed files with 1586 additions and 432 deletions

View File

@@ -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);
});
});
});

View File

@@ -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<SaveFileResponse>} 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<SaveFileResponse> => {
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<UploadFilesResponse>} 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<UploadFilesResponse> => {
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;
};

View File

@@ -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<string> => {
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<string> => {
* @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',

View File

@@ -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 }) => (
<div>{children}</div>
),
}));
vi.mock('../../hooks/useWorkspace', () => ({
useWorkspace: vi.fn(),
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
// Mock contexts
vi.mock('../../contexts/ThemeContext', () => ({
useTheme: () => ({
colorScheme: 'light',
updateColorScheme: vi.fn(),
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
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 }) => (
<div>{children}</div>
),
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>
<WorkspaceDataProvider>
<ModalProvider>{children}</ModalProvider>
</WorkspaceDataProvider>
</ThemeProvider>
);
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', () => {
<FileActions
handlePullChanges={mockHandlePullChanges}
selectedFile={null}
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -97,6 +137,7 @@ describe('FileActions', () => {
<FileActions
handlePullChanges={mockHandlePullChanges}
selectedFile="test.md"
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -113,6 +154,7 @@ describe('FileActions', () => {
<FileActions
handlePullChanges={mockHandlePullChanges}
selectedFile={null}
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -129,6 +171,7 @@ describe('FileActions', () => {
<FileActions
handlePullChanges={mockHandlePullChanges}
selectedFile="test.md"
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -157,6 +200,7 @@ describe('FileActions', () => {
<FileActions
handlePullChanges={mockHandlePullChanges}
selectedFile="test.md"
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -174,6 +218,7 @@ describe('FileActions', () => {
<FileActions
handlePullChanges={mockHandlePullChanges}
selectedFile="test.md"
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -202,6 +247,7 @@ describe('FileActions', () => {
<FileActions
handlePullChanges={mockHandlePullChanges}
selectedFile="test.md"
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);

View File

@@ -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<boolean>;
selectedFile: string | null;
loadFileList: () => Promise<void>;
}
const FileActions: React.FC<FileActionsProps> = ({
handlePullChanges,
selectedFile,
loadFileList,
}) => {
const { currentWorkspace } = useWorkspace();
const {
setNewFileModalVisible,
setDeleteFileModalVisible,
setCommitMessageModalVisible,
setRenameFileModalVisible,
} = useModalContext();
const { handleUpload } = useFileOperations();
// Hidden file input for upload
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>
): 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 (
<Group gap="xs">
<Tooltip label="Create new file">
@@ -43,6 +82,33 @@ const FileActions: React.FC<FileActionsProps> = ({
</ActionIcon>
</Tooltip>
<Tooltip label="Upload files">
<ActionIcon
variant="default"
size="md"
onClick={handleUploadClick}
aria-label="Upload files"
data-testid="upload-files-button"
>
<IconUpload size={16} />
</ActionIcon>
</Tooltip>
<Tooltip
label={selectedFile ? 'Rename current file' : 'No file selected'}
>
<ActionIcon
variant="default"
size="md"
onClick={handleRenameFile}
disabled={!selectedFile}
aria-label="Rename current file"
data-testid="rename-file-button"
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip
label={selectedFile ? 'Delete current file' : 'No file selected'}
>
@@ -104,6 +170,16 @@ const FileActions: React.FC<FileActionsProps> = ({
<IconGitCommit size={16} />
</ActionIcon>
</Tooltip>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileInputChange}
multiple
aria-label="File upload input"
/>
</Group>
);
};

View File

@@ -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(),
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
// Mock contexts
vi.mock('../../contexts/ThemeContext', () => ({
useTheme: () => ({
colorScheme: 'light',
updateColorScheme: vi.fn(),
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
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 }) => (
<div>{children}</div>
),
}));
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 }) => (
<div>{children}</div>
),
}));
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 }) => (
<ThemeProvider>
<WorkspaceDataProvider>
<ModalProvider>{children}</ModalProvider>
</WorkspaceDataProvider>
</ThemeProvider>
);
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}
/>
</TestWrapper>
);
@@ -128,6 +196,7 @@ describe('FileTree', () => {
files={mockFiles}
handleFileSelect={mockHandleFileSelect}
showHiddenFiles={true}
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -147,6 +216,7 @@ describe('FileTree', () => {
files={mockFiles}
handleFileSelect={mockHandleFileSelect}
showHiddenFiles={false}
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -166,6 +236,7 @@ describe('FileTree', () => {
files={mockFiles}
handleFileSelect={mockHandleFileSelect}
showHiddenFiles={true}
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -183,6 +254,7 @@ describe('FileTree', () => {
files={[]}
handleFileSelect={mockHandleFileSelect}
showHiddenFiles={true}
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
@@ -199,6 +271,7 @@ describe('FileTree', () => {
files={mockFiles}
handleFileSelect={mockHandleFileSelect}
showHiddenFiles={true}
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);

View File

@@ -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<void>;
showHiddenFiles: boolean;
loadFileList: () => Promise<void>;
}
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
@@ -40,7 +47,7 @@ const FileIcon = ({ node }: { node: NodeApi<FileNode> }) => {
);
};
// 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<HTMLDivElement>;
onNodeClick?: (node: NodeApi<FileNode>) => void;
// Accept any extra props from Arborist, but do not use an index signature
} & Record<string, unknown>) {
const handleClick = () => {
if (node.isInternal) {
@@ -65,7 +71,7 @@ function Node({
return (
<Tooltip label={node.data.name} openDelay={500}>
<div
ref={dragHandle}
ref={dragHandle} // This enables dragging for the node
style={{
...style,
paddingLeft: `${node.level * 20}px`,
@@ -74,6 +80,8 @@ function Node({
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
// Add visual feedback when being dragged
opacity: node.state?.isDragging ? 0.5 : 1,
}}
onClick={handleClick}
{...rest}
@@ -95,13 +103,60 @@ function Node({
);
}
const FileTree: React.FC<FileTreeProps> = ({
// 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<FileTreeProps> = ({
files,
handleFileSelect,
showHiddenFiles,
loadFileList,
}) => {
const target = useRef<HTMLDivElement>(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<FileTreeProps> = ({
}
};
// 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 (
<div
ref={target}
style={{ height: 'calc(100vh - 140px)', marginTop: '20px' }}
style={{
height: 'calc(100vh - 140px)',
marginTop: '20px',
position: 'relative',
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 123, 255, 0.1)',
border: '2px dashed var(--mantine-color-blue-6)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
zIndex: 1000,
pointerEvents: 'none',
}}
>
<IconUpload size={48} color="var(--mantine-color-blue-6)" />
<Text size="lg" fw={500} c="blue" mt="md">
Drop files here to upload
</Text>
</Box>
)}
{size && (
<Tree
data={filteredFiles}
@@ -131,6 +305,7 @@ const FileTree: React.FC<FileTreeProps> = ({
height={size.height}
indent={24}
rowHeight={28}
onMove={handleTreeMove}
onActivate={(node) => {
const fileNode = node.data;
if (!node.isInternal) {

View File

@@ -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 }) => (
<div>{children}</div>
),
}));
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 }) => (
<div>{children}</div>
),
}));
// Mock hooks
vi.mock('../../hooks/useFileContent', () => ({
useFileContent: vi.fn(),
@@ -45,7 +74,11 @@ vi.mock('../../hooks/useGitOperations', () => ({
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
<ThemeProvider>
<WorkspaceDataProvider>
<ModalProvider>{children}</ModalProvider>
</WorkspaceDataProvider>
</ThemeProvider>
);
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');

View File

@@ -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<MainContentProps> = ({
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<MainContentProps> = ({
[handleDelete, handleFileSelect, loadFileList]
);
const handleRenameFile = useCallback(
async (oldPath: string, newPath: string): Promise<void> => {
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) => (
<Text key={index} size="sm">
const items = pathParts.map((part, index) => {
// Make the filename (last part) clickable for rename
const isFileName = index === pathParts.length - 1;
return (
<Text
key={index}
size="sm"
style={{
cursor: isFileName ? 'pointer' : 'default',
...(isFileName && {
textDecoration: 'underline',
textDecorationStyle: 'dotted',
}),
}}
onClick={isFileName ? handleBreadcrumbClick : undefined}
title={isFileName ? 'Click to rename file' : undefined}
>
{part}
</Text>
));
);
});
return (
<Group>
@@ -93,7 +133,7 @@ const MainContent: React.FC<MainContentProps> = ({
)}
</Group>
);
}, [selectedFile, hasUnsavedChanges]);
}, [selectedFile, hasUnsavedChanges, handleBreadcrumbClick]);
return (
<Box
@@ -128,6 +168,10 @@ const MainContent: React.FC<MainContentProps> = ({
onDeleteFile={handleDeleteFile}
selectedFile={selectedFile}
/>
<RenameFileModal
onRenameFile={handleRenameFile}
selectedFile={selectedFile}
/>
<CommitMessageModal onCommitAndPush={handleCommitAndPush} />
</Box>
);

View File

@@ -37,11 +37,16 @@ const Sidebar: React.FC<SidebarProps> = ({
overflow: 'hidden',
}}
>
<FileActions handlePullChanges={handlePull} selectedFile={selectedFile} />
<FileActions
handlePullChanges={handlePull}
selectedFile={selectedFile}
loadFileList={loadFileList}
/>
<FileTree
files={files}
handleFileSelect={handleFileSelect}
showHiddenFiles={currentWorkspace?.showHiddenFiles || false}
loadFileList={loadFileList}
/>
</Box>
);

View File

@@ -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<void>;
selectedFile: string | null;
}
const RenameFileModal: React.FC<RenameFileModalProps> = ({
onRenameFile,
selectedFile,
}) => {
const [newFileName, setNewFileName] = useState<string>('');
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<void> => {
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 (
<Modal
opened={renameFileModalVisible}
onClose={handleClose}
title="Rename File"
centered
size="sm"
>
<Box maw={400} mx="auto">
<TextInput
label="File Name"
type="text"
placeholder="Enter new file name"
data-testid="rename-file-input"
value={newFileName}
onChange={(event) => setNewFileName(event.currentTarget.value)}
onKeyDown={handleKeyDown}
mb="md"
w="100%"
autoFocus
/>
<Group justify="flex-end" mt="xl">
<Button
variant="default"
onClick={handleClose}
data-testid="cancel-rename-file-button"
>
Cancel
</Button>
<Button
onClick={() => void handleSubmit()}
data-testid="confirm-rename-file-button"
disabled={
!newFileName.trim() ||
newFileName.trim() === getCurrentFileName(selectedFile)
}
>
Rename
</Button>
</Group>
</Box>
</Modal>
);
};
export default RenameFileModal;

View File

@@ -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,

View File

@@ -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,

View File

@@ -10,6 +10,8 @@ interface ModalContextType {
setNewFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
deleteFileModalVisible: boolean;
setDeleteFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
renameFileModalVisible: boolean;
setRenameFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
commitMessageModalVisible: boolean;
setCommitMessageModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
settingsModalVisible: boolean;
@@ -30,6 +32,7 @@ interface ModalProviderProps {
export const ModalProvider: React.FC<ModalProviderProps> = ({ 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<ModalProviderProps> = ({ children }) => {
setNewFileModalVisible,
deleteFileModalVisible,
setDeleteFileModalVisible,
renameFileModalVisible,
setRenameFileModalVisible,
commitMessageModalVisible,
setCommitMessageModalVisible,
settingsModalVisible,

View File

@@ -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');

View File

@@ -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<Workspace[]>;
loadWorkspaceData: (workspaceName: string) => Promise<void>;
@@ -121,7 +120,6 @@ export const WorkspaceDataProvider: React.FC<WorkspaceDataProviderProps> = ({
const value: WorkspaceDataContextType = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
loading,
loadWorkspaces,
loadWorkspaceData,

View File

@@ -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());

View File

@@ -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<boolean>;
handleDelete: (filePath: string) => Promise<boolean>;
handleCreate: (fileName: string, initialContent?: string) => Promise<boolean>;
handleUpload: (files: FileList, targetPath?: string) => Promise<boolean>;
handleMove: (
filePaths: string[],
destinationParentPath: string,
index?: number
) => Promise<boolean>;
handleRename: (oldPath: string, newPath: string) => Promise<boolean>;
}
export const useFileOperations = (): UseFileOperationsResult => {
const { currentWorkspace, settings } = useWorkspaceData();
const { currentWorkspace } = useWorkspaceData();
const { handleCommitAndPush } = useGitOperations();
const autoCommit = useCallback(
async (filePath: string, action: FileAction): Promise<void> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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,
};
};

View File

@@ -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!,
};

View File

@@ -10,10 +10,14 @@ interface UseGitOperationsResult {
}
export const useGitOperations = (): UseGitOperationsResult => {
const { currentWorkspace, settings } = useWorkspaceData();
const { currentWorkspace } = useWorkspaceData();
const handlePull = useCallback(async (): Promise<boolean> => {
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<void> => {
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 };

View File

@@ -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',
};
expect(isSaveFileResponse(validSaveFileResponse)).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',
];
dateFormats.forEach((dateString) => {
const validResponse: SaveFileResponse = {
filePath: 'test.md',
size: 1024,
updatedAt: dateString,
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(validResponse)).toBe(true);
expect(isUploadFilesResponse(validUploadFilesResponse)).toBe(true);
});
it('returns true for upload files response with empty array', () => {
const validUploadFilesResponse: UploadFilesResponse = {
filePaths: [],
};
expect(isUploadFilesResponse(validUploadFilesResponse)).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<string, unknown>;
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,
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',
};
expect(isSaveFileResponse(responseWithLongPath)).toBe(true);
extra: 'field',
})
).toBe(true);
expect(
isUploadFilesResponse({ filePaths: ['file1.md'], extra: 'field' })
).toBe(true);
});
});
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -1,4 +1,4 @@
//go:build test
//go:build test || integration
package db

View File

@@ -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,34 +687,28 @@ 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)
decodedPath, err := url.PathUnescape(filePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", requestBody.FilePath,
"filePath", filePath,
"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)
_, err = h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, decodedPath)
if err != nil {
if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted",
"filePath", requestBody.FilePath,
"filePath", decodedPath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
@@ -483,24 +717,23 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
if os.IsNotExist(err) {
log.Debug("file not found",
"filePath", requestBody.FilePath,
"filePath", decodedPath,
)
respondError(w, "File not found", http.StatusNotFound)
return
}
log.Error("failed to validate file path",
"filePath", requestBody.FilePath,
"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)

View File

@@ -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)
})
})
})
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")
}
})
}
}

View File

@@ -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) }

View File

@@ -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