Merge pull request #49 from lordmathis/workspace_context

Workspace context
This commit is contained in:
2025-05-25 15:58:16 +02:00
committed by GitHub
27 changed files with 514 additions and 284 deletions

View File

@@ -12,6 +12,9 @@ jobs:
type-check: type-check:
name: TypeScript Type Check name: TypeScript Type Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: ./app
steps: steps:
- name: Checkout code - name: Checkout code
@@ -22,17 +25,14 @@ jobs:
with: with:
node-version: "22" node-version: "22"
cache: "npm" cache: "npm"
cache-dependency-path: "./app/package-lock.json"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
working-directory: ./app
- name: Run TypeScript type check - name: Run TypeScript type check
run: npm run type-check run: npm run type-check
working-directory: ./app
# Optional: Run ESLint if you have it configured
- name: Run ESLint - name: Run ESLint
run: npm run lint run: npm run lint
working-directory: ./app continue-on-error: true
continue-on-error: true # Make this optional for now

View File

@@ -1,5 +1,22 @@
import { refreshToken } from './auth'; import { refreshToken } from './auth';
/**
* Gets the CSRF token from cookies
* @returns {string} The CSRF token or an empty string if not found
*/
const getCsrfToken = (): string => {
const cookies = document.cookie.split(';');
let csrfToken = '';
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'csrf_token' && value) {
csrfToken = decodeURIComponent(value);
break;
}
}
return csrfToken;
};
/** /**
* Makes an API call with proper cookie handling and error handling * Makes an API call with proper cookie handling and error handling
*/ */
@@ -9,14 +26,26 @@ export const apiCall = async (
): Promise<Response> => { ): Promise<Response> => {
console.debug(`Making API call to: ${url}`); console.debug(`Making API call to: ${url}`);
try { try {
// Set up headers with CSRF token for non-GET requests
const method = options.method || 'GET';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
// Add CSRF token for non-GET methods
if (method !== 'GET') {
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
// Include credentials to send/receive cookies // Include credentials to send/receive cookies
credentials: 'include', credentials: 'include',
headers: { headers,
'Content-Type': 'application/json',
...options.headers,
},
}); });
console.debug(`Response status: ${response.status} for URL: ${url}`); console.debug(`Response status: ${response.status} for URL: ${url}`);

View File

