mirror of
https://github.com/lordmathis/lemma.git
synced 2025-12-23 18:14:22 +00:00
Compare commits
30 Commits
efdc42cbd7
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 74aeeec42b | |||
| 2e7bd88a57 | |||
| 0579b8d0e5 | |||
| 93f484eb91 | |||
| bc49391b5c | |||
| c98ece29d9 | |||
|
|
406483c83e | ||
| b285436081 | |||
| aeeab0e37a | |||
| 414000d72f | |||
| ffa10a8411 | |||
| 73a72b64be | |||
| 681b5a857c | |||
|
|
bc97c21b1d | ||
| a2cd260bca | |||
|
|
85f2bf23c1 | ||
| 8f06d39a71 | |||
| 93f95f22ef | |||
| 0a4abdb48c | |||
| 4ad5e682a8 | |||
| dd55c81b51 | |||
| 0939bc7213 | |||
| b8a9cee04d | |||
| 2259e7400a | |||
| 2045d36211 | |||
| 76ab168c6e | |||
| 6117f7a58f | |||
| ab6cb47047 | |||
| 4acba662b6 | |||
| 054d9da867 |
21
.github/dependabot.yml
vendored
Normal file
21
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/app"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -165,3 +165,6 @@ data
|
|||||||
|
|
||||||
# Feature specifications
|
# Feature specifications
|
||||||
spec.md
|
spec.md
|
||||||
|
|
||||||
|
# Go debug files
|
||||||
|
__debug_bin*
|
||||||
2205
app/package-lock.json
generated
2205
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,55 +29,56 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/LordMathis/Lemma#readme",
|
"homepage": "https://github.com/LordMathis/Lemma#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.6.2",
|
"@codemirror/autocomplete": "^6.19.1",
|
||||||
"@codemirror/lang-markdown": "^6.2.5",
|
"@codemirror/commands": "^6.10.0",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.34.0",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@mantine/core": "^7.13.2",
|
"@codemirror/view": "^6.38.6",
|
||||||
"@mantine/hooks": "^7.13.2",
|
"@mantine/core": "^8.3.7",
|
||||||
"@mantine/modals": "^7.13.2",
|
"@mantine/hooks": "^8.3.7",
|
||||||
"@mantine/notifications": "^7.13.2",
|
"@mantine/modals": "^8.3.7",
|
||||||
|
"@mantine/notifications": "^8.3.7",
|
||||||
"@react-hook/resize-observer": "^2.0.2",
|
"@react-hook/resize-observer": "^2.0.2",
|
||||||
"@tabler/icons-react": "^3.19.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "^3.4.0",
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-mathjax": "^6.0.0",
|
"rehype-mathjax": "^7.1.0",
|
||||||
"rehype-react": "^8.0.0",
|
"rehype-react": "^8.0.0",
|
||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.1",
|
"remark-rehype": "^11.1.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"unist-util-visit": "^5.0.0"
|
"unist-util-visit": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.9",
|
"@eslint/compat": "^1.4.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^18.3.20",
|
"@types/react": "^18.3.20",
|
||||||
"@types/react-dom": "^18.3.6",
|
"@types/react-dom": "^18.3.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||||
"@typescript-eslint/parser": "^8.32.1",
|
"@typescript-eslint/parser": "^8.32.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^3.1.4",
|
"@vitest/coverage-v8": "^4.0.8",
|
||||||
"eslint": "^9.27.0",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"sass": "^1.80.4",
|
"sass": "^1.93.3",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^6.4.1",
|
"vite": "^6.4.1",
|
||||||
"vite-plugin-compression2": "^1.3.0",
|
"vite-plugin-compression2": "^2.3.1",
|
||||||
"vitest": "^3.1.4"
|
"vitest": "^4.0.8"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -121,6 +122,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -138,6 +140,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -157,6 +160,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -179,6 +183,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -208,6 +213,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Editor from './Editor';
|
|||||||
import MarkdownPreview from './MarkdownPreview';
|
import MarkdownPreview from './MarkdownPreview';
|
||||||
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
|
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
|
||||||
import { useWorkspace } from '@/contexts/WorkspaceContext';
|
import { useWorkspace } from '@/contexts/WorkspaceContext';
|
||||||
|
import type { FileNode } from '../../types/models';
|
||||||
|
|
||||||
type ViewTab = 'source' | 'preview';
|
type ViewTab = 'source' | 'preview';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ interface ContentViewProps {
|
|||||||
handleContentChange: (content: string) => void;
|
handleContentChange: (content: string) => void;
|
||||||
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
handleFileSelect: (filePath: string | null) => Promise<void>;
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
files: FileNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentView: React.FC<ContentViewProps> = ({
|
const ContentView: React.FC<ContentViewProps> = ({
|
||||||
@@ -23,6 +25,7 @@ const ContentView: React.FC<ContentViewProps> = ({
|
|||||||
handleContentChange,
|
handleContentChange,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
|
files,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
if (!currentWorkspace) {
|
if (!currentWorkspace) {
|
||||||
@@ -67,6 +70,7 @@ const ContentView: React.FC<ContentViewProps> = ({
|
|||||||
handleContentChange={handleContentChange}
|
handleContentChange={handleContentChange}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
|
files={files}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />
|
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useMemo } from 'react';
|
||||||
import { basicSetup } from 'codemirror';
|
import { basicSetup } from 'codemirror';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
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 { autocompletion } from '@codemirror/autocomplete';
|
||||||
import { useWorkspace } from '../../hooks/useWorkspace';
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
|
import { createWikiLinkCompletions } from '../../utils/wikiLinkCompletion';
|
||||||
|
import { flattenFileTree } from '../../utils/fileHelpers';
|
||||||
|
import type { FileNode } from '../../types/models';
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
handleContentChange: (content: string) => void;
|
handleContentChange: (content: string) => void;
|
||||||
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
selectedFile: string;
|
selectedFile: string;
|
||||||
|
files: FileNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Editor: React.FC<EditorProps> = ({
|
const Editor: React.FC<EditorProps> = ({
|
||||||
@@ -19,11 +24,19 @@ const Editor: React.FC<EditorProps> = ({
|
|||||||
handleContentChange,
|
handleContentChange,
|
||||||
handleSave,
|
handleSave,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
|
files,
|
||||||
}) => {
|
}) => {
|
||||||
const { colorScheme } = useWorkspace();
|
const { colorScheme, currentWorkspace } = useWorkspace();
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
|
// Flatten file tree for autocompletion, respecting showHiddenFiles setting
|
||||||
|
const showHiddenFiles = currentWorkspace?.showHiddenFiles || false;
|
||||||
|
const flatFiles = useMemo(
|
||||||
|
() => flattenFileTree(files, showHiddenFiles),
|
||||||
|
[files, showHiddenFiles]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEditorSave = (view: EditorView): boolean => {
|
const handleEditorSave = (view: EditorView): boolean => {
|
||||||
void handleSave(selectedFile, view.state.doc.toString());
|
void handleSave(selectedFile, view.state.doc.toString());
|
||||||
@@ -71,6 +84,12 @@ const Editor: React.FC<EditorProps> = ({
|
|||||||
}),
|
}),
|
||||||
theme,
|
theme,
|
||||||
colorScheme === 'dark' ? oneDark : [],
|
colorScheme === 'dark' ? oneDark : [],
|
||||||
|
autocompletion({
|
||||||
|
override: [createWikiLinkCompletions(flatFiles)],
|
||||||
|
activateOnTyping: true,
|
||||||
|
maxRenderedOptions: 10,
|
||||||
|
closeOnBlur: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +106,7 @@ const Editor: React.FC<EditorProps> = ({
|
|||||||
};
|
};
|
||||||
// TODO: Refactor
|
// TODO: Refactor
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [colorScheme, handleContentChange, handleSave, selectedFile]);
|
}, [colorScheme, handleContentChange, handleSave, selectedFile, flatFiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
IconFolderOpen,
|
IconFolderOpen,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Tooltip, Text, Box } from '@mantine/core';
|
import { Text, Box } from '@mantine/core';
|
||||||
import useResizeObserver from '@react-hook/resize-observer';
|
import useResizeObserver from '@react-hook/resize-observer';
|
||||||
import { useFileOperations } from '../../hooks/useFileOperations';
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
import type { FileNode } from '@/types/models';
|
import type { FileNode } from '@/types/models';
|
||||||
@@ -53,13 +53,12 @@ function Node({
|
|||||||
style,
|
style,
|
||||||
dragHandle,
|
dragHandle,
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
...rest
|
|
||||||
}: {
|
}: {
|
||||||
node: NodeApi<FileNode>;
|
node: NodeApi<FileNode>;
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
dragHandle?: React.Ref<HTMLDivElement>;
|
dragHandle?: React.Ref<HTMLDivElement>;
|
||||||
onNodeClick?: (node: NodeApi<FileNode>) => void;
|
onNodeClick?: (node: NodeApi<FileNode>) => void;
|
||||||
} & Record<string, unknown>) {
|
}) {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (node.isInternal) {
|
if (node.isInternal) {
|
||||||
node.toggle();
|
node.toggle();
|
||||||
@@ -69,37 +68,40 @@ function Node({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={node.data.name} openDelay={500}>
|
<div
|
||||||
<div
|
ref={dragHandle} // This enables dragging for the node
|
||||||
ref={dragHandle} // This enables dragging for the node
|
style={{
|
||||||
|
...style,
|
||||||
|
paddingLeft: `${node.level * 20}px`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
// Add visual feedback when being dragged
|
||||||
|
opacity: node.state?.isDragging ? 0.5 : 1,
|
||||||
|
// Highlight when this node will receive the drop
|
||||||
|
backgroundColor: node.state?.willReceiveDrop
|
||||||
|
? 'rgba(0, 123, 255, 0.2)'
|
||||||
|
: 'transparent',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={node.data.name}
|
||||||
|
>
|
||||||
|
<FileIcon node={node} />
|
||||||
|
<span
|
||||||
style={{
|
style={{
|
||||||
...style,
|
marginLeft: '8px',
|
||||||
paddingLeft: `${node.level * 20}px`,
|
fontSize: '14px',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
// Add visual feedback when being dragged
|
textOverflow: 'ellipsis',
|
||||||
opacity: node.state?.isDragging ? 0.5 : 1,
|
flexGrow: 1,
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
|
||||||
{...rest}
|
|
||||||
>
|
>
|
||||||
<FileIcon node={node} />
|
{node.data.name}
|
||||||
<span
|
</span>
|
||||||
style={{
|
</div>
|
||||||
marginLeft: '8px',
|
|
||||||
fontSize: '14px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
flexGrow: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{node.data.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,41 +207,46 @@ export const FileTree: React.FC<FileTreeProps> = ({
|
|||||||
|
|
||||||
// External file drag and drop handlers
|
// External file drag and drop handlers
|
||||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Check if drag contains files (not internal tree nodes)
|
// Check if drag contains files (not internal tree nodes)
|
||||||
if (e.dataTransfer.types.includes('Files')) {
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
// Only handle if it's an external file drag
|
||||||
e.stopPropagation();
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
// Only hide overlay when leaving the container itself
|
// Only hide overlay when leaving the container itself
|
||||||
if (e.currentTarget === e.target) {
|
if (e.currentTarget === e.target) {
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
// Only handle external file drags
|
||||||
e.stopPropagation();
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
// Set the drop effect to indicate this is a valid drop target
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.stopPropagation();
|
||||||
|
// Set the drop effect to indicate this is a valid drop target
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
(e: React.DragEvent) => {
|
(e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
const { files } = e.dataTransfer;
|
const { files } = e.dataTransfer;
|
||||||
|
// Only handle if it's an external file drop
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
const uploadFiles = async () => {
|
const uploadFiles = async () => {
|
||||||
try {
|
try {
|
||||||
const success = await handleUpload(files);
|
const success = await handleUpload(files);
|
||||||
@@ -305,7 +312,10 @@ export const FileTree: React.FC<FileTreeProps> = ({
|
|||||||
height={size.height}
|
height={size.height}
|
||||||
indent={24}
|
indent={24}
|
||||||
rowHeight={28}
|
rowHeight={28}
|
||||||
|
idAccessor="id"
|
||||||
onMove={handleTreeMove}
|
onMove={handleTreeMove}
|
||||||
|
disableDrag={() => false}
|
||||||
|
disableDrop={() => false}
|
||||||
onActivate={(node) => {
|
onActivate={(node) => {
|
||||||
const fileNode = node.data;
|
const fileNode = node.data;
|
||||||
if (!node.isInternal) {
|
if (!node.isInternal) {
|
||||||
|
|||||||
266
app/src/components/files/FolderSelector.tsx
Normal file
266
app/src/components/files/FolderSelector.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import React, { useRef, useLayoutEffect, useState } from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import { Tree, type NodeApi } from 'react-arborist';
|
||||||
|
import {
|
||||||
|
IconFolder,
|
||||||
|
IconFolderOpen,
|
||||||
|
IconChevronRight,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import useResizeObserver from '@react-hook/resize-observer';
|
||||||
|
import { filterToFolders } from '../../utils/fileTreeUtils';
|
||||||
|
import type { FileNode } from '@/types/models';
|
||||||
|
|
||||||
|
interface FolderSelectorProps {
|
||||||
|
files: FileNode[];
|
||||||
|
selectedPath: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Size {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
|
||||||
|
const [size, setSize] = useState<Size>();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (target.current) {
|
||||||
|
setSize(target.current.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
useResizeObserver(target, (entry) => setSize(entry.contentRect));
|
||||||
|
return size;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Node component for rendering folders
|
||||||
|
function FolderNode({
|
||||||
|
node,
|
||||||
|
style,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: NodeApi<FileNode>;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
selectedPath: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const isSelected = node.data.path === selectedPath;
|
||||||
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
onSelect(node.data.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChevronClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
node.toggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
paddingLeft: `${node.level * 16 + 8}px`,
|
||||||
|
paddingRight: '8px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
paddingBottom: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? 'var(--mantine-color-blue-filled)'
|
||||||
|
: 'transparent',
|
||||||
|
color: isSelected ? 'var(--mantine-color-white)' : 'inherit',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.1s ease, color 0.1s ease',
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={node.data.name}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
'var(--mantine-color-default-hover)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected) {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Chevron for folders with children */}
|
||||||
|
{hasChildren && (
|
||||||
|
<IconChevronRight
|
||||||
|
size={14}
|
||||||
|
onClick={handleChevronClick}
|
||||||
|
style={{
|
||||||
|
marginRight: '4px',
|
||||||
|
transform: node.isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Spacer for items without chevron */}
|
||||||
|
{!hasChildren && <div style={{ width: '18px', flexShrink: 0 }} />}
|
||||||
|
|
||||||
|
{/* Folder icon */}
|
||||||
|
{node.isOpen ? (
|
||||||
|
<IconFolderOpen
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
isSelected
|
||||||
|
? 'var(--mantine-color-white)'
|
||||||
|
: 'var(--mantine-color-yellow-filled)'
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconFolder
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
isSelected
|
||||||
|
? 'var(--mantine-color-white)'
|
||||||
|
: 'var(--mantine-color-yellow-filled)'
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.data.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root node component
|
||||||
|
function RootNode({
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingLeft: '8px',
|
||||||
|
paddingRight: '8px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
paddingBottom: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? 'var(--mantine-color-blue-filled)'
|
||||||
|
: 'transparent',
|
||||||
|
color: isSelected ? 'var(--mantine-color-white)' : 'inherit',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.1s ease, color 0.1s ease',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
onClick={onSelect}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
'var(--mantine-color-default-hover)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected) {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '18px', flexShrink: 0 }} />
|
||||||
|
<IconFolder
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
isSelected
|
||||||
|
? 'var(--mantine-color-white)'
|
||||||
|
: 'var(--mantine-color-yellow-filled)'
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/ (root)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FolderSelector: React.FC<FolderSelectorProps> = ({
|
||||||
|
files,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const target = useRef<HTMLDivElement>(null);
|
||||||
|
const size = useSize(target);
|
||||||
|
|
||||||
|
// Filter to only folders
|
||||||
|
const folders = filterToFolders(files);
|
||||||
|
|
||||||
|
// Calculate tree height: root node (32px) + folders
|
||||||
|
const rootNodeHeight = 32;
|
||||||
|
const treeHeight = size ? size.height - rootNodeHeight : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={target}
|
||||||
|
style={{
|
||||||
|
maxHeight: '300px',
|
||||||
|
height: '300px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Root option */}
|
||||||
|
<RootNode isSelected={selectedPath === ''} onSelect={() => onSelect('')} />
|
||||||
|
|
||||||
|
{/* Folder tree */}
|
||||||
|
{size && folders.length > 0 && (
|
||||||
|
<Tree
|
||||||
|
data={folders}
|
||||||
|
openByDefault={false}
|
||||||
|
width={size.width - 16}
|
||||||
|
height={treeHeight}
|
||||||
|
indent={24}
|
||||||
|
rowHeight={28}
|
||||||
|
idAccessor="id"
|
||||||
|
disableDrag={() => true}
|
||||||
|
disableDrop={() => true}
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<FolderNode {...props} selectedPath={selectedPath} onSelect={onSelect} />
|
||||||
|
)}
|
||||||
|
</Tree>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FolderSelector;
|
||||||
@@ -53,6 +53,7 @@ const Layout: React.FC = () => {
|
|||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
handleFileSelect={handleFileSelect}
|
handleFileSelect={handleFileSelect}
|
||||||
loadFileList={loadFileList}
|
loadFileList={loadFileList}
|
||||||
|
files={files}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ describe('MainContent', () => {
|
|||||||
selectedFile="docs/guide.md"
|
selectedFile="docs/guide.md"
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
loadFileList={mockLoadFileList}
|
loadFileList={mockLoadFileList}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -156,6 +157,7 @@ describe('MainContent', () => {
|
|||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
loadFileList={mockLoadFileList}
|
loadFileList={mockLoadFileList}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -172,6 +174,7 @@ describe('MainContent', () => {
|
|||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
loadFileList={mockLoadFileList}
|
loadFileList={mockLoadFileList}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -188,6 +191,7 @@ describe('MainContent', () => {
|
|||||||
selectedFile={null}
|
selectedFile={null}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
loadFileList={mockLoadFileList}
|
loadFileList={mockLoadFileList}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useFileContent } from '../../hooks/useFileContent';
|
|||||||
import { useFileOperations } from '../../hooks/useFileOperations';
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||||
import { useModalContext } from '../../contexts/ModalContext';
|
import { useModalContext } from '../../contexts/ModalContext';
|
||||||
|
import type { FileNode } from '../../types/models';
|
||||||
|
|
||||||
type ViewTab = 'source' | 'preview';
|
type ViewTab = 'source' | 'preview';
|
||||||
|
|
||||||
@@ -19,12 +20,14 @@ interface MainContentProps {
|
|||||||
selectedFile: string | null;
|
selectedFile: string | null;
|
||||||
handleFileSelect: (filePath: string | null) => Promise<void>;
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
loadFileList: () => Promise<void>;
|
loadFileList: () => Promise<void>;
|
||||||
|
files: FileNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MainContent: React.FC<MainContentProps> = ({
|
const MainContent: React.FC<MainContentProps> = ({
|
||||||
selectedFile,
|
selectedFile,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
loadFileList,
|
loadFileList,
|
||||||
|
files,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState<ViewTab>('source');
|
const [activeTab, setActiveTab] = useState<ViewTab>('source');
|
||||||
const {
|
const {
|
||||||
@@ -161,6 +164,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
handleContentChange={handleContentChange}
|
handleContentChange={handleContentChange}
|
||||||
handleSave={handleSaveFile}
|
handleSave={handleSaveFile}
|
||||||
handleFileSelect={handleFileSelect}
|
handleFileSelect={handleFileSelect}
|
||||||
|
files={files}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<CreateFileModal onCreateFile={handleCreateFile} />
|
<CreateFileModal onCreateFile={handleCreateFile} />
|
||||||
|
|||||||
@@ -29,6 +29,31 @@ vi.mock('../../../contexts/ModalContext', () => ({
|
|||||||
useModalContext: () => mockModalContext,
|
useModalContext: () => mockModalContext,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock useFileList hook
|
||||||
|
const mockLoadFileList = vi.fn();
|
||||||
|
const mockFiles = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'docs',
|
||||||
|
path: 'docs',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'guides',
|
||||||
|
path: 'docs/guides',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/useFileList', () => ({
|
||||||
|
useFileList: () => ({
|
||||||
|
files: mockFiles,
|
||||||
|
loadFileList: mockLoadFileList,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// Helper wrapper component for testing
|
// Helper wrapper component for testing
|
||||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
@@ -47,6 +72,8 @@ describe('CreateFileModal', () => {
|
|||||||
mockOnCreateFile.mockReset();
|
mockOnCreateFile.mockReset();
|
||||||
mockOnCreateFile.mockResolvedValue(undefined);
|
mockOnCreateFile.mockResolvedValue(undefined);
|
||||||
mockModalContext.setNewFileModalVisible.mockClear();
|
mockModalContext.setNewFileModalVisible.mockClear();
|
||||||
|
mockLoadFileList.mockClear();
|
||||||
|
mockLoadFileList.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Modal Visibility and Content', () => {
|
describe('Modal Visibility and Content', () => {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
import { Modal, TextInput, Button, Group, Box, Popover, ActionIcon, Text } from '@mantine/core';
|
||||||
|
import { IconFolderOpen } from '@tabler/icons-react';
|
||||||
import { useModalContext } from '../../../contexts/ModalContext';
|
import { useModalContext } from '../../../contexts/ModalContext';
|
||||||
|
import { useFileList } from '../../../hooks/useFileList';
|
||||||
|
import { FolderSelector } from '../../files/FolderSelector';
|
||||||
|
|
||||||
interface CreateFileModalProps {
|
interface CreateFileModalProps {
|
||||||
onCreateFile: (fileName: string) => Promise<void>;
|
onCreateFile: (fileName: string) => Promise<void>;
|
||||||
@@ -8,16 +11,49 @@ interface CreateFileModalProps {
|
|||||||
|
|
||||||
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
||||||
const [fileName, setFileName] = useState<string>('');
|
const [fileName, setFileName] = useState<string>('');
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState<string>('');
|
||||||
|
const [popoverOpened, setPopoverOpened] = useState<boolean>(false);
|
||||||
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
|
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
|
||||||
|
const { files, loadFileList } = useFileList();
|
||||||
|
|
||||||
const handleSubmit = async (): Promise<void> => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
if (fileName) {
|
if (fileName) {
|
||||||
await onCreateFile(fileName.trim());
|
const fullPath = selectedFolder
|
||||||
|
? `${selectedFolder}/${fileName.trim()}`
|
||||||
|
: fileName.trim();
|
||||||
|
await onCreateFile(fullPath);
|
||||||
setFileName('');
|
setFileName('');
|
||||||
|
setSelectedFolder('');
|
||||||
setNewFileModalVisible(false);
|
setNewFileModalVisible(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setFileName('');
|
||||||
|
setSelectedFolder('');
|
||||||
|
setNewFileModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderSelect = (path: string) => {
|
||||||
|
setSelectedFolder(path);
|
||||||
|
setPopoverOpened(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load files when modal opens
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (newFileModalVisible) {
|
||||||
|
void loadFileList();
|
||||||
|
}
|
||||||
|
}, [newFileModalVisible, loadFileList]);
|
||||||
|
|
||||||
|
// Generate full path preview
|
||||||
|
const fullPathPreview = selectedFolder
|
||||||
|
? `${selectedFolder}/${fileName || 'filename'}`
|
||||||
|
: fileName || 'filename';
|
||||||
|
|
||||||
|
// Display text for location input
|
||||||
|
const locationDisplay = selectedFolder || '/ (root)';
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -28,27 +64,82 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
|||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={newFileModalVisible}
|
opened={newFileModalVisible}
|
||||||
onClose={() => setNewFileModalVisible(false)}
|
onClose={handleClose}
|
||||||
title="Create New File"
|
title="Create New File"
|
||||||
centered
|
centered
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Box maw={400} mx="auto">
|
<Box maw={400} mx="auto">
|
||||||
|
{/* Location input with folder picker */}
|
||||||
|
<Popover
|
||||||
|
opened={popoverOpened}
|
||||||
|
onChange={setPopoverOpened}
|
||||||
|
position="bottom-start"
|
||||||
|
width="target"
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<TextInput
|
||||||
|
label="Location"
|
||||||
|
type="text"
|
||||||
|
placeholder="Select folder"
|
||||||
|
data-testid="location-input"
|
||||||
|
value={locationDisplay}
|
||||||
|
readOnly
|
||||||
|
mb="md"
|
||||||
|
w="100%"
|
||||||
|
rightSection={
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => setPopoverOpened((o) => !o)}
|
||||||
|
data-testid="folder-picker-button"
|
||||||
|
>
|
||||||
|
<IconFolderOpen size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => setPopoverOpened(true)}
|
||||||
|
/>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<FolderSelector
|
||||||
|
files={files}
|
||||||
|
selectedPath={selectedFolder}
|
||||||
|
onSelect={handleFolderSelect}
|
||||||
|
/>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* File name input */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="File Name"
|
label="File Name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter file name"
|
placeholder="example.md"
|
||||||
data-testid="file-name-input"
|
data-testid="file-name-input"
|
||||||
value={fileName}
|
value={fileName}
|
||||||
onChange={(event) => setFileName(event.currentTarget.value)}
|
onChange={(event) => setFileName(event.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
mb="md"
|
mb="xs"
|
||||||
w="100%"
|
w="100%"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Hint text */}
|
||||||
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
|
Tip: Use / to create nested folders (e.g., folder/subfolder/file.md)
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Full path preview */}
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Full path: {fullPathPreview}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setNewFileModalVisible(false)}
|
onClick={handleClose}
|
||||||
data-testid="cancel-create-file-button"
|
data-testid="cancel-create-file-button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ import React from 'react';
|
|||||||
import { MantineProvider } from '@mantine/core';
|
import { MantineProvider } from '@mantine/core';
|
||||||
import ProfileSettings from './ProfileSettings';
|
import ProfileSettings from './ProfileSettings';
|
||||||
import type { UserProfileSettings } from '@/types/models';
|
import type { UserProfileSettings } from '@/types/models';
|
||||||
|
import { Theme, UserRole, type User } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock user for AuthContext
|
||||||
|
const mockUser: User = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
displayName: 'Test User',
|
||||||
|
role: UserRole.Editor,
|
||||||
|
theme: Theme.Dark,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
lastWorkspaceId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the auth context
|
||||||
|
vi.mock('../../../contexts/AuthContext', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
user: mockUser,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// Helper wrapper component for testing
|
// Helper wrapper component for testing
|
||||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Stack, TextInput, Group, Text, Switch } from '@mantine/core';
|
import { Box, Stack, TextInput, Group, Text, Switch } from '@mantine/core';
|
||||||
import { IconMoon, IconSun } from '@tabler/icons-react';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Theme, type UserProfileSettings } from '@/types/models';
|
import { Theme, type UserProfileSettings } from '@/types/models';
|
||||||
|
|
||||||
@@ -44,24 +43,21 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||||||
placeholder="Enter email"
|
placeholder="Enter email"
|
||||||
data-testid="email-input"
|
data-testid="email-input"
|
||||||
/>
|
/>
|
||||||
<Group justify="space-between" align="flex-start">
|
<Box mb="md">
|
||||||
<div>
|
<Group justify="space-between" align="center">
|
||||||
<Text size="sm" fw={500}>
|
<div>
|
||||||
Default Theme
|
<Text size="sm">Default Dark Mode</Text>
|
||||||
</Text>
|
<Text size="xs" c="dimmed">
|
||||||
<Text size="xs" c="dimmed">
|
Sets the default theme for new workspaces
|
||||||
Sets the default theme for new workspaces
|
</Text>
|
||||||
</Text>
|
</div>
|
||||||
</div>
|
<Switch
|
||||||
<Switch
|
checked={currentTheme === Theme.Dark}
|
||||||
checked={currentTheme === Theme.Dark}
|
onChange={handleThemeToggle}
|
||||||
onChange={handleThemeToggle}
|
data-testid="theme-toggle"
|
||||||
size="lg"
|
/>
|
||||||
onLabel={<IconMoon size={16} />}
|
</Group>
|
||||||
offLabel={<IconSun size={16} />}
|
</Box>
|
||||||
data-testid="theme-toggle"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ describe('useFileNavigation', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
name: 'test-workspace',
|
name: 'test-workspace',
|
||||||
};
|
};
|
||||||
|
// Default mock implementations
|
||||||
|
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(null);
|
||||||
|
mockLastOpenedFile.saveLastOpenedFile.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { vi } from 'vitest';
|
import { vi, beforeAll, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
// Suppress console errors during tests
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
beforeAll(() => {
|
||||||
|
console.error = (...args: any[]) => {
|
||||||
|
// Suppress specific expected errors during tests
|
||||||
|
const errorString = args.join(' ');
|
||||||
|
if (
|
||||||
|
errorString.includes('Failed to initialize auth') ||
|
||||||
|
errorString.includes('Failed to save last opened file') ||
|
||||||
|
errorString.includes('Failed to load last opened file')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalConsoleError(...args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
|
|
||||||
// Mock window.API_BASE_URL
|
// Mock window.API_BASE_URL
|
||||||
Object.defineProperty(window, 'API_BASE_URL', {
|
Object.defineProperty(window, 'API_BASE_URL', {
|
||||||
@@ -23,8 +44,8 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mock ResizeObserver - sometimes needed for Mantine components
|
// Mock ResizeObserver - sometimes needed for Mantine components
|
||||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
global.ResizeObserver = class ResizeObserver {
|
||||||
observe: vi.fn(),
|
observe = vi.fn();
|
||||||
unobserve: vi.fn(),
|
unobserve = vi.fn();
|
||||||
disconnect: vi.fn(),
|
disconnect = vi.fn();
|
||||||
}));
|
};
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ describe('Models Type Guards', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
role: UserRole.Editor,
|
role: UserRole.Editor,
|
||||||
|
theme: Theme.Dark,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
lastWorkspaceId: 1,
|
lastWorkspaceId: 1,
|
||||||
__proto__: { malicious: true },
|
__proto__: { malicious: true },
|
||||||
@@ -773,6 +774,7 @@ describe('Models Type Guards', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
role: UserRole.Editor,
|
role: UserRole.Editor,
|
||||||
|
theme: Theme.Dark,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
lastWorkspaceId: 1,
|
lastWorkspaceId: 1,
|
||||||
};
|
};
|
||||||
@@ -806,6 +808,7 @@ describe('Models Type Guards', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
role: UserRole.Editor,
|
role: UserRole.Editor,
|
||||||
|
theme: Theme.Dark,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
lastWorkspaceId: 1,
|
lastWorkspaceId: 1,
|
||||||
});
|
});
|
||||||
@@ -854,6 +857,7 @@ describe('Models Type Guards', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
email: longString,
|
email: longString,
|
||||||
role: UserRole.Editor,
|
role: UserRole.Editor,
|
||||||
|
theme: Theme.Dark,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
lastWorkspaceId: 1,
|
lastWorkspaceId: 1,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export function isUser(value: unknown): value is User {
|
|||||||
'role' in value &&
|
'role' in value &&
|
||||||
isUserRole((value as User).role) &&
|
isUserRole((value as User).role) &&
|
||||||
'theme' in value &&
|
'theme' in value &&
|
||||||
(value as User).theme in Theme &&
|
typeof (value as User).theme === 'string' &&
|
||||||
|
Object.values(Theme).includes((value as User).theme) &&
|
||||||
'createdAt' in value &&
|
'createdAt' in value &&
|
||||||
typeof (value as User).createdAt === 'string' &&
|
typeof (value as User).createdAt === 'string' &&
|
||||||
'lastWorkspaceId' in value &&
|
'lastWorkspaceId' in value &&
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { API_BASE_URL } from '@/types/api';
|
import { API_BASE_URL } from '@/types/api';
|
||||||
import { IMAGE_EXTENSIONS } from '@/types/models';
|
import { IMAGE_EXTENSIONS, type FileNode } from '@/types/models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a flattened file for searching and autocompletion
|
||||||
|
*/
|
||||||
|
export interface FlatFile {
|
||||||
|
name: string; // "meeting-notes.md"
|
||||||
|
path: string; // "work/2024/meeting-notes.md"
|
||||||
|
displayPath: string; // "work/2024/meeting-notes"
|
||||||
|
nameWithoutExt: string; // "meeting-notes"
|
||||||
|
parentFolder: string; // "work/2024"
|
||||||
|
isImage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the given file path has an image extension.
|
* Checks if the given file path has an image extension.
|
||||||
@@ -15,3 +27,65 @@ export const getFileUrl = (workspaceName: string, filePath: string) => {
|
|||||||
workspaceName
|
workspaceName
|
||||||
)}/files/content?file_path=${encodeURIComponent(filePath)}`;
|
)}/files/content?file_path=${encodeURIComponent(filePath)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively flattens FileNode tree into searchable array
|
||||||
|
* Precomputes display strings and metadata for performance
|
||||||
|
*
|
||||||
|
* @param nodes - Array of FileNode from the file tree
|
||||||
|
* @param showHiddenFiles - Whether to include hidden files (files/folders starting with .)
|
||||||
|
* @returns Array of FlatFile objects ready for searching
|
||||||
|
*/
|
||||||
|
export function flattenFileTree(nodes: FileNode[], showHiddenFiles = false): FlatFile[] {
|
||||||
|
const result: FlatFile[] = [];
|
||||||
|
|
||||||
|
function traverse(node: FileNode) {
|
||||||
|
// Skip hidden files and folders if showHiddenFiles is false
|
||||||
|
// Hidden files/folders are those that start with a dot (.)
|
||||||
|
if (!showHiddenFiles && node.name.startsWith('.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process files, not folders (folders have children)
|
||||||
|
if (!node.children) {
|
||||||
|
const name = node.name;
|
||||||
|
const path = node.path;
|
||||||
|
const isImage = isImageFile(path);
|
||||||
|
|
||||||
|
// Remove extension for display (except for images)
|
||||||
|
let nameWithoutExt = name;
|
||||||
|
let displayPath = path;
|
||||||
|
|
||||||
|
if (name.endsWith('.md')) {
|
||||||
|
nameWithoutExt = name.slice(0, -3);
|
||||||
|
displayPath = path.slice(0, -3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parent folder path
|
||||||
|
const lastSlashIndex = path.lastIndexOf('/');
|
||||||
|
const parentFolder = lastSlashIndex > 0 ? path.slice(0, lastSlashIndex) : '';
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
displayPath,
|
||||||
|
nameWithoutExt,
|
||||||
|
parentFolder,
|
||||||
|
isImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process children
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
traverse(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
traverse(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
44
app/src/utils/fileTreeUtils.ts
Normal file
44
app/src/utils/fileTreeUtils.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { FileNode } from '@/types/models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively filter tree to only include folders
|
||||||
|
* @param nodes - Array of FileNode objects
|
||||||
|
* @returns New tree structure with only folder nodes
|
||||||
|
*/
|
||||||
|
export const filterToFolders = (nodes: FileNode[]): FileNode[] => {
|
||||||
|
return nodes
|
||||||
|
.filter((node) => node.children !== undefined)
|
||||||
|
.map((node) => {
|
||||||
|
const filtered: FileNode = {
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
path: node.path,
|
||||||
|
};
|
||||||
|
if (node.children) {
|
||||||
|
filtered.children = filterToFolders(node.children);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific folder node by its path
|
||||||
|
* @param nodes - Array of FileNode objects
|
||||||
|
* @param path - Path to search for
|
||||||
|
* @returns The found FileNode or null
|
||||||
|
*/
|
||||||
|
export const findFolderByPath = (
|
||||||
|
nodes: FileNode[],
|
||||||
|
path: string
|
||||||
|
): FileNode | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.path === path && node.children !== undefined) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
const found = findFolderByPath(node.children, path);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
129
app/src/utils/fuzzyMatch.ts
Normal file
129
app/src/utils/fuzzyMatch.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Result of a fuzzy match operation
|
||||||
|
*/
|
||||||
|
export interface MatchResult {
|
||||||
|
matched: boolean;
|
||||||
|
score: number; // Higher is better
|
||||||
|
matchedIndices: number[]; // For highlighting
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoring weights for match quality
|
||||||
|
*/
|
||||||
|
const SCORING = {
|
||||||
|
consecutiveMatch: 15,
|
||||||
|
wordBoundaryMatch: 10,
|
||||||
|
camelCaseMatch: 10,
|
||||||
|
firstCharMatch: 15,
|
||||||
|
gapPenalty: -1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs fuzzy matching between query and target string
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* - Sequential character matching (order matters)
|
||||||
|
* - Bonus for consecutive matches
|
||||||
|
* - Bonus for word boundary matches
|
||||||
|
* - Bonus for camelCase matches
|
||||||
|
* - Case-insensitive by default
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* query: "mtno"
|
||||||
|
* target: "meeting-notes"
|
||||||
|
* → matches: [0, 4, 8, 9], score: 85
|
||||||
|
*
|
||||||
|
* @param query - The search string
|
||||||
|
* @param target - The string to search in
|
||||||
|
* @returns MatchResult with matched status, score, and matched indices
|
||||||
|
*/
|
||||||
|
export function fuzzyMatch(query: string, target: string): MatchResult {
|
||||||
|
if (!query) {
|
||||||
|
return { matched: true, score: 0, matchedIndices: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryLower = query.toLowerCase();
|
||||||
|
const targetLower = target.toLowerCase();
|
||||||
|
|
||||||
|
const matchedIndices: number[] = [];
|
||||||
|
let score = 0;
|
||||||
|
let queryIndex = 0;
|
||||||
|
let previousMatchIndex = -1;
|
||||||
|
|
||||||
|
// Try to match all query characters in order
|
||||||
|
for (let targetIndex = 0; targetIndex < targetLower.length; targetIndex++) {
|
||||||
|
if (queryIndex >= queryLower.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryLower[queryIndex] === targetLower[targetIndex]) {
|
||||||
|
matchedIndices.push(targetIndex);
|
||||||
|
|
||||||
|
// Bonus for first character match
|
||||||
|
if (targetIndex === 0) {
|
||||||
|
score += SCORING.firstCharMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for consecutive matches
|
||||||
|
if (previousMatchIndex === targetIndex - 1) {
|
||||||
|
score += SCORING.consecutiveMatch;
|
||||||
|
} else if (previousMatchIndex >= 0) {
|
||||||
|
// Penalty for gaps
|
||||||
|
const gap = targetIndex - previousMatchIndex - 1;
|
||||||
|
score += gap * SCORING.gapPenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for word boundary matches
|
||||||
|
if (isWordBoundary(target, targetIndex)) {
|
||||||
|
score += SCORING.wordBoundaryMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for camelCase matches
|
||||||
|
if (isCamelCaseMatch(target, targetIndex)) {
|
||||||
|
score += SCORING.camelCaseMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMatchIndex = targetIndex;
|
||||||
|
queryIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All query characters must be matched
|
||||||
|
const matched = queryIndex === queryLower.length;
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, score: 0, matchedIndices: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost score for matches with higher character coverage
|
||||||
|
const coverage = matchedIndices.length / target.length;
|
||||||
|
score += coverage * 50;
|
||||||
|
|
||||||
|
return { matched, score, matchedIndices };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a character at the given index is at a word boundary
|
||||||
|
* Word boundaries are: start of string, after space, after dash, after slash
|
||||||
|
*/
|
||||||
|
function isWordBoundary(str: string, index: number): boolean {
|
||||||
|
if (index === 0) return true;
|
||||||
|
const prevChar = str[index - 1];
|
||||||
|
return prevChar === ' ' || prevChar === '-' || prevChar === '/' || prevChar === '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a character at the given index is a camelCase boundary
|
||||||
|
* (lowercase followed by uppercase)
|
||||||
|
*/
|
||||||
|
function isCamelCaseMatch(str: string, index: number): boolean {
|
||||||
|
if (index === 0 || index >= str.length) return false;
|
||||||
|
const currentChar = str[index];
|
||||||
|
const prevChar = str[index - 1];
|
||||||
|
if (!currentChar || !prevChar) return false;
|
||||||
|
return (
|
||||||
|
currentChar === currentChar.toUpperCase() &&
|
||||||
|
currentChar !== currentChar.toLowerCase() &&
|
||||||
|
prevChar === prevChar.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -240,17 +240,13 @@ export function remarkWikiLinks(workspaceName: string) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookupFileName: string = match.isImage
|
// If the filename contains a path separator, treat it as a full path
|
||||||
? match.fileName
|
// This handles wikilinks with paths like [[folder/subfolder/file]]
|
||||||
: addMarkdownExtension(match.fileName);
|
let filePath: string;
|
||||||
|
if (match.fileName.includes('/')) {
|
||||||
|
// It's already a full path - use it directly
|
||||||
|
filePath = match.isImage ? match.fileName : addMarkdownExtension(match.fileName);
|
||||||
|
|
||||||
const paths: string[] = await lookupFileByName(
|
|
||||||
workspaceName,
|
|
||||||
lookupFileName
|
|
||||||
);
|
|
||||||
|
|
||||||
if (paths && paths.length > 0 && paths[0]) {
|
|
||||||
const filePath: string = paths[0];
|
|
||||||
if (match.isImage) {
|
if (match.isImage) {
|
||||||
newNodes.push(
|
newNodes.push(
|
||||||
createImageNode(workspaceName, filePath, match.displayText)
|
createImageNode(workspaceName, filePath, match.displayText)
|
||||||
@@ -266,9 +262,37 @@ export function remarkWikiLinks(workspaceName: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newNodes.push(
|
// It's just a filename - look it up to find the full path
|
||||||
createNotFoundLink(match.fileName, match.displayText, baseUrl)
|
const lookupFileName: string = match.isImage
|
||||||
|
? match.fileName
|
||||||
|
: addMarkdownExtension(match.fileName);
|
||||||
|
|
||||||
|
const paths: string[] = await lookupFileByName(
|
||||||
|
workspaceName,
|
||||||
|
lookupFileName
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (paths && paths.length > 0 && paths[0]) {
|
||||||
|
filePath = paths[0];
|
||||||
|
if (match.isImage) {
|
||||||
|
newNodes.push(
|
||||||
|
createImageNode(workspaceName, filePath, match.displayText)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newNodes.push(
|
||||||
|
createFileLink(
|
||||||
|
filePath,
|
||||||
|
match.displayText,
|
||||||
|
match.heading,
|
||||||
|
baseUrl
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newNodes.push(
|
||||||
|
createNotFoundLink(match.fileName, match.displayText, baseUrl)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug('File lookup failed:', match.fileName, error);
|
console.debug('File lookup failed:', match.fileName, error);
|
||||||
|
|||||||
239
app/src/utils/wikiLinkCompletion.ts
Normal file
239
app/src/utils/wikiLinkCompletion.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import type {
|
||||||
|
CompletionContext,
|
||||||
|
CompletionResult,
|
||||||
|
Completion,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
|
import type { FlatFile } from './fileHelpers';
|
||||||
|
import { fuzzyMatch } from './fuzzyMatch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiki link context detection result
|
||||||
|
*/
|
||||||
|
interface WikiLinkContext {
|
||||||
|
isWikiLink: boolean;
|
||||||
|
isImage: boolean; // true if ![[
|
||||||
|
query: string; // partial text after [[
|
||||||
|
from: number; // cursor position to replace from
|
||||||
|
to: number; // cursor position to replace to
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates CodeMirror autocompletion source for wiki links
|
||||||
|
*
|
||||||
|
* @param files - Flattened file list from workspace
|
||||||
|
* @returns CompletionSource function
|
||||||
|
*/
|
||||||
|
export function createWikiLinkCompletions(
|
||||||
|
files: FlatFile[]
|
||||||
|
): (context: CompletionContext) => CompletionResult | null {
|
||||||
|
return (context: CompletionContext): CompletionResult | null => {
|
||||||
|
const wikiContext = detectWikiLinkContext(context);
|
||||||
|
|
||||||
|
if (!wikiContext.isWikiLink) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and rank files based on query
|
||||||
|
const rankedFiles = filterAndRankFiles(
|
||||||
|
wikiContext.query,
|
||||||
|
files,
|
||||||
|
wikiContext.isImage,
|
||||||
|
50
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rankedFiles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to completion options
|
||||||
|
const options = rankedFiles.map((file) => formatCompletion(file));
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: wikiContext.from,
|
||||||
|
to: wikiContext.to,
|
||||||
|
options,
|
||||||
|
// Don't set filter or validFor - let CodeMirror re-trigger our completion
|
||||||
|
// source on every keystroke so we can re-filter with fuzzy matching
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if cursor is inside a wiki link and extracts context
|
||||||
|
*
|
||||||
|
* Detection logic:
|
||||||
|
* 1. Search backwards from cursor for [[ or ![[
|
||||||
|
* 2. Ensure no closing ]] between opener and cursor
|
||||||
|
* 3. Extract partial query (text after [[ and before cursor)
|
||||||
|
* 4. Determine if image link (![[) or regular ([[)
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* "[[meeti|ng" → { isWikiLink: true, query: "meeti", ... }
|
||||||
|
* "![[img|" → { isWikiLink: true, isImage: true, query: "img", ... }
|
||||||
|
* "regular text|" → { isWikiLink: false }
|
||||||
|
*/
|
||||||
|
function detectWikiLinkContext(context: CompletionContext): WikiLinkContext {
|
||||||
|
const { state, pos } = context;
|
||||||
|
const line = state.doc.lineAt(pos);
|
||||||
|
const textBefore = state.sliceDoc(line.from, pos);
|
||||||
|
|
||||||
|
// Look for [[ or ![[
|
||||||
|
const imageWikiLinkMatch = textBefore.lastIndexOf('![[');
|
||||||
|
const regularWikiLinkMatch = textBefore.lastIndexOf('[[');
|
||||||
|
|
||||||
|
// Determine which one is closer to cursor
|
||||||
|
let isImage = false;
|
||||||
|
let openerIndex = -1;
|
||||||
|
|
||||||
|
if (imageWikiLinkMatch > regularWikiLinkMatch) {
|
||||||
|
isImage = true;
|
||||||
|
openerIndex = imageWikiLinkMatch;
|
||||||
|
} else if (regularWikiLinkMatch >= 0) {
|
||||||
|
openerIndex = regularWikiLinkMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no opener found, not in a wiki link
|
||||||
|
if (openerIndex < 0) {
|
||||||
|
return {
|
||||||
|
isWikiLink: false,
|
||||||
|
isImage: false,
|
||||||
|
query: '',
|
||||||
|
from: pos,
|
||||||
|
to: pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the absolute position of the opener in the document
|
||||||
|
const openerPos = line.from + openerIndex;
|
||||||
|
const openerLength = isImage ? 3 : 2; // ![[ or [[
|
||||||
|
const queryStartPos = openerPos + openerLength;
|
||||||
|
|
||||||
|
// Check if there's a closing ]] between opener and cursor
|
||||||
|
const textAfterOpener = textBefore.slice(openerIndex);
|
||||||
|
const closingIndex = textAfterOpener.indexOf(']]');
|
||||||
|
|
||||||
|
if (closingIndex >= 0 && closingIndex < textAfterOpener.length - 2) {
|
||||||
|
// Found ]] before cursor, so we're not inside a wiki link
|
||||||
|
return {
|
||||||
|
isWikiLink: false,
|
||||||
|
isImage: false,
|
||||||
|
query: '',
|
||||||
|
from: pos,
|
||||||
|
to: pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the query (text between [[ and cursor)
|
||||||
|
const query = state.sliceDoc(queryStartPos, pos);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWikiLink: true,
|
||||||
|
isImage,
|
||||||
|
query,
|
||||||
|
from: queryStartPos,
|
||||||
|
to: pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters files and ranks by relevance
|
||||||
|
*
|
||||||
|
* Ranking priority:
|
||||||
|
* 1. File type match (images for ![[, markdown for [[)
|
||||||
|
* 2. Fuzzy match score
|
||||||
|
* 3. Exact filename match > path component match
|
||||||
|
* 4. Shorter paths (prefer root over deeply nested)
|
||||||
|
* 5. Alphabetical for ties
|
||||||
|
*
|
||||||
|
* @param query - User's partial input
|
||||||
|
* @param files - All available files
|
||||||
|
* @param isImage - Whether to filter for images
|
||||||
|
* @param maxResults - Limit returned results (default: 50)
|
||||||
|
*/
|
||||||
|
function filterAndRankFiles(
|
||||||
|
query: string,
|
||||||
|
files: FlatFile[],
|
||||||
|
isImage: boolean,
|
||||||
|
maxResults = 50
|
||||||
|
): FlatFile[] {
|
||||||
|
// If query is empty, show all matching file types
|
||||||
|
if (!query) {
|
||||||
|
const filtered = files.filter((f) => f.isImage === isImage);
|
||||||
|
return filtered.slice(0, maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoredFile {
|
||||||
|
file: FlatFile;
|
||||||
|
score: number;
|
||||||
|
nameScore: number;
|
||||||
|
pathScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scored: ScoredFile[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Filter by file type
|
||||||
|
if (file.isImage !== isImage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try matching against different fields
|
||||||
|
const nameMatch = fuzzyMatch(query, file.nameWithoutExt);
|
||||||
|
const pathMatch = fuzzyMatch(query, file.displayPath);
|
||||||
|
|
||||||
|
// Use the best match
|
||||||
|
if (nameMatch.matched || pathMatch.matched) {
|
||||||
|
// Prefer name matches over path matches
|
||||||
|
const nameScore = nameMatch.matched ? nameMatch.score : 0;
|
||||||
|
const pathScore = pathMatch.matched ? pathMatch.score : 0;
|
||||||
|
|
||||||
|
// Name matches get higher priority
|
||||||
|
const totalScore = nameScore * 2 + pathScore;
|
||||||
|
|
||||||
|
// Penalize deeply nested files slightly
|
||||||
|
const depth = file.path.split('/').length;
|
||||||
|
const depthPenalty = depth * 0.5;
|
||||||
|
|
||||||
|
scored.push({
|
||||||
|
file,
|
||||||
|
score: totalScore - depthPenalty,
|
||||||
|
nameScore,
|
||||||
|
pathScore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (descending), then alphabetically
|
||||||
|
scored.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) {
|
||||||
|
return b.score - a.score;
|
||||||
|
}
|
||||||
|
return a.file.displayPath.localeCompare(b.file.displayPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return top results
|
||||||
|
return scored.slice(0, maxResults).map((s) => s.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts FlatFile to CodeMirror Completion object
|
||||||
|
*
|
||||||
|
* Format rules:
|
||||||
|
* - Markdown files: show path without .md extension
|
||||||
|
* - Images: show full path with extension
|
||||||
|
* - Display includes path relative to workspace root
|
||||||
|
* - Apply text: full path (no extension for .md)
|
||||||
|
*
|
||||||
|
* Example outputs:
|
||||||
|
* work/2024/meeting-notes (for .md)
|
||||||
|
* assets/screenshot.png (for image)
|
||||||
|
*/
|
||||||
|
function formatCompletion(file: FlatFile): Completion {
|
||||||
|
return {
|
||||||
|
label: file.displayPath,
|
||||||
|
apply: file.displayPath,
|
||||||
|
type: file.isImage ? 'image' : 'file',
|
||||||
|
detail: file.parentFolder || '/',
|
||||||
|
boost: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ export default defineConfig(({ mode }) => ({
|
|||||||
react({
|
react({
|
||||||
include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
|
include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
|
||||||
}),
|
}),
|
||||||
compression(),
|
compression({
|
||||||
|
threshold: 1024, // Only compress files > 1KB
|
||||||
|
deleteOriginalAssets: false, // Keep original files
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
root: 'src',
|
root: 'src',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vitest/config';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func TestSessionOperations(t *testing.T) {
|
|||||||
DisplayName: "Test User",
|
DisplayName: "Test User",
|
||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Role: "editor",
|
Role: "editor",
|
||||||
|
Theme: "dark",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func TestStructQueries(t *testing.T) {
|
|||||||
DisplayName: "Struct Query Test",
|
DisplayName: "Struct Query Test",
|
||||||
PasswordHash: "hashed_password",
|
PasswordHash: "hashed_password",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("InsertStructQuery", func(t *testing.T) {
|
t.Run("InsertStructQuery", func(t *testing.T) {
|
||||||
@@ -243,6 +244,7 @@ func TestStructQueries(t *testing.T) {
|
|||||||
DisplayName: "Struct Query Test 2",
|
DisplayName: "Struct Query Test 2",
|
||||||
PasswordHash: "hashed_password2",
|
PasswordHash: "hashed_password2",
|
||||||
Role: models.RoleViewer,
|
Role: models.RoleViewer,
|
||||||
|
Theme: "light",
|
||||||
}
|
}
|
||||||
|
|
||||||
createdUser2, err := database.CreateUser(secondUser)
|
createdUser2, err := database.CreateUser(secondUser)
|
||||||
@@ -437,6 +439,7 @@ func TestEncryptedFields(t *testing.T) {
|
|||||||
DisplayName: "Encryption Test",
|
DisplayName: "Encryption Test",
|
||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create test user: %v", err)
|
t.Fatalf("Failed to create test user: %v", err)
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ func TestSystemOperations(t *testing.T) {
|
|||||||
DisplayName: "User 1",
|
DisplayName: "User 1",
|
||||||
PasswordHash: "hash1",
|
PasswordHash: "hash1",
|
||||||
Role: "editor",
|
Role: "editor",
|
||||||
|
Theme: "dark",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Email: "user2@test.com",
|
Email: "user2@test.com",
|
||||||
DisplayName: "User 2",
|
DisplayName: "User 2",
|
||||||
PasswordHash: "hash2",
|
PasswordHash: "hash2",
|
||||||
Role: "viewer",
|
Role: "viewer",
|
||||||
|
Theme: "light",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "Test User",
|
DisplayName: "Test User",
|
||||||
PasswordHash: "hashed_password",
|
PasswordHash: "hashed_password",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
@@ -44,6 +45,7 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "Another User",
|
DisplayName: "Another User",
|
||||||
PasswordHash: "different_hash",
|
PasswordHash: "different_hash",
|
||||||
Role: models.RoleViewer,
|
Role: models.RoleViewer,
|
||||||
|
Theme: "light",
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errContains: "UNIQUE constraint failed",
|
errContains: "UNIQUE constraint failed",
|
||||||
@@ -108,6 +110,7 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "Get By ID User",
|
DisplayName: "Get By ID User",
|
||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
@@ -159,6 +162,7 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "Get By Email User",
|
DisplayName: "Get By Email User",
|
||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
@@ -210,6 +214,7 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "Original Name",
|
DisplayName: "Original Name",
|
||||||
PasswordHash: "original_hash",
|
PasswordHash: "original_hash",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
@@ -249,12 +254,14 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "User One",
|
DisplayName: "User One",
|
||||||
PasswordHash: "hash1",
|
PasswordHash: "hash1",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Email: "user2@example.com",
|
Email: "user2@example.com",
|
||||||
DisplayName: "User Two",
|
DisplayName: "User Two",
|
||||||
PasswordHash: "hash2",
|
PasswordHash: "hash2",
|
||||||
Role: models.RoleViewer,
|
Role: models.RoleViewer,
|
||||||
|
Theme: "light",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +312,7 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "Workspace User",
|
DisplayName: "Workspace User",
|
||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
@@ -343,6 +351,7 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "Delete User",
|
DisplayName: "Delete User",
|
||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
@@ -377,18 +386,21 @@ func TestUserOperations(t *testing.T) {
|
|||||||
DisplayName: "Admin One",
|
DisplayName: "Admin One",
|
||||||
PasswordHash: "hash1",
|
PasswordHash: "hash1",
|
||||||
Role: models.RoleAdmin,
|
Role: models.RoleAdmin,
|
||||||
|
Theme: "dark",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Email: "admin2@example.com",
|
Email: "admin2@example.com",
|
||||||
DisplayName: "Admin Two",
|
DisplayName: "Admin Two",
|
||||||
PasswordHash: "hash2",
|
PasswordHash: "hash2",
|
||||||
Role: models.RoleAdmin,
|
Role: models.RoleAdmin,
|
||||||
|
Theme: "light",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Email: "editor@example.com",
|
Email: "editor@example.com",
|
||||||
DisplayName: "Editor",
|
DisplayName: "Editor",
|
||||||
PasswordHash: "hash3",
|
PasswordHash: "hash3",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func TestWorkspaceOperations(t *testing.T) {
|
|||||||
DisplayName: "Test User",
|
DisplayName: "Test User",
|
||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Role: models.RoleEditor,
|
Role: models.RoleEditor,
|
||||||
|
Theme: "dark",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ func (h *testHarness) createTestUser(t *testing.T, email, password string, role
|
|||||||
DisplayName: "Test User",
|
DisplayName: "Test User",
|
||||||
PasswordHash: string(hashedPassword),
|
PasswordHash: string(hashedPassword),
|
||||||
Role: role,
|
Role: role,
|
||||||
|
Theme: "dark",
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err = h.DB.CreateUser(user)
|
user, err = h.DB.CreateUser(user)
|
||||||
|
|||||||
@@ -24,6 +24,28 @@ func getStaticLogger() logging.Logger {
|
|||||||
return logging.WithGroup("static")
|
return logging.WithGroup("static")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getContentType returns the appropriate content type based on file extension
|
||||||
|
func getContentType(path string) string {
|
||||||
|
switch filepath.Ext(path) {
|
||||||
|
case ".js":
|
||||||
|
return "application/javascript"
|
||||||
|
case ".css":
|
||||||
|
return "text/css"
|
||||||
|
case ".html":
|
||||||
|
return "text/html"
|
||||||
|
case ".json":
|
||||||
|
return "application/json"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".xml":
|
||||||
|
return "application/xml"
|
||||||
|
case ".yaml", ".yml":
|
||||||
|
return "application/x-yaml"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ServeHTTP serves the static files
|
// ServeHTTP serves the static files
|
||||||
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
log := getStaticLogger().With(
|
log := getStaticLogger().With(
|
||||||
@@ -77,23 +99,28 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for pre-compressed version
|
// Check for pre-compressed versions (prefer brotli over gzip)
|
||||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
acceptEncoding := r.Header.Get("Accept-Encoding")
|
||||||
|
|
||||||
|
// Try brotli first (better compression ratio)
|
||||||
|
if strings.Contains(acceptEncoding, "br") {
|
||||||
|
brPath := cleanPath + ".br"
|
||||||
|
if _, err := os.Stat(brPath); err == nil {
|
||||||
|
w.Header().Set("Content-Encoding", "br")
|
||||||
|
w.Header().Set("Content-Type", getContentType(cleanPath))
|
||||||
|
w.Header().Set("Vary", "Accept-Encoding")
|
||||||
|
http.ServeFile(w, r, brPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to gzip
|
||||||
|
if strings.Contains(acceptEncoding, "gzip") {
|
||||||
gzPath := cleanPath + ".gz"
|
gzPath := cleanPath + ".gz"
|
||||||
if _, err := os.Stat(gzPath); err == nil {
|
if _, err := os.Stat(gzPath); err == nil {
|
||||||
w.Header().Set("Content-Encoding", "gzip")
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
w.Header().Set("Content-Type", getContentType(cleanPath))
|
||||||
// Set proper content type based on original file
|
w.Header().Set("Vary", "Accept-Encoding")
|
||||||
contentType := "application/octet-stream"
|
|
||||||
switch filepath.Ext(cleanPath) {
|
|
||||||
case ".js":
|
|
||||||
contentType = "application/javascript"
|
|
||||||
case ".css":
|
|
||||||
contentType = "text/css"
|
|
||||||
case ".html":
|
|
||||||
contentType = "text/html"
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
|
||||||
http.ServeFile(w, r, gzPath)
|
http.ServeFile(w, r, gzPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ func TestStaticHandler_Integration(t *testing.T) {
|
|||||||
"index.html": []byte("<html><body>Index</body></html>"),
|
"index.html": []byte("<html><body>Index</body></html>"),
|
||||||
"assets/style.css": []byte("body { color: blue; }"),
|
"assets/style.css": []byte("body { color: blue; }"),
|
||||||
"assets/style.css.gz": []byte("gzipped css content"),
|
"assets/style.css.gz": []byte("gzipped css content"),
|
||||||
|
"assets/style.css.br": []byte("brotli css content"),
|
||||||
"assets/script.js": []byte("console.log('test');"),
|
"assets/script.js": []byte("console.log('test');"),
|
||||||
"assets/script.js.gz": []byte("gzipped js content"),
|
"assets/script.js.gz": []byte("gzipped js content"),
|
||||||
|
"assets/script.js.br": []byte("brotli js content"),
|
||||||
|
"assets/app.js": []byte("console.log('app');"),
|
||||||
|
"assets/app.js.br": []byte("brotli app content"),
|
||||||
"subdir/page.html": []byte("<html><body>Page</body></html>"),
|
"subdir/page.html": []byte("<html><body>Page</body></html>"),
|
||||||
"subdir/page.html.gz": []byte("gzipped html content"),
|
"subdir/page.html.gz": []byte("gzipped html content"),
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,7 @@ func TestStaticHandler_Integration(t *testing.T) {
|
|||||||
wantType string
|
wantType string
|
||||||
wantEncoding string
|
wantEncoding string
|
||||||
wantCacheHeader string
|
wantCacheHeader string
|
||||||
|
wantVary string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "serve index.html",
|
name: "serve index.html",
|
||||||
@@ -69,6 +74,7 @@ func TestStaticHandler_Integration(t *testing.T) {
|
|||||||
wantType: "text/css",
|
wantType: "text/css",
|
||||||
wantEncoding: "gzip",
|
wantEncoding: "gzip",
|
||||||
wantCacheHeader: "public, max-age=31536000",
|
wantCacheHeader: "public, max-age=31536000",
|
||||||
|
wantVary: "Accept-Encoding",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "serve JS with gzip support",
|
name: "serve JS with gzip support",
|
||||||
@@ -79,6 +85,7 @@ func TestStaticHandler_Integration(t *testing.T) {
|
|||||||
wantType: "application/javascript",
|
wantType: "application/javascript",
|
||||||
wantEncoding: "gzip",
|
wantEncoding: "gzip",
|
||||||
wantCacheHeader: "public, max-age=31536000",
|
wantCacheHeader: "public, max-age=31536000",
|
||||||
|
wantVary: "Accept-Encoding",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "serve CSS without gzip",
|
name: "serve CSS without gzip",
|
||||||
@@ -114,6 +121,50 @@ func TestStaticHandler_Integration(t *testing.T) {
|
|||||||
wantBody: []byte("<html><body>Index</body></html>"),
|
wantBody: []byte("<html><body>Index</body></html>"),
|
||||||
wantType: "text/html; charset=utf-8",
|
wantType: "text/html; charset=utf-8",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "serve CSS with brotli support",
|
||||||
|
path: "/assets/style.css",
|
||||||
|
acceptEncoding: "br",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: []byte("brotli css content"),
|
||||||
|
wantType: "text/css",
|
||||||
|
wantEncoding: "br",
|
||||||
|
wantCacheHeader: "public, max-age=31536000",
|
||||||
|
wantVary: "Accept-Encoding",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "serve JS with brotli support",
|
||||||
|
path: "/assets/script.js",
|
||||||
|
acceptEncoding: "br",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: []byte("brotli js content"),
|
||||||
|
wantType: "application/javascript",
|
||||||
|
wantEncoding: "br",
|
||||||
|
wantCacheHeader: "public, max-age=31536000",
|
||||||
|
wantVary: "Accept-Encoding",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefer brotli over gzip when both supported",
|
||||||
|
path: "/assets/script.js",
|
||||||
|
acceptEncoding: "gzip, br",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: []byte("brotli js content"),
|
||||||
|
wantType: "application/javascript",
|
||||||
|
wantEncoding: "br",
|
||||||
|
wantCacheHeader: "public, max-age=31536000",
|
||||||
|
wantVary: "Accept-Encoding",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback to gzip when brotli not available",
|
||||||
|
path: "/assets/app.js",
|
||||||
|
acceptEncoding: "gzip, br",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: []byte("brotli app content"),
|
||||||
|
wantType: "application/javascript",
|
||||||
|
wantEncoding: "br",
|
||||||
|
wantCacheHeader: "public, max-age=31536000",
|
||||||
|
wantVary: "Accept-Encoding",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -139,6 +190,10 @@ func TestStaticHandler_Integration(t *testing.T) {
|
|||||||
if tc.wantCacheHeader != "" {
|
if tc.wantCacheHeader != "" {
|
||||||
assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control"))
|
assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tc.wantVary != "" {
|
||||||
|
assert.Equal(t, tc.wantVary, w.Header().Get("Vary"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user