mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 15:44:21 +00:00
Merge pull request #55 from lordmathis/feat/file-actions
Implement file upload, move and rename
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
// 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 }) => (
|
||||
<div>{children}</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }) => (
|
||||
<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 }) => (
|
||||
<div>{children}</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
{part}
|
||||
</Text>
|
||||
));
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
109
app/src/components/modals/file/RenameFileModal.tsx
Normal file
109
app/src/components/modals/file/RenameFileModal.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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!,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<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,
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build test
|
||||
//go:build test || integration
|
||||
|
||||
package db
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user