mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-06 07:54:22 +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');
|
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 () => {
|
it('handles missing CSRF token gracefully', async () => {
|
||||||
// No CSRF token in cookies
|
// No CSRF token in cookies
|
||||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
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', () => {
|
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', () => {
|
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 () => {
|
it('handles null response body', async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -563,18 +465,5 @@ describe('apiCall', () => {
|
|||||||
|
|
||||||
expect(result.status).toBe(200);
|
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,
|
API_BASE_URL,
|
||||||
isLookupResponse,
|
isLookupResponse,
|
||||||
isSaveFileResponse,
|
isSaveFileResponse,
|
||||||
|
isUploadFilesResponse,
|
||||||
type SaveFileResponse,
|
type SaveFileResponse,
|
||||||
|
type UploadFilesResponse,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,7 +73,7 @@ export const getFileContent = async (
|
|||||||
const response = await apiCall(
|
const response = await apiCall(
|
||||||
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
workspaceName
|
workspaceName
|
||||||
)}/files/${encodeURIComponent(filePath)}`
|
)}/files/content?file_path=${encodeURIComponent(filePath)}`
|
||||||
);
|
);
|
||||||
return response.text();
|
return response.text();
|
||||||
};
|
};
|
||||||
@@ -92,7 +94,7 @@ export const saveFile = async (
|
|||||||
const response = await apiCall(
|
const response = await apiCall(
|
||||||
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
workspaceName
|
workspaceName
|
||||||
)}/files/${encodeURIComponent(filePath)}`,
|
)}/files?file_path=${encodeURIComponent(filePath)}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -118,7 +120,7 @@ export const deleteFile = async (workspaceName: string, filePath: string) => {
|
|||||||
await apiCall(
|
await apiCall(
|
||||||
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
workspaceName
|
workspaceName
|
||||||
)}/files/${encodeURIComponent(filePath)}`,
|
)}/files?file_path=${encodeURIComponent(filePath)}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}
|
}
|
||||||
@@ -161,13 +163,75 @@ export const updateLastOpenedFile = async (
|
|||||||
await apiCall(
|
await apiCall(
|
||||||
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
workspaceName
|
workspaceName
|
||||||
)}/files/last`,
|
)}/files/last?file_path=${encodeURIComponent(filePath)}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
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
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
*/
|
*/
|
||||||
export const getLastWorkspaceName = async (): Promise<string> => {
|
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();
|
const data: unknown = await response.json();
|
||||||
if (
|
if (
|
||||||
typeof data !== 'object' ||
|
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
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
*/
|
*/
|
||||||
export const updateLastWorkspaceName = async (workspaceName: string) => {
|
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',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -3,22 +3,59 @@ import { fireEvent } from '@testing-library/react';
|
|||||||
import { render } from '../../test/utils';
|
import { render } from '../../test/utils';
|
||||||
import FileActions from './FileActions';
|
import FileActions from './FileActions';
|
||||||
import { Theme } from '@/types/models';
|
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
|
// Mock the contexts and hooks
|
||||||
vi.mock('../../contexts/ModalContext', () => ({
|
vi.mock('../../contexts/ModalContext', () => ({
|
||||||
useModalContext: vi.fn(),
|
useModalContext: vi.fn(),
|
||||||
|
ModalProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../hooks/useWorkspace', () => ({
|
vi.mock('../../hooks/useWorkspace', () => ({
|
||||||
useWorkspace: vi.fn(),
|
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 }) => (
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<div>{children}</div>
|
<ThemeProvider>
|
||||||
|
<WorkspaceDataProvider>
|
||||||
|
<ModalProvider>{children}</ModalProvider>
|
||||||
|
</WorkspaceDataProvider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('FileActions', () => {
|
describe('FileActions', () => {
|
||||||
const mockHandlePullChanges = vi.fn();
|
const mockHandlePullChanges = vi.fn();
|
||||||
|
const mockLoadFileList = vi.fn();
|
||||||
const mockSetNewFileModalVisible = vi.fn();
|
const mockSetNewFileModalVisible = vi.fn();
|
||||||
const mockSetDeleteFileModalVisible = vi.fn();
|
const mockSetDeleteFileModalVisible = vi.fn();
|
||||||
const mockSetCommitMessageModalVisible = vi.fn();
|
const mockSetCommitMessageModalVisible = vi.fn();
|
||||||
@@ -52,6 +89,8 @@ describe('FileActions', () => {
|
|||||||
setNewFileModalVisible: mockSetNewFileModalVisible,
|
setNewFileModalVisible: mockSetNewFileModalVisible,
|
||||||
deleteFileModalVisible: false,
|
deleteFileModalVisible: false,
|
||||||
setDeleteFileModalVisible: mockSetDeleteFileModalVisible,
|
setDeleteFileModalVisible: mockSetDeleteFileModalVisible,
|
||||||
|
renameFileModalVisible: false,
|
||||||
|
setRenameFileModalVisible: vi.fn(),
|
||||||
commitMessageModalVisible: false,
|
commitMessageModalVisible: false,
|
||||||
setCommitMessageModalVisible: mockSetCommitMessageModalVisible,
|
setCommitMessageModalVisible: mockSetCommitMessageModalVisible,
|
||||||
settingsModalVisible: false,
|
settingsModalVisible: false,
|
||||||
@@ -81,6 +120,7 @@ describe('FileActions', () => {
|
|||||||
<FileActions
|
<FileActions
|
||||||
handlePullChanges={mockHandlePullChanges}
|
handlePullChanges={mockHandlePullChanges}
|
||||||
selectedFile={null}
|
selectedFile={null}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -97,6 +137,7 @@ describe('FileActions', () => {
|
|||||||
<FileActions
|
<FileActions
|
||||||
handlePullChanges={mockHandlePullChanges}
|
handlePullChanges={mockHandlePullChanges}
|
||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -113,6 +154,7 @@ describe('FileActions', () => {
|
|||||||
<FileActions
|
<FileActions
|
||||||
handlePullChanges={mockHandlePullChanges}
|
handlePullChanges={mockHandlePullChanges}
|
||||||
selectedFile={null}
|
selectedFile={null}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -129,6 +171,7 @@ describe('FileActions', () => {
|
|||||||
<FileActions
|
<FileActions
|
||||||
handlePullChanges={mockHandlePullChanges}
|
handlePullChanges={mockHandlePullChanges}
|
||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -157,6 +200,7 @@ describe('FileActions', () => {
|
|||||||
<FileActions
|
<FileActions
|
||||||
handlePullChanges={mockHandlePullChanges}
|
handlePullChanges={mockHandlePullChanges}
|
||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -174,6 +218,7 @@ describe('FileActions', () => {
|
|||||||
<FileActions
|
<FileActions
|
||||||
handlePullChanges={mockHandlePullChanges}
|
handlePullChanges={mockHandlePullChanges}
|
||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -202,6 +247,7 @@ describe('FileActions', () => {
|
|||||||
<FileActions
|
<FileActions
|
||||||
handlePullChanges={mockHandlePullChanges}
|
handlePullChanges={mockHandlePullChanges}
|
||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,73 @@
|
|||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { ActionIcon, Tooltip, Group } from '@mantine/core';
|
import { ActionIcon, Tooltip, Group } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconGitPullRequest,
|
IconGitPullRequest,
|
||||||
IconGitCommit,
|
IconGitCommit,
|
||||||
|
IconUpload,
|
||||||
|
IconEdit,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useModalContext } from '../../contexts/ModalContext';
|
import { useModalContext } from '../../contexts/ModalContext';
|
||||||
import { useWorkspace } from '../../hooks/useWorkspace';
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
|
|
||||||
interface FileActionsProps {
|
interface FileActionsProps {
|
||||||
handlePullChanges: () => Promise<boolean>;
|
handlePullChanges: () => Promise<boolean>;
|
||||||
selectedFile: string | null;
|
selectedFile: string | null;
|
||||||
|
loadFileList: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileActions: React.FC<FileActionsProps> = ({
|
const FileActions: React.FC<FileActionsProps> = ({
|
||||||
handlePullChanges,
|
handlePullChanges,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
|
loadFileList,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const {
|
const {
|
||||||
setNewFileModalVisible,
|
setNewFileModalVisible,
|
||||||
setDeleteFileModalVisible,
|
setDeleteFileModalVisible,
|
||||||
setCommitMessageModalVisible,
|
setCommitMessageModalVisible,
|
||||||
|
setRenameFileModalVisible,
|
||||||
} = useModalContext();
|
} = useModalContext();
|
||||||
|
|
||||||
|
const { handleUpload } = useFileOperations();
|
||||||
|
|
||||||
|
// Hidden file input for upload
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleCreateFile = (): void => setNewFileModalVisible(true);
|
const handleCreateFile = (): void => setNewFileModalVisible(true);
|
||||||
const handleDeleteFile = (): void => setDeleteFileModalVisible(true);
|
const handleDeleteFile = (): void => setDeleteFileModalVisible(true);
|
||||||
|
const handleRenameFile = (): void => setRenameFileModalVisible(true);
|
||||||
const handleCommitAndPush = (): void => setCommitMessageModalVisible(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 (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Tooltip label="Create new file">
|
<Tooltip label="Create new file">
|
||||||
@@ -43,6 +82,33 @@ const FileActions: React.FC<FileActionsProps> = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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
|
<Tooltip
|
||||||
label={selectedFile ? 'Delete current file' : 'No file selected'}
|
label={selectedFile ? 'Delete current file' : 'No file selected'}
|
||||||
>
|
>
|
||||||
@@ -104,6 +170,16 @@ const FileActions: React.FC<FileActionsProps> = ({
|
|||||||
<IconGitCommit size={16} />
|
<IconGitCommit size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
multiple
|
||||||
|
aria-label="File upload input"
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { fireEvent, waitFor } from '@testing-library/react';
|
|||||||
import { render } from '../../test/utils';
|
import { render } from '../../test/utils';
|
||||||
import FileTree from './FileTree';
|
import FileTree from './FileTree';
|
||||||
import type { FileNode } from '../../types/models';
|
import type { FileNode } from '../../types/models';
|
||||||
|
import { ModalProvider } from '../../contexts/ModalContext';
|
||||||
|
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||||
|
import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext';
|
||||||
|
|
||||||
// Mock react-arborist
|
// Mock react-arborist
|
||||||
vi.mock('react-arborist', () => ({
|
vi.mock('react-arborist', () => ({
|
||||||
@@ -69,12 +72,76 @@ vi.mock('@react-hook/resize-observer', () => ({
|
|||||||
default: vi.fn(),
|
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 }) => (
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<div>{children}</div>
|
<ThemeProvider>
|
||||||
|
<WorkspaceDataProvider>
|
||||||
|
<ModalProvider>{children}</ModalProvider>
|
||||||
|
</WorkspaceDataProvider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('FileTree', () => {
|
describe('FileTree', () => {
|
||||||
const mockHandleFileSelect = vi.fn();
|
const mockHandleFileSelect = vi.fn();
|
||||||
|
const mockLoadFileList = vi.fn();
|
||||||
|
|
||||||
const mockFiles: FileNode[] = [
|
const mockFiles: FileNode[] = [
|
||||||
{
|
{
|
||||||
@@ -112,6 +179,7 @@ describe('FileTree', () => {
|
|||||||
files={mockFiles}
|
files={mockFiles}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
showHiddenFiles={true}
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -128,6 +196,7 @@ describe('FileTree', () => {
|
|||||||
files={mockFiles}
|
files={mockFiles}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
showHiddenFiles={true}
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -147,6 +216,7 @@ describe('FileTree', () => {
|
|||||||
files={mockFiles}
|
files={mockFiles}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
showHiddenFiles={false}
|
showHiddenFiles={false}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -166,6 +236,7 @@ describe('FileTree', () => {
|
|||||||
files={mockFiles}
|
files={mockFiles}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
showHiddenFiles={true}
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -183,6 +254,7 @@ describe('FileTree', () => {
|
|||||||
files={[]}
|
files={[]}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
showHiddenFiles={true}
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -199,6 +271,7 @@ describe('FileTree', () => {
|
|||||||
files={mockFiles}
|
files={mockFiles}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
showHiddenFiles={true}
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</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 { Tree, type NodeApi } from 'react-arborist';
|
||||||
import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react';
|
import {
|
||||||
import { Tooltip } from '@mantine/core';
|
IconFile,
|
||||||
|
IconFolder,
|
||||||
|
IconFolderOpen,
|
||||||
|
IconUpload,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { Tooltip, Text, Box } from '@mantine/core';
|
||||||
import useResizeObserver from '@react-hook/resize-observer';
|
import useResizeObserver from '@react-hook/resize-observer';
|
||||||
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
import type { FileNode } from '@/types/models';
|
import type { FileNode } from '@/types/models';
|
||||||
|
|
||||||
interface Size {
|
interface Size {
|
||||||
@@ -14,6 +20,7 @@ interface FileTreeProps {
|
|||||||
files: FileNode[];
|
files: FileNode[];
|
||||||
handleFileSelect: (filePath: string | null) => Promise<void>;
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
showHiddenFiles: boolean;
|
showHiddenFiles: boolean;
|
||||||
|
loadFileList: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
|
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({
|
function Node({
|
||||||
node,
|
node,
|
||||||
style,
|
style,
|
||||||
@@ -52,7 +59,6 @@ function Node({
|
|||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
dragHandle?: React.Ref<HTMLDivElement>;
|
dragHandle?: React.Ref<HTMLDivElement>;
|
||||||
onNodeClick?: (node: NodeApi<FileNode>) => void;
|
onNodeClick?: (node: NodeApi<FileNode>) => void;
|
||||||
// Accept any extra props from Arborist, but do not use an index signature
|
|
||||||
} & Record<string, unknown>) {
|
} & Record<string, unknown>) {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (node.isInternal) {
|
if (node.isInternal) {
|
||||||
@@ -65,7 +71,7 @@ function Node({
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={node.data.name} openDelay={500}>
|
<Tooltip label={node.data.name} openDelay={500}>
|
||||||
<div
|
<div
|
||||||
ref={dragHandle}
|
ref={dragHandle} // This enables dragging for the node
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
paddingLeft: `${node.level * 20}px`,
|
paddingLeft: `${node.level * 20}px`,
|
||||||
@@ -74,6 +80,8 @@ function Node({
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
// Add visual feedback when being dragged
|
||||||
|
opacity: node.state?.isDragging ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
{...rest}
|
{...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,
|
files,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
showHiddenFiles,
|
showHiddenFiles,
|
||||||
|
loadFileList,
|
||||||
}) => {
|
}) => {
|
||||||
const target = useRef<HTMLDivElement>(null);
|
const target = useRef<HTMLDivElement>(null);
|
||||||
const size = useSize(target);
|
const size = useSize(target);
|
||||||
|
const { handleMove, handleUpload } = useFileOperations();
|
||||||
|
|
||||||
|
// State for drag and drop overlay
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
const filteredFiles = files.filter((file) => {
|
const filteredFiles = files.filter((file) => {
|
||||||
if (file.name.startsWith('.') && !showHiddenFiles) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={target}
|
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 && (
|
{size && (
|
||||||
<Tree
|
<Tree
|
||||||
data={filteredFiles}
|
data={filteredFiles}
|
||||||
@@ -131,6 +305,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
|||||||
height={size.height}
|
height={size.height}
|
||||||
indent={24}
|
indent={24}
|
||||||
rowHeight={28}
|
rowHeight={28}
|
||||||
|
onMove={handleTreeMove}
|
||||||
onActivate={(node) => {
|
onActivate={(node) => {
|
||||||
const fileNode = node.data;
|
const fileNode = node.data;
|
||||||
if (!node.isInternal) {
|
if (!node.isInternal) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render } from '../../test/utils';
|
import { render } from '../../test/utils';
|
||||||
import MainContent from './MainContent';
|
import MainContent from './MainContent';
|
||||||
|
import { ModalProvider } from '../../contexts/ModalContext';
|
||||||
|
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||||
|
import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext';
|
||||||
|
|
||||||
// Mock child components
|
// Mock child components
|
||||||
vi.mock('../editor/ContentView', () => ({
|
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
|
// Mock hooks
|
||||||
vi.mock('../../hooks/useFileContent', () => ({
|
vi.mock('../../hooks/useFileContent', () => ({
|
||||||
useFileContent: vi.fn(),
|
useFileContent: vi.fn(),
|
||||||
@@ -45,7 +74,11 @@ vi.mock('../../hooks/useGitOperations', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<div>{children}</div>
|
<ThemeProvider>
|
||||||
|
<WorkspaceDataProvider>
|
||||||
|
<ModalProvider>{children}</ModalProvider>
|
||||||
|
</WorkspaceDataProvider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('MainContent', () => {
|
describe('MainContent', () => {
|
||||||
@@ -56,6 +89,9 @@ describe('MainContent', () => {
|
|||||||
const mockHandleSave = vi.fn();
|
const mockHandleSave = vi.fn();
|
||||||
const mockHandleCreate = vi.fn();
|
const mockHandleCreate = vi.fn();
|
||||||
const mockHandleDelete = vi.fn();
|
const mockHandleDelete = vi.fn();
|
||||||
|
const mockHandleUpload = vi.fn();
|
||||||
|
const mockHandleMove = vi.fn();
|
||||||
|
const mockHandleRename = vi.fn();
|
||||||
const mockHandleCommitAndPush = vi.fn();
|
const mockHandleCommitAndPush = vi.fn();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -76,6 +112,9 @@ describe('MainContent', () => {
|
|||||||
handleSave: mockHandleSave,
|
handleSave: mockHandleSave,
|
||||||
handleCreate: mockHandleCreate,
|
handleCreate: mockHandleCreate,
|
||||||
handleDelete: mockHandleDelete,
|
handleDelete: mockHandleDelete,
|
||||||
|
handleUpload: mockHandleUpload,
|
||||||
|
handleMove: mockHandleMove,
|
||||||
|
handleRename: mockHandleRename,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { useGitOperations } = await import('../../hooks/useGitOperations');
|
const { useGitOperations } = await import('../../hooks/useGitOperations');
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react';
|
|||||||
import ContentView from '../editor/ContentView';
|
import ContentView from '../editor/ContentView';
|
||||||
import CreateFileModal from '../modals/file/CreateFileModal';
|
import CreateFileModal from '../modals/file/CreateFileModal';
|
||||||
import DeleteFileModal from '../modals/file/DeleteFileModal';
|
import DeleteFileModal from '../modals/file/DeleteFileModal';
|
||||||
|
import RenameFileModal from '../modals/file/RenameFileModal';
|
||||||
import CommitMessageModal from '../modals/git/CommitMessageModal';
|
import CommitMessageModal from '../modals/git/CommitMessageModal';
|
||||||
|
|
||||||
import { useFileContent } from '../../hooks/useFileContent';
|
import { useFileContent } from '../../hooks/useFileContent';
|
||||||
import { useFileOperations } from '../../hooks/useFileOperations';
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||||
|
import { useModalContext } from '../../contexts/ModalContext';
|
||||||
|
|
||||||
type ViewTab = 'source' | 'preview';
|
type ViewTab = 'source' | 'preview';
|
||||||
|
|
||||||
@@ -31,8 +33,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
handleContentChange,
|
handleContentChange,
|
||||||
} = useFileContent(selectedFile);
|
} = useFileContent(selectedFile);
|
||||||
const { handleSave, handleCreate, handleDelete } = useFileOperations();
|
const { handleSave, handleCreate, handleDelete, handleRename } =
|
||||||
|
useFileOperations();
|
||||||
const { handleCommitAndPush } = useGitOperations();
|
const { handleCommitAndPush } = useGitOperations();
|
||||||
|
const { setRenameFileModalVisible } = useModalContext();
|
||||||
|
|
||||||
const handleTabChange = useCallback((value: string | null): void => {
|
const handleTabChange = useCallback((value: string | null): void => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -73,14 +77,50 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
[handleDelete, handleFileSelect, loadFileList]
|
[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(() => {
|
const renderBreadcrumbs = useMemo(() => {
|
||||||
if (!selectedFile) return null;
|
if (!selectedFile) return null;
|
||||||
const pathParts = selectedFile.split('/');
|
const pathParts = selectedFile.split('/');
|
||||||
const items = pathParts.map((part, index) => (
|
const items = pathParts.map((part, index) => {
|
||||||
<Text key={index} size="sm">
|
// Make the filename (last part) clickable for rename
|
||||||
{part}
|
const isFileName = index === pathParts.length - 1;
|
||||||
</Text>
|
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 (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
@@ -93,7 +133,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}, [selectedFile, hasUnsavedChanges]);
|
}, [selectedFile, hasUnsavedChanges, handleBreadcrumbClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -128,6 +168,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
onDeleteFile={handleDeleteFile}
|
onDeleteFile={handleDeleteFile}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
/>
|
/>
|
||||||
|
<RenameFileModal
|
||||||
|
onRenameFile={handleRenameFile}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
/>
|
||||||
<CommitMessageModal onCommitAndPush={handleCommitAndPush} />
|
<CommitMessageModal onCommitAndPush={handleCommitAndPush} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,11 +37,16 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileActions handlePullChanges={handlePull} selectedFile={selectedFile} />
|
<FileActions
|
||||||
|
handlePullChanges={handlePull}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
loadFileList={loadFileList}
|
||||||
|
/>
|
||||||
<FileTree
|
<FileTree
|
||||||
files={files}
|
files={files}
|
||||||
handleFileSelect={handleFileSelect}
|
handleFileSelect={handleFileSelect}
|
||||||
showHiddenFiles={currentWorkspace?.showHiddenFiles || false}
|
showHiddenFiles={currentWorkspace?.showHiddenFiles || false}
|
||||||
|
loadFileList={loadFileList}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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(),
|
setNewFileModalVisible: vi.fn(),
|
||||||
deleteFileModalVisible: false,
|
deleteFileModalVisible: false,
|
||||||
setDeleteFileModalVisible: vi.fn(),
|
setDeleteFileModalVisible: vi.fn(),
|
||||||
|
renameFileModalVisible: false,
|
||||||
|
setRenameFileModalVisible: vi.fn(),
|
||||||
commitMessageModalVisible: false,
|
commitMessageModalVisible: false,
|
||||||
setCommitMessageModalVisible: vi.fn(),
|
setCommitMessageModalVisible: vi.fn(),
|
||||||
settingsModalVisible: false,
|
settingsModalVisible: false,
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ describe('WorkspaceSwitcher', () => {
|
|||||||
setNewFileModalVisible: vi.fn(),
|
setNewFileModalVisible: vi.fn(),
|
||||||
deleteFileModalVisible: false,
|
deleteFileModalVisible: false,
|
||||||
setDeleteFileModalVisible: vi.fn(),
|
setDeleteFileModalVisible: vi.fn(),
|
||||||
|
renameFileModalVisible: false,
|
||||||
|
setRenameFileModalVisible: vi.fn(),
|
||||||
commitMessageModalVisible: false,
|
commitMessageModalVisible: false,
|
||||||
setCommitMessageModalVisible: vi.fn(),
|
setCommitMessageModalVisible: vi.fn(),
|
||||||
settingsModalVisible: false,
|
settingsModalVisible: false,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface ModalContextType {
|
|||||||
setNewFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
setNewFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
deleteFileModalVisible: boolean;
|
deleteFileModalVisible: boolean;
|
||||||
setDeleteFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
setDeleteFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
renameFileModalVisible: boolean;
|
||||||
|
setRenameFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
commitMessageModalVisible: boolean;
|
commitMessageModalVisible: boolean;
|
||||||
setCommitMessageModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
setCommitMessageModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
settingsModalVisible: boolean;
|
settingsModalVisible: boolean;
|
||||||
@@ -30,6 +32,7 @@ interface ModalProviderProps {
|
|||||||
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
|
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
|
||||||
const [newFileModalVisible, setNewFileModalVisible] = useState(false);
|
const [newFileModalVisible, setNewFileModalVisible] = useState(false);
|
||||||
const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false);
|
const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false);
|
||||||
|
const [renameFileModalVisible, setRenameFileModalVisible] = useState(false);
|
||||||
const [commitMessageModalVisible, setCommitMessageModalVisible] =
|
const [commitMessageModalVisible, setCommitMessageModalVisible] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
|
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
|
||||||
@@ -43,6 +46,8 @@ export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
|
|||||||
setNewFileModalVisible,
|
setNewFileModalVisible,
|
||||||
deleteFileModalVisible,
|
deleteFileModalVisible,
|
||||||
setDeleteFileModalVisible,
|
setDeleteFileModalVisible,
|
||||||
|
renameFileModalVisible,
|
||||||
|
setRenameFileModalVisible,
|
||||||
commitMessageModalVisible,
|
commitMessageModalVisible,
|
||||||
setCommitMessageModalVisible,
|
setCommitMessageModalVisible,
|
||||||
settingsModalVisible,
|
settingsModalVisible,
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import {
|
|||||||
WorkspaceDataProvider,
|
WorkspaceDataProvider,
|
||||||
useWorkspaceData,
|
useWorkspaceData,
|
||||||
} from './WorkspaceDataContext';
|
} from './WorkspaceDataContext';
|
||||||
import {
|
import { type Workspace, Theme } from '@/types/models';
|
||||||
DEFAULT_WORKSPACE_SETTINGS,
|
|
||||||
type Workspace,
|
|
||||||
Theme,
|
|
||||||
} from '@/types/models';
|
|
||||||
|
|
||||||
// Set up mocks before imports are used
|
// Set up mocks before imports are used
|
||||||
vi.mock('@/api/workspace', () => {
|
vi.mock('@/api/workspace', () => {
|
||||||
@@ -126,7 +122,6 @@ describe('WorkspaceDataContext', () => {
|
|||||||
expect(result.current.currentWorkspace).toBeNull();
|
expect(result.current.currentWorkspace).toBeNull();
|
||||||
expect(result.current.loading).toBe(true);
|
expect(result.current.loading).toBe(true);
|
||||||
expect(result.current.workspaces).toEqual([]);
|
expect(result.current.workspaces).toEqual([]);
|
||||||
expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.loading).toBe(false);
|
expect(result.current.loading).toBe(false);
|
||||||
@@ -171,7 +166,6 @@ describe('WorkspaceDataContext', () => {
|
|||||||
|
|
||||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||||
expect(result.current.workspaces).toEqual(mockWorkspaceList);
|
expect(result.current.workspaces).toEqual(mockWorkspaceList);
|
||||||
expect(result.current.settings).toEqual(mockWorkspace);
|
|
||||||
expect(mockGetLastWorkspaceName).toHaveBeenCalledTimes(1);
|
expect(mockGetLastWorkspaceName).toHaveBeenCalledTimes(1);
|
||||||
expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace');
|
expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace');
|
||||||
expect(mockListWorkspaces).toHaveBeenCalledTimes(1);
|
expect(mockListWorkspaces).toHaveBeenCalledTimes(1);
|
||||||
@@ -258,7 +252,6 @@ describe('WorkspaceDataContext', () => {
|
|||||||
|
|
||||||
expect(result.current.currentWorkspace).toBeNull();
|
expect(result.current.currentWorkspace).toBeNull();
|
||||||
expect(result.current.workspaces).toEqual([]);
|
expect(result.current.workspaces).toEqual([]);
|
||||||
expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS);
|
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
@@ -420,7 +413,6 @@ describe('WorkspaceDataContext', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||||
expect(result.current.settings).toEqual(mockWorkspace);
|
|
||||||
expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace');
|
expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace');
|
||||||
expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark');
|
expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark');
|
||||||
});
|
});
|
||||||
@@ -500,7 +492,6 @@ describe('WorkspaceDataContext', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||||
expect(result.current.settings).toEqual(mockWorkspace);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets workspace to null', async () => {
|
it('sets workspace to null', async () => {
|
||||||
@@ -524,7 +515,6 @@ describe('WorkspaceDataContext', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.currentWorkspace).toBeNull();
|
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.currentWorkspace).toBeNull();
|
||||||
expect(result.current.workspaces).toEqual([]);
|
expect(result.current.workspaces).toEqual([]);
|
||||||
expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS);
|
|
||||||
expect(result.current.loading).toBe(false);
|
expect(result.current.loading).toBe(false);
|
||||||
|
|
||||||
expect(typeof result.current.loadWorkspaces).toBe('function');
|
expect(typeof result.current.loadWorkspaces).toBe('function');
|
||||||
@@ -631,7 +620,6 @@ describe('WorkspaceDataContext', () => {
|
|||||||
|
|
||||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||||
expect(result.current.workspaces).toEqual(mockWorkspaceList);
|
expect(result.current.workspaces).toEqual(mockWorkspaceList);
|
||||||
expect(result.current.settings).toEqual(mockWorkspace);
|
|
||||||
expect(result.current.loading).toBe(false);
|
expect(result.current.loading).toBe(false);
|
||||||
|
|
||||||
expect(typeof result.current.loadWorkspaces).toBe('function');
|
expect(typeof result.current.loadWorkspaces).toBe('function');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { DEFAULT_WORKSPACE_SETTINGS, type Workspace } from '@/types/models';
|
import { type Workspace } from '@/types/models';
|
||||||
import {
|
import {
|
||||||
getWorkspace,
|
getWorkspace,
|
||||||
listWorkspaces,
|
listWorkspaces,
|
||||||
@@ -19,7 +19,6 @@ import { useTheme } from './ThemeContext';
|
|||||||
interface WorkspaceDataContextType {
|
interface WorkspaceDataContextType {
|
||||||
currentWorkspace: Workspace | null;
|
currentWorkspace: Workspace | null;
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadWorkspaces: () => Promise<Workspace[]>;
|
loadWorkspaces: () => Promise<Workspace[]>;
|
||||||
loadWorkspaceData: (workspaceName: string) => Promise<void>;
|
loadWorkspaceData: (workspaceName: string) => Promise<void>;
|
||||||
@@ -121,7 +120,6 @@ export const WorkspaceDataProvider: React.FC<WorkspaceDataProviderProps> = ({
|
|||||||
const value: WorkspaceDataContextType = {
|
const value: WorkspaceDataContextType = {
|
||||||
currentWorkspace,
|
currentWorkspace,
|
||||||
workspaces,
|
workspaces,
|
||||||
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
|
|
||||||
loading,
|
loading,
|
||||||
loadWorkspaces,
|
loadWorkspaces,
|
||||||
loadWorkspaceData,
|
loadWorkspaceData,
|
||||||
|
|||||||
@@ -13,21 +13,20 @@ vi.mock('@mantine/notifications', () => ({
|
|||||||
|
|
||||||
// Mock the workspace context and git operations
|
// Mock the workspace context and git operations
|
||||||
const mockWorkspaceData: {
|
const mockWorkspaceData: {
|
||||||
currentWorkspace: { id: number; name: string } | null;
|
currentWorkspace: {
|
||||||
settings: {
|
id: number;
|
||||||
gitAutoCommit: boolean;
|
name: string;
|
||||||
gitEnabled: boolean;
|
gitAutoCommit?: boolean;
|
||||||
gitCommitMsgTemplate: string;
|
gitEnabled?: boolean;
|
||||||
};
|
gitCommitMsgTemplate?: string;
|
||||||
|
} | null;
|
||||||
} = {
|
} = {
|
||||||
currentWorkspace: {
|
currentWorkspace: {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'test-workspace',
|
name: 'test-workspace',
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
gitAutoCommit: false,
|
gitAutoCommit: false,
|
||||||
gitEnabled: false,
|
gitEnabled: false,
|
||||||
gitCommitMsgTemplate: '${action} ${filename}',
|
gitCommitMsgTemplate: '${action}: ${filename}',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,8 +52,6 @@ describe('useFileOperations', () => {
|
|||||||
mockWorkspaceData.currentWorkspace = {
|
mockWorkspaceData.currentWorkspace = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'test-workspace',
|
name: 'test-workspace',
|
||||||
};
|
|
||||||
mockWorkspaceData.settings = {
|
|
||||||
gitAutoCommit: false,
|
gitAutoCommit: false,
|
||||||
gitEnabled: false,
|
gitEnabled: false,
|
||||||
gitCommitMsgTemplate: '${action} ${filename}',
|
gitCommitMsgTemplate: '${action} ${filename}',
|
||||||
@@ -155,8 +152,8 @@ describe('useFileOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Enable auto-commit
|
// Enable auto-commit
|
||||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
mockWorkspaceData.currentWorkspace!.gitAutoCommit = true;
|
||||||
mockWorkspaceData.settings.gitEnabled = true;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
|
||||||
|
|
||||||
const { result } = renderHook(() => useFileOperations());
|
const { result } = renderHook(() => useFileOperations());
|
||||||
|
|
||||||
@@ -178,9 +175,9 @@ describe('useFileOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Enable auto-commit with custom template
|
// Enable auto-commit with custom template
|
||||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
mockWorkspaceData.currentWorkspace!.gitAutoCommit = true;
|
||||||
mockWorkspaceData.settings.gitEnabled = true;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
|
||||||
mockWorkspaceData.settings.gitCommitMsgTemplate =
|
mockWorkspaceData.currentWorkspace!.gitCommitMsgTemplate =
|
||||||
'Modified ${filename} - ${action}';
|
'Modified ${filename} - ${action}';
|
||||||
|
|
||||||
const { result } = renderHook(() => useFileOperations());
|
const { result } = renderHook(() => useFileOperations());
|
||||||
@@ -264,8 +261,8 @@ describe('useFileOperations', () => {
|
|||||||
mockDeleteFile.mockResolvedValue(undefined);
|
mockDeleteFile.mockResolvedValue(undefined);
|
||||||
|
|
||||||
// Enable auto-commit
|
// Enable auto-commit
|
||||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
mockWorkspaceData.currentWorkspace!.gitAutoCommit = true;
|
||||||
mockWorkspaceData.settings.gitEnabled = true;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
|
||||||
|
|
||||||
const { result } = renderHook(() => useFileOperations());
|
const { result } = renderHook(() => useFileOperations());
|
||||||
|
|
||||||
@@ -382,8 +379,8 @@ describe('useFileOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Enable auto-commit
|
// Enable auto-commit
|
||||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
mockWorkspaceData.currentWorkspace!.gitAutoCommit = true;
|
||||||
mockWorkspaceData.settings.gitEnabled = true;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
|
||||||
|
|
||||||
const { result } = renderHook(() => useFileOperations());
|
const { result } = renderHook(() => useFileOperations());
|
||||||
|
|
||||||
@@ -407,8 +404,8 @@ describe('useFileOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Enable auto-commit but disable git
|
// Enable auto-commit but disable git
|
||||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
mockWorkspaceData.currentWorkspace!.gitAutoCommit = true;
|
||||||
mockWorkspaceData.settings.gitEnabled = false;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = false;
|
||||||
|
|
||||||
const { result } = renderHook(() => useFileOperations());
|
const { result } = renderHook(() => useFileOperations());
|
||||||
|
|
||||||
@@ -428,8 +425,8 @@ describe('useFileOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Enable git but disable auto-commit
|
// Enable git but disable auto-commit
|
||||||
mockWorkspaceData.settings.gitAutoCommit = false;
|
mockWorkspaceData.currentWorkspace!.gitAutoCommit = false;
|
||||||
mockWorkspaceData.settings.gitEnabled = true;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
|
||||||
|
|
||||||
const { result } = renderHook(() => useFileOperations());
|
const { result } = renderHook(() => useFileOperations());
|
||||||
|
|
||||||
@@ -449,9 +446,10 @@ describe('useFileOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Enable auto-commit with lowercase template
|
// Enable auto-commit with lowercase template
|
||||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
mockWorkspaceData.currentWorkspace!.gitAutoCommit = true;
|
||||||
mockWorkspaceData.settings.gitEnabled = true;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
|
||||||
mockWorkspaceData.settings.gitCommitMsgTemplate = 'updated ${filename}';
|
mockWorkspaceData.currentWorkspace!.gitCommitMsgTemplate =
|
||||||
|
'updated ${filename}';
|
||||||
|
|
||||||
const { result } = renderHook(() => useFileOperations());
|
const { result } = renderHook(() => useFileOperations());
|
||||||
|
|
||||||
@@ -476,9 +474,9 @@ describe('useFileOperations', () => {
|
|||||||
mockDeleteFile.mockResolvedValue(undefined);
|
mockDeleteFile.mockResolvedValue(undefined);
|
||||||
|
|
||||||
// Enable auto-commit
|
// Enable auto-commit
|
||||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
mockWorkspaceData.currentWorkspace!.gitAutoCommit = true;
|
||||||
mockWorkspaceData.settings.gitEnabled = true;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
|
||||||
mockWorkspaceData.settings.gitCommitMsgTemplate =
|
mockWorkspaceData.currentWorkspace!.gitCommitMsgTemplate =
|
||||||
'${action}: ${filename}';
|
'${action}: ${filename}';
|
||||||
|
|
||||||
const { result } = renderHook(() => useFileOperations());
|
const { result } = renderHook(() => useFileOperations());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { notifications } from '@mantine/notifications';
|
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 { useWorkspaceData } from '../contexts/WorkspaceDataContext';
|
||||||
import { useGitOperations } from './useGitOperations';
|
import { useGitOperations } from './useGitOperations';
|
||||||
import { FileAction } from '@/types/models';
|
import { FileAction } from '@/types/models';
|
||||||
@@ -9,16 +9,24 @@ interface UseFileOperationsResult {
|
|||||||
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
handleDelete: (filePath: string) => Promise<boolean>;
|
handleDelete: (filePath: string) => Promise<boolean>;
|
||||||
handleCreate: (fileName: string, initialContent?: 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 => {
|
export const useFileOperations = (): UseFileOperationsResult => {
|
||||||
const { currentWorkspace, settings } = useWorkspaceData();
|
const { currentWorkspace } = useWorkspaceData();
|
||||||
const { handleCommitAndPush } = useGitOperations();
|
const { handleCommitAndPush } = useGitOperations();
|
||||||
|
|
||||||
const autoCommit = useCallback(
|
const autoCommit = useCallback(
|
||||||
async (filePath: string, action: FileAction): Promise<void> => {
|
async (filePath: string, action: FileAction): Promise<void> => {
|
||||||
if (settings.gitAutoCommit && settings.gitEnabled) {
|
if (!currentWorkspace || !currentWorkspace.gitEnabled) return;
|
||||||
let commitMessage = settings.gitCommitMsgTemplate
|
if (currentWorkspace.gitAutoCommit && currentWorkspace.gitEnabled) {
|
||||||
|
let commitMessage = currentWorkspace.gitCommitMsgTemplate
|
||||||
.replace('${filename}', filePath)
|
.replace('${filename}', filePath)
|
||||||
.replace('${action}', action);
|
.replace('${action}', action);
|
||||||
|
|
||||||
@@ -28,7 +36,7 @@ export const useFileOperations = (): UseFileOperationsResult => {
|
|||||||
await handleCommitAndPush(commitMessage);
|
await handleCommitAndPush(commitMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[settings, handleCommitAndPush]
|
[currentWorkspace, handleCommitAndPush]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = useCallback(
|
const handleSave = useCallback(
|
||||||
@@ -109,5 +117,116 @@ export const useFileOperations = (): UseFileOperationsResult => {
|
|||||||
[currentWorkspace, autoCommit]
|
[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
|
// Mock the workspace context
|
||||||
const mockWorkspaceData: {
|
const mockWorkspaceData: {
|
||||||
currentWorkspace: { id: number; name: string } | null;
|
currentWorkspace: { id: number; name: string; gitEnabled: boolean } | null;
|
||||||
settings: { gitEnabled: boolean };
|
|
||||||
} = {
|
} = {
|
||||||
currentWorkspace: {
|
currentWorkspace: {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'test-workspace',
|
name: 'test-workspace',
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
gitEnabled: true,
|
gitEnabled: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -39,8 +36,6 @@ describe('useGitOperations', () => {
|
|||||||
mockWorkspaceData.currentWorkspace = {
|
mockWorkspaceData.currentWorkspace = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'test-workspace',
|
name: 'test-workspace',
|
||||||
};
|
|
||||||
mockWorkspaceData.settings = {
|
|
||||||
gitEnabled: true,
|
gitEnabled: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -114,7 +109,7 @@ describe('useGitOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when git is disabled', async () => {
|
it('returns false when git is disabled', async () => {
|
||||||
mockWorkspaceData.settings.gitEnabled = false;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = false;
|
||||||
|
|
||||||
const { result } = renderHook(() => useGitOperations());
|
const { result } = renderHook(() => useGitOperations());
|
||||||
|
|
||||||
@@ -208,7 +203,7 @@ describe('useGitOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing when git is disabled', async () => {
|
it('does nothing when git is disabled', async () => {
|
||||||
mockWorkspaceData.settings.gitEnabled = false;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = false;
|
||||||
|
|
||||||
const { result } = renderHook(() => useGitOperations());
|
const { result } = renderHook(() => useGitOperations());
|
||||||
|
|
||||||
@@ -306,6 +301,7 @@ describe('useGitOperations', () => {
|
|||||||
mockWorkspaceData.currentWorkspace = {
|
mockWorkspaceData.currentWorkspace = {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'different-workspace',
|
name: 'different-workspace',
|
||||||
|
gitEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
rerender();
|
rerender();
|
||||||
@@ -321,10 +317,10 @@ describe('useGitOperations', () => {
|
|||||||
const { result, rerender } = renderHook(() => useGitOperations());
|
const { result, rerender } = renderHook(() => useGitOperations());
|
||||||
|
|
||||||
// Initially git is enabled
|
// Initially git is enabled
|
||||||
expect(mockWorkspaceData.settings.gitEnabled).toBe(true);
|
expect(mockWorkspaceData.currentWorkspace!.gitEnabled).toBe(true);
|
||||||
|
|
||||||
// Disable git
|
// Disable git
|
||||||
mockWorkspaceData.settings.gitEnabled = false;
|
mockWorkspaceData.currentWorkspace!.gitEnabled = false;
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
let pullResult: boolean | undefined;
|
let pullResult: boolean | undefined;
|
||||||
@@ -381,6 +377,7 @@ describe('useGitOperations', () => {
|
|||||||
mockWorkspaceData.currentWorkspace = {
|
mockWorkspaceData.currentWorkspace = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: undefined!,
|
name: undefined!,
|
||||||
|
gitEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => useGitOperations());
|
const { result } = renderHook(() => useGitOperations());
|
||||||
@@ -395,7 +392,9 @@ describe('useGitOperations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles missing settings gracefully', async () => {
|
it('handles missing settings gracefully', async () => {
|
||||||
mockWorkspaceData.settings = {
|
mockWorkspaceData.currentWorkspace = {
|
||||||
|
id: 1,
|
||||||
|
name: 'test-workspace',
|
||||||
gitEnabled: undefined!,
|
gitEnabled: undefined!,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ interface UseGitOperationsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useGitOperations = (): UseGitOperationsResult => {
|
export const useGitOperations = (): UseGitOperationsResult => {
|
||||||
const { currentWorkspace, settings } = useWorkspaceData();
|
const { currentWorkspace } = useWorkspaceData();
|
||||||
|
|
||||||
const handlePull = useCallback(async (): Promise<boolean> => {
|
const handlePull = useCallback(async (): Promise<boolean> => {
|
||||||
if (!currentWorkspace || !settings.gitEnabled || !currentWorkspace.name)
|
if (
|
||||||
|
!currentWorkspace ||
|
||||||
|
!currentWorkspace.gitEnabled ||
|
||||||
|
!currentWorkspace.name
|
||||||
|
)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -33,11 +37,11 @@ export const useGitOperations = (): UseGitOperationsResult => {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [currentWorkspace, settings.gitEnabled]);
|
}, [currentWorkspace]);
|
||||||
|
|
||||||
const handleCommitAndPush = useCallback(
|
const handleCommitAndPush = useCallback(
|
||||||
async (message: string): Promise<void> => {
|
async (message: string): Promise<void> => {
|
||||||
if (!currentWorkspace || !settings.gitEnabled) return;
|
if (!currentWorkspace || !currentWorkspace.gitEnabled) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const commitHash: CommitHash = await commitAndPush(
|
const commitHash: CommitHash = await commitAndPush(
|
||||||
@@ -60,7 +64,7 @@ export const useGitOperations = (): UseGitOperationsResult => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentWorkspace, settings.gitEnabled]
|
[currentWorkspace]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handlePull, handleCommitAndPush };
|
return { handlePull, handleCommitAndPush };
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
isLoginResponse,
|
isLoginResponse,
|
||||||
isLookupResponse,
|
isLookupResponse,
|
||||||
isSaveFileResponse,
|
isSaveFileResponse,
|
||||||
|
isUploadFilesResponse,
|
||||||
type LoginResponse,
|
type LoginResponse,
|
||||||
type LookupResponse,
|
type LookupResponse,
|
||||||
type SaveFileResponse,
|
type SaveFileResponse,
|
||||||
|
type UploadFilesResponse,
|
||||||
} from './api';
|
} from './api';
|
||||||
import { UserRole, type User } from './models';
|
import { UserRole, type User } from './models';
|
||||||
|
|
||||||
@@ -139,16 +141,6 @@ describe('API Type Guards', () => {
|
|||||||
|
|
||||||
expect(isLoginResponse(invalidResponse)).toBe(false);
|
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', () => {
|
describe('isLookupResponse', () => {
|
||||||
@@ -243,31 +235,6 @@ describe('API Type Guards', () => {
|
|||||||
|
|
||||||
expect(isLookupResponse(responseWithExtra)).toBe(true);
|
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', () => {
|
describe('isSaveFileResponse', () => {
|
||||||
@@ -387,18 +354,6 @@ describe('API Type Guards', () => {
|
|||||||
expect(isSaveFileResponse(invalidResponse)).toBe(true); // Note: Type guard doesn't validate negative numbers
|
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', () => {
|
it('handles objects with extra properties', () => {
|
||||||
const responseWithExtra = {
|
const responseWithExtra = {
|
||||||
filePath: 'test.md',
|
filePath: 'test.md',
|
||||||
@@ -409,105 +364,122 @@ describe('API Type Guards', () => {
|
|||||||
|
|
||||||
expect(isSaveFileResponse(responseWithExtra)).toBe(true);
|
expect(isSaveFileResponse(responseWithExtra)).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('handles complex file paths', () => {
|
describe('isUploadFilesResponse', () => {
|
||||||
const validSaveFileResponse: SaveFileResponse = {
|
it('returns true for valid upload files response', () => {
|
||||||
filePath: 'deep/nested/path/file with spaces & symbols.md',
|
const validUploadFilesResponse: UploadFilesResponse = {
|
||||||
size: 2048,
|
filePaths: [
|
||||||
updatedAt: '2024-01-01T10:00:00Z',
|
'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', () => {
|
it('returns true for upload files response with empty array', () => {
|
||||||
const dateFormats = [
|
const validUploadFilesResponse: UploadFilesResponse = {
|
||||||
'2024-01-01T10:00:00Z',
|
filePaths: [],
|
||||||
'2024-01-01T10:00:00.000Z',
|
};
|
||||||
'2024-01-01T10:00:00+00:00',
|
|
||||||
'2024-01-01T10:00:00.123456Z',
|
|
||||||
];
|
|
||||||
|
|
||||||
dateFormats.forEach((dateString) => {
|
expect(isUploadFilesResponse(validUploadFilesResponse)).toBe(true);
|
||||||
const validResponse: SaveFileResponse = {
|
});
|
||||||
filePath: 'test.md',
|
|
||||||
size: 1024,
|
|
||||||
updatedAt: dateString,
|
|
||||||
};
|
|
||||||
|
|
||||||
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', () => {
|
describe('edge cases and error conditions', () => {
|
||||||
it('handles circular references gracefully', () => {
|
it('handles objects with extra properties across different type guards', () => {
|
||||||
const circularObj: { paths: string[]; self?: unknown } = { paths: [] };
|
// Test that all type guards handle extra properties correctly
|
||||||
circularObj.self = circularObj;
|
expect(isLoginResponse({ user: mockUser, extra: 'field' })).toBe(true);
|
||||||
|
expect(isLookupResponse({ paths: [], extra: 'field' })).toBe(true);
|
||||||
// Should not throw an error
|
expect(
|
||||||
expect(isLookupResponse(circularObj)).toBe(true);
|
isSaveFileResponse({
|
||||||
});
|
filePath: 'test.md',
|
||||||
|
size: 1024,
|
||||||
it('handles deeply nested objects', () => {
|
updatedAt: '2024-01-01T10:00:00Z',
|
||||||
const deeplyNested = {
|
extra: 'field',
|
||||||
user: {
|
})
|
||||||
...mockUser,
|
).toBe(true);
|
||||||
nested: {
|
expect(
|
||||||
deep: {
|
isUploadFilesResponse({ filePaths: ['file1.md'], extra: 'field' })
|
||||||
deeper: {
|
).toBe(true);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
export interface UpdateLastOpenedFileRequest {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ func setupRouter(o Options) *chi.Mux {
|
|||||||
r.Route("/workspaces", func(r chi.Router) {
|
r.Route("/workspaces", func(r chi.Router) {
|
||||||
r.Get("/", handler.ListWorkspaces())
|
r.Get("/", handler.ListWorkspaces())
|
||||||
r.Post("/", handler.CreateWorkspace())
|
r.Post("/", handler.CreateWorkspace())
|
||||||
r.Get("/last", handler.GetLastWorkspaceName())
|
r.Get("/_op/last", handler.GetLastWorkspaceName())
|
||||||
r.Put("/last", handler.UpdateLastWorkspaceName())
|
r.Put("/_op/last", handler.UpdateLastWorkspaceName())
|
||||||
|
|
||||||
// Single workspace routes
|
// Single workspace routes
|
||||||
r.Route("/{workspaceName}", func(r chi.Router) {
|
r.Route("/{workspaceName}", func(r chi.Router) {
|
||||||
@@ -133,9 +133,12 @@ func setupRouter(o Options) *chi.Mux {
|
|||||||
r.Put("/last", handler.UpdateLastOpenedFile())
|
r.Put("/last", handler.UpdateLastOpenedFile())
|
||||||
r.Get("/lookup", handler.LookupFileByName())
|
r.Get("/lookup", handler.LookupFileByName())
|
||||||
|
|
||||||
r.Post("/*", handler.SaveFile())
|
r.Post("/upload", handler.UploadFile())
|
||||||
r.Get("/*", handler.GetFileContent())
|
r.Put("/move", handler.MoveFile())
|
||||||
r.Delete("/*", handler.DeleteFile())
|
|
||||||
|
r.Post("/", handler.SaveFile())
|
||||||
|
r.Get("/content", handler.GetFileContent())
|
||||||
|
r.Delete("/", handler.DeleteFile())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Git routes
|
// Git routes
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build test
|
//go:build test || integration
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -11,8 +10,6 @@ import (
|
|||||||
"lemma/internal/context"
|
"lemma/internal/context"
|
||||||
"lemma/internal/logging"
|
"lemma/internal/logging"
|
||||||
"lemma/internal/storage"
|
"lemma/internal/storage"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// LookupResponse represents a response to a file lookup request
|
// LookupResponse represents a response to a file lookup request
|
||||||
@@ -27,16 +24,16 @@ type SaveFileResponse struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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
|
// LastOpenedFileResponse represents a response to a last opened file request
|
||||||
type LastOpenedFileResponse struct {
|
type LastOpenedFileResponse struct {
|
||||||
LastOpenedFilePath string `json:"lastOpenedFilePath"`
|
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 {
|
func getFilesLogger() logging.Logger {
|
||||||
return getHandlersLogger().WithGroup("files")
|
return getHandlersLogger().WithGroup("files")
|
||||||
}
|
}
|
||||||
@@ -150,13 +147,13 @@ func (h *Handler) LookupFileByName() http.HandlerFunc {
|
|||||||
// @Security CookieAuth
|
// @Security CookieAuth
|
||||||
// @Produce plain
|
// @Produce plain
|
||||||
// @Param workspace_name path string true "Workspace name"
|
// @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"
|
// @Success 200 {string} string "Raw file content"
|
||||||
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
||||||
// @Failure 404 {object} ErrorResponse "File not found"
|
// @Failure 404 {object} ErrorResponse "File not found"
|
||||||
// @Failure 500 {object} ErrorResponse "Failed to read file"
|
// @Failure 500 {object} ErrorResponse "Failed to read file"
|
||||||
// @Failure 500 {object} ErrorResponse "Failed to write response"
|
// @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 {
|
func (h *Handler) GetFileContent() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, ok := context.GetRequestContext(w, r)
|
ctx, ok := context.GetRequestContext(w, r)
|
||||||
@@ -170,8 +167,7 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
|
|||||||
"clientIP", r.RemoteAddr,
|
"clientIP", r.RemoteAddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
filePath := chi.URLParam(r, "*")
|
filePath := r.URL.Query().Get("file_path")
|
||||||
// URL-decode the file path
|
|
||||||
decodedPath, err := url.PathUnescape(filePath)
|
decodedPath, err := url.PathUnescape(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("failed to decode file path",
|
log.Error("failed to decode file path",
|
||||||
@@ -231,12 +227,12 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
|
|||||||
// @Accept plain
|
// @Accept plain
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param workspace_name path string true "Workspace name"
|
// @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
|
// @Success 200 {object} SaveFileResponse
|
||||||
// @Failure 400 {object} ErrorResponse "Failed to read request body"
|
// @Failure 400 {object} ErrorResponse "Failed to read request body"
|
||||||
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
||||||
// @Failure 500 {object} ErrorResponse "Failed to save file"
|
// @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 {
|
func (h *Handler) SaveFile() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, ok := context.GetRequestContext(w, r)
|
ctx, ok := context.GetRequestContext(w, r)
|
||||||
@@ -250,7 +246,7 @@ func (h *Handler) SaveFile() http.HandlerFunc {
|
|||||||
"clientIP", r.RemoteAddr,
|
"clientIP", r.RemoteAddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
filePath := chi.URLParam(r, "*")
|
filePath := r.URL.Query().Get("file_path")
|
||||||
// URL-decode the file path
|
// URL-decode the file path
|
||||||
decodedPath, err := url.PathUnescape(filePath)
|
decodedPath, err := url.PathUnescape(filePath)
|
||||||
if err != nil {
|
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
|
// DeleteFile godoc
|
||||||
// @Summary Delete file
|
// @Summary Delete file
|
||||||
// @Description Deletes a file in the user's workspace
|
// @Description Deletes a file in the user's workspace
|
||||||
@@ -309,12 +543,12 @@ func (h *Handler) SaveFile() http.HandlerFunc {
|
|||||||
// @ID deleteFile
|
// @ID deleteFile
|
||||||
// @Security CookieAuth
|
// @Security CookieAuth
|
||||||
// @Param workspace_name path string true "Workspace name"
|
// @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"
|
// @Success 204 "No Content - File deleted successfully"
|
||||||
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
||||||
// @Failure 404 {object} ErrorResponse "File not found"
|
// @Failure 404 {object} ErrorResponse "File not found"
|
||||||
// @Failure 500 {object} ErrorResponse "Failed to delete file"
|
// @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 {
|
func (h *Handler) DeleteFile() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, ok := context.GetRequestContext(w, r)
|
ctx, ok := context.GetRequestContext(w, r)
|
||||||
@@ -328,7 +562,13 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
|
|||||||
"clientIP", r.RemoteAddr,
|
"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
|
// URL-decode the file path
|
||||||
decodedPath, err := url.PathUnescape(filePath)
|
decodedPath, err := url.PathUnescape(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -427,7 +667,7 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param workspace_name path string true "Workspace name"
|
// @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"
|
// @Success 204 "No Content - Last opened file updated successfully"
|
||||||
// @Failure 400 {object} ErrorResponse "Invalid request body"
|
// @Failure 400 {object} ErrorResponse "Invalid request body"
|
||||||
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
||||||
@@ -447,60 +687,53 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
|
|||||||
"clientIP", r.RemoteAddr,
|
"clientIP", r.RemoteAddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
var requestBody UpdateLastOpenedFileRequest
|
filePath := r.URL.Query().Get("file_path")
|
||||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
if filePath == "" {
|
||||||
log.Error("failed to decode request body",
|
log.Debug("missing file_path parameter")
|
||||||
"error", err.Error(),
|
respondError(w, "file_path is required", http.StatusBadRequest)
|
||||||
)
|
|
||||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the file path in the workspace
|
decodedPath, err := url.PathUnescape(filePath)
|
||||||
if requestBody.FilePath != "" {
|
if err != nil {
|
||||||
// URL-decode the file path
|
log.Error("failed to decode file path",
|
||||||
decodedPath, err := url.PathUnescape(requestBody.FilePath)
|
"filePath", filePath,
|
||||||
if err != nil {
|
"error", err.Error(),
|
||||||
log.Error("failed to decode file path",
|
)
|
||||||
"filePath", requestBody.FilePath,
|
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(),
|
"error", err.Error(),
|
||||||
)
|
)
|
||||||
respondError(w, "Invalid file path", http.StatusBadRequest)
|
respondError(w, "Invalid file path", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requestBody.FilePath = decodedPath
|
|
||||||
|
|
||||||
_, err = h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath)
|
if os.IsNotExist(err) {
|
||||||
if err != nil {
|
log.Debug("file not found",
|
||||||
if storage.IsPathValidationError(err) {
|
"filePath", decodedPath,
|
||||||
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(),
|
|
||||||
)
|
)
|
||||||
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
|
respondError(w, "File not found", http.StatusNotFound)
|
||||||
return
|
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",
|
log.Error("failed to update last opened file in database",
|
||||||
"filePath", requestBody.FilePath,
|
"filePath", decodedPath,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
)
|
)
|
||||||
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
|
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
filePath := "test.md"
|
filePath := "test.md"
|
||||||
|
|
||||||
// Save file
|
// 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)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
// Get file content
|
// 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)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
assert.Equal(t, content, rr.Body.String())
|
assert.Equal(t, content, rr.Body.String())
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
|
|
||||||
// Create all files
|
// Create all files
|
||||||
for path, content := range 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)
|
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
|
// Look up a file that exists in multiple locations
|
||||||
filename := "readme.md"
|
filename := "readme.md"
|
||||||
dupContent := "Another readme"
|
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)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
// Search for the file
|
// 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)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
@@ -135,7 +135,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
assert.Len(t, response.Paths, 2)
|
assert.Len(t, response.Paths, 2)
|
||||||
|
|
||||||
// Search for non-existent file
|
// 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)
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -144,15 +144,15 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
content := "This file will be deleted"
|
content := "This file will be deleted"
|
||||||
|
|
||||||
// Create file
|
// 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)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
// Delete file
|
// 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)
|
require.Equal(t, http.StatusNoContent, rr.Code)
|
||||||
|
|
||||||
// Verify file is gone
|
// 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)
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
}{
|
}{
|
||||||
FilePath: "docs/readme.md",
|
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)
|
require.Equal(t, http.StatusNoContent, rr.Code)
|
||||||
|
|
||||||
// Verify update
|
// Verify update
|
||||||
@@ -187,7 +187,7 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
|
|
||||||
// Test invalid file path
|
// Test invalid file path
|
||||||
updateReq.FilePath = "nonexistent.md"
|
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)
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -199,11 +199,11 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
body any
|
body any
|
||||||
}{
|
}{
|
||||||
{"list files", http.MethodGet, baseURL, nil},
|
{"list files", http.MethodGet, baseURL, nil},
|
||||||
{"get file", http.MethodGet, baseURL + "/test.md", nil},
|
{"get file", http.MethodGet, baseURL + "/content?file_path=" + url.QueryEscape("test.md"), nil},
|
||||||
{"save file", http.MethodPost, baseURL + "/test.md", "content"},
|
{"save file", http.MethodPost, baseURL + "?file_path=" + url.QueryEscape("test.md"), "content"},
|
||||||
{"delete file", http.MethodDelete, baseURL + "/test.md", nil},
|
{"delete file", http.MethodDelete, baseURL + "?file_path=" + url.QueryEscape("test.md"), nil},
|
||||||
{"get last file", http.MethodGet, baseURL + "/last", 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 {
|
for _, tc := range tests {
|
||||||
@@ -230,14 +230,98 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
for _, path := range maliciousPaths {
|
for _, path := range maliciousPaths {
|
||||||
t.Run(path, func(t *testing.T) {
|
t.Run(path, func(t *testing.T) {
|
||||||
// Try to read
|
// 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)
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||||
|
|
||||||
// Try to write
|
// 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)
|
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"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -303,11 +304,19 @@ func (h *testHarness) makeRequest(t *testing.T, method, path string, body any, t
|
|||||||
return h.executeRequest(req)
|
return h.executeRequest(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// makeRequestRawWithHeaders adds support for custom headers with raw body
|
// 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) *httptest.ResponseRecorder {
|
func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io.Reader, testUser *testUser, headers ...map[string]string) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := h.newRequestRaw(t, method, path, body)
|
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)
|
h.addAuthCookies(t, req, testUser)
|
||||||
|
|
||||||
needsCSRF := method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions
|
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)
|
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
|
// @Produce json
|
||||||
// @Success 200 {object} LastWorkspaceNameResponse
|
// @Success 200 {object} LastWorkspaceNameResponse
|
||||||
// @Failure 500 {object} ErrorResponse "Failed to get last workspace"
|
// @Failure 500 {object} ErrorResponse "Failed to get last workspace"
|
||||||
// @Router /workspaces/last [get]
|
// @Router /workspaces/_op/last [get]
|
||||||
func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
|
func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, ok := context.GetRequestContext(w, r)
|
ctx, ok := context.GetRequestContext(w, r)
|
||||||
@@ -444,7 +444,7 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
|
|||||||
// @Success 204 "No Content - Last workspace updated successfully"
|
// @Success 204 "No Content - Last workspace updated successfully"
|
||||||
// @Failure 400 {object} ErrorResponse "Invalid request body"
|
// @Failure 400 {object} ErrorResponse "Invalid request body"
|
||||||
// @Failure 500 {object} ErrorResponse "Failed to update last workspace"
|
// @Failure 500 {object} ErrorResponse "Failed to update last workspace"
|
||||||
// @Router /workspaces/last [put]
|
// @Router /workspaces/_op/last [put]
|
||||||
func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc {
|
func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, ok := context.GetRequestContext(w, r)
|
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("last workspace", func(t *testing.T) {
|
||||||
t.Run("get 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)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
@@ -229,11 +229,11 @@ func testWorkspaceHandlers(t *testing.T, dbConfig DatabaseConfig) {
|
|||||||
WorkspaceName: workspace.Name,
|
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)
|
require.Equal(t, http.StatusNoContent, rr.Code)
|
||||||
|
|
||||||
// Verify the update
|
// 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)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type FileManager interface {
|
|||||||
FindFileByName(userID, workspaceID int, filename string) ([]string, error)
|
FindFileByName(userID, workspaceID int, filename string) ([]string, error)
|
||||||
GetFileContent(userID, workspaceID int, filePath string) ([]byte, error)
|
GetFileContent(userID, workspaceID int, filePath string) ([]byte, error)
|
||||||
SaveFile(userID, workspaceID int, filePath string, content []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
|
DeleteFile(userID, workspaceID int, filePath string) error
|
||||||
GetFileStats(userID, workspaceID int) (*FileCountStats, error)
|
GetFileStats(userID, workspaceID int) (*FileCountStats, error)
|
||||||
GetTotalFileStats() (*FileCountStats, error)
|
GetTotalFileStats() (*FileCountStats, error)
|
||||||
@@ -174,6 +175,34 @@ func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []b
|
|||||||
return nil
|
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.
|
// DeleteFile deletes the file at the given filePath.
|
||||||
// Path must be a relative path within the workspace directory given by userID and workspaceID.
|
// 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 {
|
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 {
|
type fileSystem interface {
|
||||||
ReadFile(path string) ([]byte, error)
|
ReadFile(path string) ([]byte, error)
|
||||||
WriteFile(path string, data []byte, perm fs.FileMode) error
|
WriteFile(path string, data []byte, perm fs.FileMode) error
|
||||||
|
MoveFile(src, dst string) error
|
||||||
Remove(path string) error
|
Remove(path string) error
|
||||||
MkdirAll(path string, perm fs.FileMode) error
|
MkdirAll(path string, perm fs.FileMode) error
|
||||||
RemoveAll(path string) 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)
|
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.
|
// Remove deletes the file at the given path.
|
||||||
func (f *osFS) Remove(path string) error { return os.Remove(path) }
|
func (f *osFS) Remove(path string) error { return os.Remove(path) }
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type mockFS struct {
|
|||||||
// Record operations for verification
|
// Record operations for verification
|
||||||
ReadCalls map[string]int
|
ReadCalls map[string]int
|
||||||
WriteCalls map[string][]byte
|
WriteCalls map[string][]byte
|
||||||
|
MoveCalls map[string]string
|
||||||
RemoveCalls []string
|
RemoveCalls []string
|
||||||
MkdirCalls []string
|
MkdirCalls []string
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ type mockFS struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
WriteFileError error
|
WriteFileError error
|
||||||
|
MoveFileError error
|
||||||
RemoveError error
|
RemoveError error
|
||||||
MkdirError error
|
MkdirError error
|
||||||
StatError error
|
StatError error
|
||||||
@@ -66,6 +68,7 @@ func NewMockFS() *mockFS {
|
|||||||
return &mockFS{
|
return &mockFS{
|
||||||
ReadCalls: make(map[string]int),
|
ReadCalls: make(map[string]int),
|
||||||
WriteCalls: make(map[string][]byte),
|
WriteCalls: make(map[string][]byte),
|
||||||
|
MoveCalls: make(map[string]string),
|
||||||
RemoveCalls: make([]string, 0),
|
RemoveCalls: make([]string, 0),
|
||||||
MkdirCalls: make([]string, 0),
|
MkdirCalls: make([]string, 0),
|
||||||
ReadFileReturns: make(map[string]struct {
|
ReadFileReturns: make(map[string]struct {
|
||||||
@@ -88,6 +91,14 @@ func (m *mockFS) WriteFile(path string, data []byte, _ fs.FileMode) error {
|
|||||||
return m.WriteFileError
|
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 {
|
func (m *mockFS) Remove(path string) error {
|
||||||
m.RemoveCalls = append(m.RemoveCalls, path)
|
m.RemoveCalls = append(m.RemoveCalls, path)
|
||||||
return m.RemoveError
|
return m.RemoveError
|
||||||
|
|||||||
Reference in New Issue
Block a user