diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 6ae6533..29fe19e 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -12,6 +12,9 @@ jobs: type-check: name: TypeScript Type Check runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app steps: - name: Checkout code @@ -22,17 +25,14 @@ jobs: with: node-version: "22" cache: "npm" + cache-dependency-path: "./app/package-lock.json" - name: Install dependencies run: npm ci - working-directory: ./app - name: Run TypeScript type check run: npm run type-check - working-directory: ./app - # Optional: Run ESLint if you have it configured - name: Run ESLint run: npm run lint - working-directory: ./app - continue-on-error: true # Make this optional for now + continue-on-error: true diff --git a/app/src/api/api.ts b/app/src/api/api.ts index 53a3931..6f97c76 100644 --- a/app/src/api/api.ts +++ b/app/src/api/api.ts @@ -1,5 +1,22 @@ 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 */ @@ -9,14 +26,26 @@ export const apiCall = async ( ): Promise => { console.debug(`Making API call to: ${url}`); try { + // Set up headers with CSRF token for non-GET requests + const method = options.method || 'GET'; + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + // 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, { ...options, // Include credentials to send/receive cookies credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, + headers, }); console.debug(`Response status: ${response.status} for URL: ${url}`); diff --git a/app/src/components/editor/Editor.tsx b/app/src/components/editor/Editor.tsx index 7bc5fd7..bbff99f 100644 --- a/app/src/components/editor/Editor.tsx +++ b/app/src/components/editor/Editor.tsx @@ -5,7 +5,7 @@ import { EditorView, keymap } from '@codemirror/view'; import { markdown } from '@codemirror/lang-markdown'; import { defaultKeymap } from '@codemirror/commands'; import { oneDark } from '@codemirror/theme-one-dark'; -import { useWorkspace } from '../../contexts/WorkspaceContext'; +import { useWorkspace } from '../../hooks/useWorkspace'; interface EditorProps { content: string; diff --git a/app/src/components/editor/MarkdownPreview.tsx b/app/src/components/editor/MarkdownPreview.tsx index b79e959..83e4c4b 100644 --- a/app/src/components/editor/MarkdownPreview.tsx +++ b/app/src/components/editor/MarkdownPreview.tsx @@ -9,7 +9,7 @@ import rehypePrism from 'rehype-prism'; import * as prod from 'react/jsx-runtime'; import { notifications } from '@mantine/notifications'; import { remarkWikiLinks } from '../../utils/remarkWikiLinks'; -import { useWorkspace } from '../../contexts/WorkspaceContext'; +import { useWorkspace } from '../../hooks/useWorkspace'; interface MarkdownPreviewProps { content: string; diff --git a/app/src/components/files/FileActions.tsx b/app/src/components/files/FileActions.tsx index 907bf88..51ff89a 100644 --- a/app/src/components/files/FileActions.tsx +++ b/app/src/components/files/FileActions.tsx @@ -7,7 +7,7 @@ import { IconGitCommit, } from '@tabler/icons-react'; import { useModalContext } from '../../contexts/ModalContext'; -import { useWorkspace } from '../../contexts/WorkspaceContext'; +import { useWorkspace } from '../../hooks/useWorkspace'; interface FileActionsProps { handlePullChanges: () => Promise; diff --git a/app/src/components/layout/Layout.tsx b/app/src/components/layout/Layout.tsx index 1e76d13..98e97b8 100644 --- a/app/src/components/layout/Layout.tsx +++ b/app/src/components/layout/Layout.tsx @@ -5,7 +5,7 @@ import Sidebar from './Sidebar'; import MainContent from './MainContent'; import { useFileNavigation } from '../../hooks/useFileNavigation'; import { useFileList } from '../../hooks/useFileList'; -import { useWorkspace } from '../../contexts/WorkspaceContext'; +import { useWorkspace } from '../../hooks/useWorkspace'; const Layout: React.FC = () => { const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index c491524..06f40ca 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import { Box } from '@mantine/core'; import FileActions from '../files/FileActions'; import FileTree from '../files/FileTree'; import { useGitOperations } from '../../hooks/useGitOperations'; -import { useWorkspace } from '../../contexts/WorkspaceContext'; +import { useWorkspace } from '../../hooks/useWorkspace'; import type { FileNode } from '@/types/models'; interface SidebarProps { diff --git a/app/src/components/navigation/UserMenu.tsx b/app/src/components/navigation/UserMenu.tsx index 3981b73..c7177b8 100644 --- a/app/src/components/navigation/UserMenu.tsx +++ b/app/src/components/navigation/UserMenu.tsx @@ -102,7 +102,10 @@ const UserMenu: React.FC = () => { )} void handleLogout} + onClick={() => { + void handleLogout(); + setOpened(false); + }} px="sm" py="xs" color="red" diff --git a/app/src/components/navigation/WorkspaceSwitcher.tsx b/app/src/components/navigation/WorkspaceSwitcher.tsx index a0b05b1..74d2722 100644 --- a/app/src/components/navigation/WorkspaceSwitcher.tsx +++ b/app/src/components/navigation/WorkspaceSwitcher.tsx @@ -15,7 +15,7 @@ import { useMantineTheme, } from '@mantine/core'; import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; -import { useWorkspace } from '../../contexts/WorkspaceContext'; +import { useWorkspace } from '../../hooks/useWorkspace'; import { useModalContext } from '../../contexts/ModalContext'; import { listWorkspaces } from '../../api/workspace'; import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal'; diff --git a/app/src/components/settings/workspace/AppearanceSettings.tsx b/app/src/components/settings/workspace/AppearanceSettings.tsx index 0a242a6..537d38b 100644 --- a/app/src/components/settings/workspace/AppearanceSettings.tsx +++ b/app/src/components/settings/workspace/AppearanceSettings.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Text, Switch, Group, Box } from '@mantine/core'; -import { useWorkspace } from '../../../contexts/WorkspaceContext'; +import { useTheme } from '../../../contexts/ThemeContext'; import { Theme } from '@/types/models'; interface AppearanceSettingsProps { @@ -10,7 +10,7 @@ interface AppearanceSettingsProps { const AppearanceSettings: React.FC = ({ onThemeChange, }) => { - const { colorScheme, updateColorScheme } = useWorkspace(); + const { colorScheme, updateColorScheme } = useTheme(); const handleThemeChange = (): void => { const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark; diff --git a/app/src/components/settings/workspace/DangerZoneSettings.tsx b/app/src/components/settings/workspace/DangerZoneSettings.tsx index 3906d42..9ee98c5 100644 --- a/app/src/components/settings/workspace/DangerZoneSettings.tsx +++ b/app/src/components/settings/workspace/DangerZoneSettings.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Box, Button } from '@mantine/core'; import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal'; -import { useWorkspace } from '../../../contexts/WorkspaceContext'; +import { useWorkspace } from '../../../hooks/useWorkspace'; import { useModalContext } from '../../../contexts/ModalContext'; const DangerZoneSettings: React.FC = () => { diff --git a/app/src/components/settings/workspace/WorkspaceSettings.tsx b/app/src/components/settings/workspace/WorkspaceSettings.tsx index 1efcb85..f20b01c 100644 --- a/app/src/components/settings/workspace/WorkspaceSettings.tsx +++ b/app/src/components/settings/workspace/WorkspaceSettings.tsx @@ -9,7 +9,7 @@ import { Accordion, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { useWorkspace } from '../../../contexts/WorkspaceContext'; +import { useWorkspace } from '../../../hooks/useWorkspace'; import AppearanceSettings from './AppearanceSettings'; import EditorSettings from './EditorSettings'; import GitSettings from './GitSettings'; diff --git a/app/src/contexts/ThemeContext.tsx b/app/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..5ccb069 --- /dev/null +++ b/app/src/contexts/ThemeContext.tsx @@ -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(null); + +interface ThemeProviderProps { + children: ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const { colorScheme, setColorScheme } = useMantineColorScheme(); + + const updateColorScheme = useCallback( + (newTheme: MantineColorScheme): void => { + setColorScheme(newTheme); + }, + [setColorScheme] + ); + + const value: ThemeContextType = { + colorScheme, + updateColorScheme, + }; + + return ( + {children} + ); +}; + +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; diff --git a/app/src/contexts/WorkspaceContext.tsx b/app/src/contexts/WorkspaceContext.tsx index 039078a..41cbd09 100644 --- a/app/src/contexts/WorkspaceContext.tsx +++ b/app/src/contexts/WorkspaceContext.tsx @@ -1,241 +1,22 @@ -import React, { - type ReactNode, - createContext, - useContext, - 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'; +import React from 'react'; +import { ThemeProvider } from './ThemeContext'; +import { WorkspaceDataProvider } from './WorkspaceDataContext'; +import { useWorkspace as useWorkspaceHook } from '../hooks/useWorkspace'; -interface WorkspaceContextType { - currentWorkspace: Workspace | null; - workspaces: Workspace[]; - settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS; - updateSettings: (newSettings: Partial) => Promise; - loading: boolean; - colorScheme: MantineColorScheme; - updateColorScheme: (newTheme: MantineColorScheme) => void; - switchWorkspace: (workspaceName: string) => Promise; - deleteCurrentWorkspace: () => Promise; -} - -const WorkspaceContext = createContext(null); +// Re-export the useWorkspace hook directly for backward compatibility +export const useWorkspace = useWorkspaceHook; interface WorkspaceProviderProps { - children: ReactNode; + children: React.ReactNode; } +// Create a backward-compatible WorkspaceProvider that composes our new providers export const WorkspaceProvider: React.FC = ({ children, }) => { - const [currentWorkspace, setCurrentWorkspace] = useState( - null - ); - const [workspaces, setWorkspaces] = useState([]); - const [loading, setLoading] = useState(true); - const { colorScheme, setColorScheme } = useMantineColorScheme(); - - const loadWorkspaces = useCallback(async (): Promise => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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): Promise => { - 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 ( - - {children} - + + {children} + ); }; - -export const useWorkspace = (): WorkspaceContextType => { - const context = useContext(WorkspaceContext); - if (!context) { - throw new Error('useWorkspace must be used within a WorkspaceProvider'); - } - return context; -}; diff --git a/app/src/contexts/WorkspaceDataContext.tsx b/app/src/contexts/WorkspaceDataContext.tsx new file mode 100644 index 0000000..4d9e7e4 --- /dev/null +++ b/app/src/contexts/WorkspaceDataContext.tsx @@ -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; + loadWorkspaceData: (workspaceName: string) => Promise; + setCurrentWorkspace: (workspace: Workspace | null) => void; +} + +const WorkspaceDataContext = createContext( + null +); + +interface WorkspaceDataProviderProps { + children: ReactNode; +} + +export const WorkspaceDataProvider: React.FC = ({ + children, +}) => { + const [currentWorkspace, setCurrentWorkspace] = useState( + null + ); + const [workspaces, setWorkspaces] = useState([]); + const [loading, setLoading] = useState(true); + const { updateColorScheme } = useTheme(); + + const loadWorkspaces = useCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 ( + + {children} + + ); +}; + +export const useWorkspaceData = (): WorkspaceDataContextType => { + const context = useContext(WorkspaceDataContext); + if (!context) { + throw new Error( + 'useWorkspaceData must be used within a WorkspaceDataProvider' + ); + } + return context; +}; diff --git a/app/src/hooks/useFileContent.ts b/app/src/hooks/useFileContent.ts index 500a9a4..12f996e 100644 --- a/app/src/hooks/useFileContent.ts +++ b/app/src/hooks/useFileContent.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import { isImageFile } from '../utils/fileHelpers'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; import { getFileContent } from '@/api/file'; import { DEFAULT_FILE } from '@/types/models'; @@ -16,7 +16,7 @@ interface UseFileContentResult { export const useFileContent = ( selectedFile: string | null ): UseFileContentResult => { - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace } = useWorkspaceData(); const [content, setContent] = useState(DEFAULT_FILE.content); const [originalContent, setOriginalContent] = useState( DEFAULT_FILE.content diff --git a/app/src/hooks/useFileList.ts b/app/src/hooks/useFileList.ts index d835961..9d40bbd 100644 --- a/app/src/hooks/useFileList.ts +++ b/app/src/hooks/useFileList.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { listFiles } from '../api/file'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; import type { FileNode } from '@/types/models'; interface UseFileListResult { @@ -10,7 +10,7 @@ interface UseFileListResult { export const useFileList = (): UseFileListResult => { const [files, setFiles] = useState([]); - const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); + const { currentWorkspace, loading: workspaceLoading } = useWorkspaceData(); const loadFileList = useCallback(async (): Promise => { if (!currentWorkspace || workspaceLoading) return; diff --git a/app/src/hooks/useFileNavigation.ts b/app/src/hooks/useFileNavigation.ts index 3bc24e6..3240c19 100644 --- a/app/src/hooks/useFileNavigation.ts +++ b/app/src/hooks/useFileNavigation.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; import { useLastOpenedFile } from './useLastOpenedFile'; import { DEFAULT_FILE } from '@/types/models'; @@ -12,7 +12,7 @@ interface UseFileNavigationResult { export const useFileNavigation = (): UseFileNavigationResult => { const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path); const [isNewFile, setIsNewFile] = useState(true); - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace } = useWorkspaceData(); const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile(); const handleFileSelect = useCallback( diff --git a/app/src/hooks/useFileOperations.ts b/app/src/hooks/useFileOperations.ts index 3ef5b9e..f34e32c 100644 --- a/app/src/hooks/useFileOperations.ts +++ b/app/src/hooks/useFileOperations.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { saveFile, deleteFile } from '../api/file'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; import { useGitOperations } from './useGitOperations'; import { FileAction } from '@/types/models'; @@ -12,7 +12,7 @@ interface UseFileOperationsResult { } export const useFileOperations = (): UseFileOperationsResult => { - const { currentWorkspace, settings } = useWorkspace(); + const { currentWorkspace, settings } = useWorkspaceData(); const { handleCommitAndPush } = useGitOperations(); const autoCommit = useCallback( diff --git a/app/src/hooks/useGitOperations.ts b/app/src/hooks/useGitOperations.ts index 617330e..601dff1 100644 --- a/app/src/hooks/useGitOperations.ts +++ b/app/src/hooks/useGitOperations.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { pullChanges, commitAndPush } from '../api/git'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; import type { CommitHash } from '@/types/models'; interface UseGitOperationsResult { @@ -10,7 +10,7 @@ interface UseGitOperationsResult { } export const useGitOperations = (): UseGitOperationsResult => { - const { currentWorkspace, settings } = useWorkspace(); + const { currentWorkspace, settings } = useWorkspaceData(); const handlePull = useCallback(async (): Promise => { if (!currentWorkspace || !settings.gitEnabled) return false; diff --git a/app/src/hooks/useLastOpenedFile.ts b/app/src/hooks/useLastOpenedFile.ts index 4422818..5c0ceeb 100644 --- a/app/src/hooks/useLastOpenedFile.ts +++ b/app/src/hooks/useLastOpenedFile.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { getLastOpenedFile, updateLastOpenedFile } from '../api/file'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; interface UseLastOpenedFileResult { loadLastOpenedFile: () => Promise; @@ -8,7 +8,7 @@ interface UseLastOpenedFileResult { } export const useLastOpenedFile = (): UseLastOpenedFileResult => { - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace } = useWorkspaceData(); const loadLastOpenedFile = useCallback(async (): Promise => { if (!currentWorkspace) return null; diff --git a/app/src/hooks/useWorkspace.ts b/app/src/hooks/useWorkspace.ts new file mode 100644 index 0000000..fcaba6c --- /dev/null +++ b/app/src/hooks/useWorkspace.ts @@ -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) => Promise; + loading: boolean; + colorScheme: MantineColorScheme; + updateColorScheme: (newTheme: MantineColorScheme) => void; + switchWorkspace: (workspaceName: string) => Promise; + deleteCurrentWorkspace: () => Promise; +} + +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, + }; +}; diff --git a/app/src/hooks/useWorkspaceOperations.ts b/app/src/hooks/useWorkspaceOperations.ts new file mode 100644 index 0000000..a822384 --- /dev/null +++ b/app/src/hooks/useWorkspaceOperations.ts @@ -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; + deleteCurrentWorkspace: () => Promise; + updateSettings: (newSettings: Partial) => Promise; +} + +export const useWorkspaceOperations = (): UseWorkspaceOperationsResult => { + const { + currentWorkspace, + loadWorkspaceData, + loadWorkspaces, + setCurrentWorkspace, + } = useWorkspaceData(); + const { updateColorScheme } = useTheme(); + + const switchWorkspace = useCallback( + async (workspaceName: string): Promise => { + 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 => { + 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): Promise => { + 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, + }; +}; diff --git a/server/internal/app/config.go b/server/internal/app/config.go index e8ab943..3708935 100644 --- a/server/internal/app/config.go +++ b/server/internal/app/config.go @@ -34,7 +34,7 @@ type Config struct { // DefaultConfig returns a new Config instance with default values func DefaultConfig() *Config { return &Config{ - DBURL: "./lemma.db", + DBURL: "sqlite://lemma.db", DBType: db.DBTypeSQLite, WorkDir: "./data", StaticPath: "../app/dist", diff --git a/server/internal/context/middleware.go b/server/internal/context/middleware.go index 4d0a8d3..a06e946 100644 --- a/server/internal/context/middleware.go +++ b/server/internal/context/middleware.go @@ -3,6 +3,7 @@ package context import ( "lemma/internal/db" "net/http" + "net/url" "github.com/go-chi/chi/v5" ) @@ -42,12 +43,25 @@ func WithWorkspaceContextMiddleware(db db.WorkspaceReader) func(http.Handler) ht } 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 { log.Error("failed to get workspace", "error", err, "userID", ctx.UserID, - "workspace", workspaceName, + "workspace", decodedWorkspaceName, + "encodedWorkspace", workspaceName, "path", r.URL.Path) http.Error(w, "Failed to get workspace", http.StatusNotFound) return diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index 4c2a4c7..538742e 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "net/url" "os" "time" @@ -110,7 +111,18 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { 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 !os.IsNotExist(err) { log.Error("failed to lookup file", @@ -159,11 +171,22 @@ func (h *Handler) GetFileContent() http.HandlerFunc { ) 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 storage.IsPathValidationError(err) { log.Error("invalid file path attempted", - "filePath", filePath, + "filePath", decodedPath, "error", err.Error(), ) respondError(w, "Invalid file path", http.StatusBadRequest) @@ -172,7 +195,7 @@ func (h *Handler) GetFileContent() http.HandlerFunc { if os.IsNotExist(err) { log.Debug("file not found", - "filePath", filePath, + "filePath", decodedPath, ) respondError(w, "File not found", http.StatusNotFound) return @@ -228,21 +251,32 @@ func (h *Handler) SaveFile() http.HandlerFunc { ) 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) if err != nil { log.Error("failed to read request body", - "filePath", filePath, + "filePath", decodedPath, "error", err.Error(), ) respondError(w, "Failed to read request body", http.StatusBadRequest) 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 storage.IsPathValidationError(err) { log.Error("invalid file path attempted", - "filePath", filePath, + "filePath", decodedPath, "error", err.Error(), ) respondError(w, "Invalid file path", http.StatusBadRequest) @@ -295,11 +329,22 @@ func (h *Handler) DeleteFile() http.HandlerFunc { ) 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 storage.IsPathValidationError(err) { log.Error("invalid file path attempted", - "filePath", filePath, + "filePath", decodedPath, "error", err.Error(), ) respondError(w, "Invalid file path", http.StatusBadRequest) @@ -308,7 +353,7 @@ func (h *Handler) DeleteFile() http.HandlerFunc { if os.IsNotExist(err) { log.Debug("file not found", - "filePath", filePath, + "filePath", decodedPath, ) respondError(w, "File not found", http.StatusNotFound) return @@ -413,7 +458,19 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { // Validate the file path in the workspace 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 storage.IsPathValidationError(err) { log.Error("invalid file path attempted", diff --git a/server/internal/handlers/workspace_handlers.go b/server/internal/handlers/workspace_handlers.go index 3977a80..663c966 100644 --- a/server/internal/handlers/workspace_handlers.go +++ b/server/internal/handlers/workspace_handlers.go @@ -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 - if new.GitEnabled != old.GitEnabled { + if newWorkspace.GitEnabled != old.GitEnabled { return true } // If Git is enabled, check if any settings changed - if new.GitEnabled { - return new.GitURL != old.GitURL || - new.GitUser != old.GitUser || - new.GitToken != old.GitToken || - new.GitCommitName != old.GitCommitName || - new.GitCommitEmail != old.GitCommitEmail + if newWorkspace.GitEnabled { + return newWorkspace.GitURL != old.GitURL || + newWorkspace.GitUser != old.GitUser || + newWorkspace.GitToken != old.GitToken || + newWorkspace.GitCommitName != old.GitCommitName || + newWorkspace.GitCommitEmail != old.GitCommitEmail } return false