mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 23:44:22 +00:00
Merge pull request #49 from lordmathis/workspace_context
Workspace context
This commit is contained in:
10
.github/workflows/typescript.yml
vendored
10
.github/workflows/typescript.yml
vendored
@@ -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
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
46
app/src/contexts/ThemeContext.tsx
Normal file
46
app/src/contexts/ThemeContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
|
|||||||
146
app/src/contexts/WorkspaceDataContext.tsx
Normal file
146
app/src/contexts/WorkspaceDataContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
37
app/src/hooks/useWorkspace.ts
Normal file
37
app/src/hooks/useWorkspace.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
117
app/src/hooks/useWorkspaceOperations.ts
Normal file
117
app/src/hooks/useWorkspaceOperations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user