@@ -5,7 +5,7 @@ import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands'; import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
interface EditorProps { interface EditorProps {
content: string; content: string;

View File

@@ -9,7 +9,7 @@ import rehypePrism from 'rehype-prism';
import * as prod from 'react/jsx-runtime'; import * as prod from 'react/jsx-runtime';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { remarkWikiLinks } from '../../utils/remarkWikiLinks'; import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
interface MarkdownPreviewProps { interface MarkdownPreviewProps {
content: string; content: string;

View File

@@ -7,7 +7,7 @@ import {
IconGitCommit, IconGitCommit,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useModalContext } from '../../contexts/ModalContext'; import { useModalContext } from '../../contexts/ModalContext';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
interface FileActionsProps { interface FileActionsProps {
handlePullChanges: () => Promise<boolean>; handlePullChanges: () => Promise<boolean>;

View File

@@ -5,7 +5,7 @@ import Sidebar from './Sidebar';
import MainContent from './MainContent'; import MainContent from './MainContent';
import { useFileNavigation } from '../../hooks/useFileNavigation'; import { useFileNavigation } from '../../hooks/useFileNavigation';
import { useFileList } from '../../hooks/useFileList'; import { useFileList } from '../../hooks/useFileList';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
const Layout: React.FC = () => { const Layout: React.FC = () => {
const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); const { currentWorkspace, loading: workspaceLoading } = useWorkspace();

View File

@@ -3,7 +3,7 @@ import { Box } from '@mantine/core';
import FileActions from '../files/FileActions'; import FileActions from '../files/FileActions';
import FileTree from '../files/FileTree'; import FileTree from '../files/FileTree';
import { useGitOperations } from '../../hooks/useGitOperations'; import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
import type { FileNode } from '@/types/models'; import type { FileNode } from '@/types/models';
interface SidebarProps { interface SidebarProps {

View File

@@ -102,7 +102,10 @@ const UserMenu: React.FC = () => {
)} )}
<UnstyledButton <UnstyledButton
onClick={() => void handleLogout} onClick={() => {
void handleLogout();
setOpened(false);
}}
px="sm" px="sm"
py="xs" py="xs"
color="red" color="red"

View File

@@ -15,7 +15,7 @@ import {
useMantineTheme, useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
import { useModalContext } from '../../contexts/ModalContext'; import { useModalContext } from '../../contexts/ModalContext';
import { listWorkspaces } from '../../api/workspace'; import { listWorkspaces } from '../../api/workspace';
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal'; import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Text, Switch, Group, Box } from '@mantine/core'; import { Text, Switch, Group, Box } from '@mantine/core';
import { useWorkspace } from '../../../contexts/WorkspaceContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { Theme } from '@/types/models'; import { Theme } from '@/types/models';
interface AppearanceSettingsProps { interface AppearanceSettingsProps {
@@ -10,7 +10,7 @@ interface AppearanceSettingsProps {
const AppearanceSettings: React.FC<AppearanceSettingsProps> = ({ const AppearanceSettings: React.FC<AppearanceSettingsProps> = ({
onThemeChange, onThemeChange,
}) => { }) => {
const { colorScheme, updateColorScheme } = useWorkspace(); const { colorScheme, updateColorScheme } = useTheme();
const handleThemeChange = (): void => { const handleThemeChange = (): void => {
const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark; const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark;

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Box, Button } from '@mantine/core'; import { Box, Button } from '@mantine/core';
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal'; import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
import { useWorkspace } from '../../../contexts/WorkspaceContext'; import { useWorkspace } from '../../../hooks/useWorkspace';
import { useModalContext } from '../../../contexts/ModalContext'; import { useModalContext } from '../../../contexts/ModalContext';
const DangerZoneSettings: React.FC = () => { const DangerZoneSettings: React.FC = () => {

View File

@@ -9,7 +9,7 @@ import {
Accordion, Accordion,
} from '@mantine/core'; } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useWorkspace } from '../../../contexts/WorkspaceContext'; import { useWorkspace } from '../../../hooks/useWorkspace';
import AppearanceSettings from './AppearanceSettings'; import AppearanceSettings from './AppearanceSettings';
import EditorSettings from './EditorSettings'; import EditorSettings from './EditorSettings';
import GitSettings from './GitSettings'; import GitSettings from './GitSettings';

View File

@@ -0,0 +1,46 @@
import React, {
createContext,
useContext,
useCallback,
type ReactNode,
} from 'react';
import { useMantineColorScheme, type MantineColorScheme } from '@mantine/core';
interface ThemeContextType {
colorScheme: MantineColorScheme;
updateColorScheme: (newTheme: MantineColorScheme) => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { colorScheme, setColorScheme } = useMantineColorScheme();
const updateColorScheme = useCallback(
(newTheme: MantineColorScheme): void => {
setColorScheme(newTheme);
},
[setColorScheme]
);
const value: ThemeContextType = {
colorScheme,
updateColorScheme,
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@@ -1,241 +1,22 @@
import React, { import React from 'react';
type ReactNode, import { ThemeProvider } from './ThemeContext';
createContext, import { WorkspaceDataProvider } from './WorkspaceDataContext';
useContext, import { useWorkspace as useWorkspaceHook } from '../hooks/useWorkspace';
useState,
useEffect,
useCallback,
} from 'react';
import { useMantineColorScheme, type MantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { DEFAULT_WORKSPACE_SETTINGS, type Workspace } from '@/types/models';
import {
deleteWorkspace,
getLastWorkspaceName,
getWorkspace,
listWorkspaces,
updateLastWorkspaceName,
updateWorkspace,
} from '@/api/workspace';
interface WorkspaceContextType { // Re-export the useWorkspace hook directly for backward compatibility
currentWorkspace: Workspace | null; export const useWorkspace = useWorkspaceHook;
workspaces: Workspace[];
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
updateSettings: (newSettings: Partial<Workspace>) => Promise<void>;
loading: boolean;
colorScheme: MantineColorScheme;
updateColorScheme: (newTheme: MantineColorScheme) => void;
switchWorkspace: (workspaceName: string) => Promise<void>;
deleteCurrentWorkspace: () => Promise<void>;
}
const WorkspaceContext = createContext<WorkspaceContextType | null>(null);
interface WorkspaceProviderProps { interface WorkspaceProviderProps {
children: ReactNode; children: React.ReactNode;
} }
// Create a backward-compatible WorkspaceProvider that composes our new providers
export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
children, children,
}) => { }) => {
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(
null
);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const loadWorkspaces = useCallback(async (): Promise<Workspace[]> => {
try {
const workspaceList = await listWorkspaces();
setWorkspaces(workspaceList);
return workspaceList;
} catch (error) {
console.error('Failed to load workspaces:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspaces list',
color: 'red',
});
return [];
}
}, []);
const loadWorkspaceData = useCallback(
async (workspaceName: string): Promise<void> => {
try {
const workspace = await getWorkspace(workspaceName);
setCurrentWorkspace(workspace);
setColorScheme(workspace.theme as MantineColorScheme);
} catch (error) {
console.error('Failed to load workspace data:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace data',
color: 'red',
});
}
},
[setColorScheme]
);
const loadFirstAvailableWorkspace = useCallback(async (): Promise<void> => {
try {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
if (!firstWorkspace) throw new Error('No workspaces available');
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace',
color: 'red',
});
}
}, [loadWorkspaceData]);
useEffect(() => {
const initializeWorkspace = async (): Promise<void> => {
try {
const lastWorkspaceName = await getLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to initialize workspace:', error);
await loadFirstAvailableWorkspace();
} finally {
setLoading(false);
}
};
void initializeWorkspace();
}, [loadFirstAvailableWorkspace, loadWorkspaceData, loadWorkspaces]);
const switchWorkspace = useCallback(
async (workspaceName: string): Promise<void> => {
try {
setLoading(true);
await updateLastWorkspaceName(workspaceName);
await loadWorkspaceData(workspaceName);
await loadWorkspaces();
} catch (error) {
console.error('Failed to switch workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to switch workspace',
color: 'red',
});
} finally {
setLoading(false);
}
},
[loadWorkspaceData, loadWorkspaces]
);
const deleteCurrentWorkspace = useCallback(async (): Promise<void> => {
if (!currentWorkspace) return;
try {
const allWorkspaces = await loadWorkspaces();
if (allWorkspaces.length <= 1) {
notifications.show({
title: 'Error',
message:
'Cannot delete the last workspace. At least one workspace must exist.',
color: 'red',
});
return;
}
// Delete workspace and get the next workspace ID
const nextWorkspaceName: string = await deleteWorkspace(
currentWorkspace.name
);
// Load the new workspace data
await loadWorkspaceData(nextWorkspaceName);
notifications.show({
title: 'Success',
message: 'Workspace deleted successfully',
color: 'green',
});
await loadWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete workspace',
color: 'red',
});
}
}, [currentWorkspace, loadWorkspaceData, loadWorkspaces]);
const updateSettings = useCallback(
async (newSettings: Partial<Workspace>): Promise<void> => {
if (!currentWorkspace) return;
try {
const updatedWorkspace = {
...currentWorkspace,
...newSettings,
};
const response = await updateWorkspace(
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);
setColorScheme(response.theme);
await loadWorkspaces();
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[currentWorkspace, loadWorkspaces, setColorScheme]
);
const updateColorScheme = useCallback(
(newTheme: MantineColorScheme): void => {
setColorScheme(newTheme);
},
[setColorScheme]
);
const value: WorkspaceContextType = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
updateSettings,
loading,
colorScheme,
updateColorScheme,
switchWorkspace,
deleteCurrentWorkspace,
};
return ( return (
<WorkspaceContext.Provider value={value}> <ThemeProvider>
{children} <WorkspaceDataProvider>{children}</WorkspaceDataProvider>
</WorkspaceContext.Provider> </ThemeProvider>
); );
}; };
export const useWorkspace = (): WorkspaceContextType => {
const context = useContext(WorkspaceContext);
if (!context) {
throw new Error('useWorkspace must be used within a WorkspaceProvider');
}
return context;
};

View File

@@ -0,0 +1,146 @@
import React, {
type ReactNode,
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { notifications } from '@mantine/notifications';
import { DEFAULT_WORKSPACE_SETTINGS, type Workspace } from '@/types/models';
import {
getWorkspace,
listWorkspaces,
getLastWorkspaceName,
updateLastWorkspaceName,
} from '@/api/workspace';
import { useTheme } from './ThemeContext';
interface WorkspaceDataContextType {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
loading: boolean;
loadWorkspaces: () => Promise<Workspace[]>;
loadWorkspaceData: (workspaceName: string) => Promise<void>;
setCurrentWorkspace: (workspace: Workspace | null) => void;
}
const WorkspaceDataContext = createContext<WorkspaceDataContextType | null>(
null
);
interface WorkspaceDataProviderProps {
children: ReactNode;
}
export const WorkspaceDataProvider: React.FC<WorkspaceDataProviderProps> = ({
children,
}) => {
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(
null
);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const { updateColorScheme } = useTheme();
const loadWorkspaces = useCallback(async (): Promise<Workspace[]> => {
try {
const workspaceList = await listWorkspaces();
setWorkspaces(workspaceList);
return workspaceList;
} catch (error) {
console.error('Failed to load workspaces:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspaces list',
color: 'red',
});
return [];
}
}, []);
const loadWorkspaceData = useCallback(
async (workspaceName: string): Promise<void> => {
try {
const workspace = await getWorkspace(workspaceName);
setCurrentWorkspace(workspace);
updateColorScheme(workspace.theme);
} catch (error) {
console.error('Failed to load workspace data:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace data',
color: 'red',
});
}
},
[updateColorScheme]
);
const loadFirstAvailableWorkspace = useCallback(async (): Promise<void> => {
try {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
if (!firstWorkspace) throw new Error('No workspaces available');
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace',
color: 'red',
});
}
}, [loadWorkspaceData]);
useEffect(() => {
const initializeWorkspace = async (): Promise<void> => {
try {
const lastWorkspaceName = await getLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to initialize workspace:', error);
await loadFirstAvailableWorkspace();
} finally {
setLoading(false);
}
};
void initializeWorkspace();
}, [loadFirstAvailableWorkspace, loadWorkspaceData, loadWorkspaces]);
const value: WorkspaceDataContextType = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
loading,
loadWorkspaces,
loadWorkspaceData,
setCurrentWorkspace,
};
return (
<WorkspaceDataContext.Provider value={value}>
{children}
</WorkspaceDataContext.Provider>
);
};
export const useWorkspaceData = (): WorkspaceDataContextType => {
const context = useContext(WorkspaceDataContext);
if (!context) {
throw new Error(
'useWorkspaceData must be used within a WorkspaceDataProvider'
);
}
return context;
};

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { isImageFile } from '../utils/fileHelpers'; import { isImageFile } from '../utils/fileHelpers';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { getFileContent } from '@/api/file'; import { getFileContent } from '@/api/file';
import { DEFAULT_FILE } from '@/types/models'; import { DEFAULT_FILE } from '@/types/models';
@@ -16,7 +16,7 @@ interface UseFileContentResult {
export const useFileContent = ( export const useFileContent = (
selectedFile: string | null selectedFile: string | null
): UseFileContentResult => { ): UseFileContentResult => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspaceData();
const [content, setContent] = useState<string>(DEFAULT_FILE.content); const [content, setContent] = useState<string>(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState<string>( const [originalContent, setOriginalContent] = useState<string>(
DEFAULT_FILE.content DEFAULT_FILE.content

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { listFiles } from '../api/file'; import { listFiles } from '../api/file';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import type { FileNode } from '@/types/models'; import type { FileNode } from '@/types/models';
interface UseFileListResult { interface UseFileListResult {
@@ -10,7 +10,7 @@ interface UseFileListResult {
export const useFileList = (): UseFileListResult => { export const useFileList = (): UseFileListResult => {
const [files, setFiles] = useState<FileNode[]>([]); const [files, setFiles] = useState<FileNode[]>([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); const { currentWorkspace, loading: workspaceLoading } = useWorkspaceData();
const loadFileList = useCallback(async (): Promise<void> => { const loadFileList = useCallback(async (): Promise<void> => {
if (!currentWorkspace || workspaceLoading) return; if (!currentWorkspace || workspaceLoading) return;

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useLastOpenedFile } from './useLastOpenedFile'; import { useLastOpenedFile } from './useLastOpenedFile';
import { DEFAULT_FILE } from '@/types/models'; import { DEFAULT_FILE } from '@/types/models';
@@ -12,7 +12,7 @@ interface UseFileNavigationResult {
export const useFileNavigation = (): UseFileNavigationResult => { export const useFileNavigation = (): UseFileNavigationResult => {
const [selectedFile, setSelectedFile] = useState<string>(DEFAULT_FILE.path); const [selectedFile, setSelectedFile] = useState<string>(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState<boolean>(true); const [isNewFile, setIsNewFile] = useState<boolean>(true);
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspaceData();
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile(); const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
const handleFileSelect = useCallback( const handleFileSelect = useCallback(

View File

@@ -1,7 +1,7 @@
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 } from '../api/file';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useGitOperations } from './useGitOperations'; import { useGitOperations } from './useGitOperations';
import { FileAction } from '@/types/models'; import { FileAction } from '@/types/models';
@@ -12,7 +12,7 @@ interface UseFileOperationsResult {
} }
export const useFileOperations = (): UseFileOperationsResult => { export const useFileOperations = (): UseFileOperationsResult => {
const { currentWorkspace, settings } = useWorkspace(); const { currentWorkspace, settings } = useWorkspaceData();
const { handleCommitAndPush } = useGitOperations(); const { handleCommitAndPush } = useGitOperations();
const autoCommit = useCallback( const autoCommit = useCallback(

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { pullChanges, commitAndPush } from '../api/git'; import { pullChanges, commitAndPush } from '../api/git';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import type { CommitHash } from '@/types/models'; import type { CommitHash } from '@/types/models';
interface UseGitOperationsResult { interface UseGitOperationsResult {
@@ -10,7 +10,7 @@ interface UseGitOperationsResult {
} }
export const useGitOperations = (): UseGitOperationsResult => { export const useGitOperations = (): UseGitOperationsResult => {
const { currentWorkspace, settings } = useWorkspace(); const { currentWorkspace, settings } = useWorkspaceData();
const handlePull = useCallback(async (): Promise<boolean> => { const handlePull = useCallback(async (): Promise<boolean> => {
if (!currentWorkspace || !settings.gitEnabled) return false; if (!currentWorkspace || !settings.gitEnabled) return false;

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { getLastOpenedFile, updateLastOpenedFile } from '../api/file'; import { getLastOpenedFile, updateLastOpenedFile } from '../api/file';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
interface UseLastOpenedFileResult { interface UseLastOpenedFileResult {
loadLastOpenedFile: () => Promise<string | null>; loadLastOpenedFile: () => Promise<string | null>;
@@ -8,7 +8,7 @@ interface UseLastOpenedFileResult {
} }
export const useLastOpenedFile = (): UseLastOpenedFileResult => { export const useLastOpenedFile = (): UseLastOpenedFileResult => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspaceData();
const loadLastOpenedFile = useCallback(async (): Promise<string | null> => { const loadLastOpenedFile = useCallback(async (): Promise<string | null> => {
if (!currentWorkspace) return null; if (!currentWorkspace) return null;

View File

@@ -0,0 +1,37 @@
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useTheme } from '../contexts/ThemeContext';
import { useWorkspaceOperations } from './useWorkspaceOperations';
import type { Workspace, DEFAULT_WORKSPACE_SETTINGS } from '@/types/models';
import type { MantineColorScheme } from '@mantine/core';
interface UseWorkspaceResult {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
updateSettings: (newSettings: Partial<Workspace>) => Promise<void>;
loading: boolean;
colorScheme: MantineColorScheme;
updateColorScheme: (newTheme: MantineColorScheme) => void;
switchWorkspace: (workspaceName: string) => Promise<void>;
deleteCurrentWorkspace: () => Promise<void>;
}
export const useWorkspace = (): UseWorkspaceResult => {
const { currentWorkspace, workspaces, settings, loading } =
useWorkspaceData();
const { colorScheme, updateColorScheme } = useTheme();
const { switchWorkspace, deleteCurrentWorkspace, updateSettings } =
useWorkspaceOperations();
return {
currentWorkspace,
workspaces,
settings,
updateSettings,
loading,
colorScheme,
updateColorScheme,
switchWorkspace,
deleteCurrentWorkspace,
};
};

View File

@@ -0,0 +1,117 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import {
updateLastWorkspaceName,
updateWorkspace,
deleteWorkspace,
} from '@/api/workspace';
import { useTheme } from '../contexts/ThemeContext';
import type { Workspace } from '@/types/models';
interface UseWorkspaceOperationsResult {
switchWorkspace: (workspaceName: string) => Promise<void>;
deleteCurrentWorkspace: () => Promise<void>;
updateSettings: (newSettings: Partial<Workspace>) => Promise<void>;
}
export const useWorkspaceOperations = (): UseWorkspaceOperationsResult => {
const {
currentWorkspace,
loadWorkspaceData,
loadWorkspaces,
setCurrentWorkspace,
} = useWorkspaceData();
const { updateColorScheme } = useTheme();
const switchWorkspace = useCallback(
async (workspaceName: string): Promise<void> => {
try {
await updateLastWorkspaceName(workspaceName);
await loadWorkspaceData(workspaceName);
await loadWorkspaces();
} catch (error) {
console.error('Failed to switch workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to switch workspace',
color: 'red',
});
}
},
[loadWorkspaceData, loadWorkspaces]
);
const deleteCurrentWorkspace = useCallback(async (): Promise<void> => {
if (!currentWorkspace) return;
try {
const allWorkspaces = await loadWorkspaces();
if (allWorkspaces.length <= 1) {
notifications.show({
title: 'Error',
message:
'Cannot delete the last workspace. At least one workspace must exist.',
color: 'red',
});
return;
}
// Delete workspace and get the next workspace ID
const nextWorkspaceName: string = await deleteWorkspace(
currentWorkspace.name
);
// Load the new workspace data
await loadWorkspaceData(nextWorkspaceName);
notifications.show({
title: 'Success',
message: 'Workspace deleted successfully',
color: 'green',
});
await loadWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete workspace',
color: 'red',
});
}
}, [currentWorkspace, loadWorkspaceData, loadWorkspaces]);
const updateSettings = useCallback(
async (newSettings: Partial<Workspace>): Promise<void> => {
if (!currentWorkspace) return;
try {
const updatedWorkspace = {
...currentWorkspace,
...newSettings,
};
const response = await updateWorkspace(
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);
if (newSettings.theme) {
updateColorScheme(response.theme);
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[currentWorkspace, loadWorkspaces, updateColorScheme, setCurrentWorkspace]
);
return {
switchWorkspace,
deleteCurrentWorkspace,
updateSettings,
};
};

View File

@@ -34,7 +34,7 @@ type Config struct {
// DefaultConfig returns a new Config instance with default values // DefaultConfig returns a new Config instance with default values
func DefaultConfig() *Config { func DefaultConfig() *Config {
return &Config{ return &Config{
DBURL: "./lemma.db", DBURL: "sqlite://lemma.db",
DBType: db.DBTypeSQLite, DBType: db.DBTypeSQLite,
WorkDir: "./data", WorkDir: "./data",
StaticPath: "../app/dist", StaticPath: "../app/dist",

View File

@@ -3,6 +3,7 @@ package context
import ( import (
"lemma/internal/db" "lemma/internal/db"
"net/http" "net/http"
"net/url"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -42,12 +43,25 @@ func WithWorkspaceContextMiddleware(db db.WorkspaceReader) func(http.Handler) ht
} }
workspaceName := chi.URLParam(r, "workspaceName") workspaceName := chi.URLParam(r, "workspaceName")
workspace, err := db.GetWorkspaceByName(ctx.UserID, workspaceName) // URL-decode the workspace name
decodedWorkspaceName, err := url.PathUnescape(workspaceName)
if err != nil {
log.Error("failed to decode workspace name",
"error", err,
"userID", ctx.UserID,
"workspace", workspaceName,
"path", r.URL.Path)
http.Error(w, "Invalid workspace name", http.StatusBadRequest)
return
}
workspace, err := db.GetWorkspaceByName(ctx.UserID, decodedWorkspaceName)
if err != nil { if err != nil {
log.Error("failed to get workspace", log.Error("failed to get workspace",
"error", err, "error", err,
"userID", ctx.UserID, "userID", ctx.UserID,
"workspace", workspaceName, "workspace", decodedWorkspaceName,
"encodedWorkspace", workspaceName,
"path", r.URL.Path) "path", r.URL.Path)
http.Error(w, "Failed to get workspace", http.StatusNotFound) http.Error(w, "Failed to get workspace", http.StatusNotFound)
return return

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"time" "time"
@@ -110,7 +111,18 @@ func (h *Handler) LookupFileByName() http.HandlerFunc {
return return
} }
filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename) // URL-decode the filename
decodedFilename, err := url.PathUnescape(filename)
if err != nil {
log.Error("failed to decode filename",
"filename", filename,
"error", err.Error(),
)
respondError(w, "Invalid filename", http.StatusBadRequest)
return
}
filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, decodedFilename)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
log.Error("failed to lookup file", log.Error("failed to lookup file",
@@ -159,11 +171,22 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
) )
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
content, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, filePath) // URL-decode the file path
decodedPath, err := url.PathUnescape(filePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
content, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, decodedPath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted", log.Error("invalid file path attempted",
"filePath", filePath, "filePath", decodedPath,
"error", err.Error(), "error", err.Error(),
) )
respondError(w, "Invalid file path", http.StatusBadRequest) respondError(w, "Invalid file path", http.StatusBadRequest)
@@ -172,7 +195,7 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
if os.IsNotExist(err) { if os.IsNotExist(err) {
log.Debug("file not found", log.Debug("file not found",
"filePath", filePath, "filePath", decodedPath,
) )
respondError(w, "File not found", http.StatusNotFound) respondError(w, "File not found", http.StatusNotFound)
return return
@@ -228,21 +251,32 @@ func (h *Handler) SaveFile() http.HandlerFunc {
) )
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
// URL-decode the file path
decodedPath, err := url.PathUnescape(filePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
content, err := io.ReadAll(r.Body) content, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
log.Error("failed to read request body", log.Error("failed to read request body",
"filePath", filePath, "filePath", decodedPath,
"error", err.Error(), "error", err.Error(),
) )
respondError(w, "Failed to read request body", http.StatusBadRequest) respondError(w, "Failed to read request body", http.StatusBadRequest)
return return
} }
err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, decodedPath, content)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted", log.Error("invalid file path attempted",
"filePath", filePath, "filePath", decodedPath,
"error", err.Error(), "error", err.Error(),
) )
respondError(w, "Invalid file path", http.StatusBadRequest) respondError(w, "Invalid file path", http.StatusBadRequest)
@@ -295,11 +329,22 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
) )
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath) // URL-decode the file path
decodedPath, err := url.PathUnescape(filePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
err = h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, decodedPath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted", log.Error("invalid file path attempted",
"filePath", filePath, "filePath", decodedPath,
"error", err.Error(), "error", err.Error(),
) )
respondError(w, "Invalid file path", http.StatusBadRequest) respondError(w, "Invalid file path", http.StatusBadRequest)
@@ -308,7 +353,7 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
if os.IsNotExist(err) { if os.IsNotExist(err) {
log.Debug("file not found", log.Debug("file not found",
"filePath", filePath, "filePath", decodedPath,
) )
respondError(w, "File not found", http.StatusNotFound) respondError(w, "File not found", http.StatusNotFound)
return return
@@ -413,7 +458,19 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
// Validate the file path in the workspace // Validate the file path in the workspace
if requestBody.FilePath != "" { if requestBody.FilePath != "" {
_, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath) // URL-decode the file path
decodedPath, err := url.PathUnescape(requestBody.FilePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", requestBody.FilePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
requestBody.FilePath = decodedPath
_, err = h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted", log.Error("invalid file path attempted",

View File

@@ -173,19 +173,19 @@ func (h *Handler) GetWorkspace() http.HandlerFunc {
} }
} }
func gitSettingsChanged(new, old *models.Workspace) bool { func gitSettingsChanged(newWorkspace, old *models.Workspace) bool {
// Check if Git was enabled/disabled // Check if Git was enabled/disabled
if new.GitEnabled != old.GitEnabled { if newWorkspace.GitEnabled != old.GitEnabled {
return true return true
} }
// If Git is enabled, check if any settings changed // If Git is enabled, check if any settings changed
if new.GitEnabled { if newWorkspace.GitEnabled {
return new.GitURL != old.GitURL || return newWorkspace.GitURL != old.GitURL ||
new.GitUser != old.GitUser || newWorkspace.GitUser != old.GitUser ||
new.GitToken != old.GitToken || newWorkspace.GitToken != old.GitToken ||
new.GitCommitName != old.GitCommitName || newWorkspace.GitCommitName != old.GitCommitName ||
new.GitCommitEmail != old.GitCommitEmail newWorkspace.GitCommitEmail != old.GitCommitEmail
} }
return false return false