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

View File

@@ -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<Response> => {
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<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, {
...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}`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<AppearanceSettingsProps> = ({
onThemeChange,
}) => {
const { colorScheme, updateColorScheme } = useWorkspace();
const { colorScheme, updateColorScheme } = useTheme();
const handleThemeChange = (): void => {
const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark;

View File

@@ -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 = () => {

View File

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

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, {
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<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);
// 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<WorkspaceProviderProps> = ({
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 (
<WorkspaceContext.Provider value={value}>
{children}
</WorkspaceContext.Provider>
<ThemeProvider>
<WorkspaceDataProvider>{children}</WorkspaceDataProvider>
</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 { 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<string>(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState<string>(
DEFAULT_FILE.content

View File

@@ -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<FileNode[]>([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { currentWorkspace, loading: workspaceLoading } = useWorkspaceData();
const loadFileList = useCallback(async (): Promise<void> => {
if (!currentWorkspace || workspaceLoading) return;

View File

@@ -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<string>(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState<boolean>(true);
const { currentWorkspace } = useWorkspace();
const { currentWorkspace } = useWorkspaceData();
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
const handleFileSelect = useCallback(

View File

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

View File

@@ -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<boolean> => {
if (!currentWorkspace || !settings.gitEnabled) return false;

View File

@@ -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<string | null>;
@@ -8,7 +8,7 @@ interface UseLastOpenedFileResult {
}
export const useLastOpenedFile = (): UseLastOpenedFileResult => {
const { currentWorkspace } = useWorkspace();
const { currentWorkspace } = useWorkspaceData();
const loadLastOpenedFile = useCallback(async (): Promise<string | 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
func DefaultConfig() *Config {
return &Config{
DBURL: "./lemma.db",
DBURL: "sqlite://lemma.db",
DBType: db.DBTypeSQLite,
WorkDir: "./data",
StaticPath: "../app/dist",

View File

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

View File

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

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