8 Commits

18 changed files with 1003 additions and 141 deletions

5
.gitignore vendored
View File

@@ -164,4 +164,7 @@ main
data
# Feature specifications
spec.md
spec.md
# Go debug files
__debug_bin*

View File

@@ -6,13 +6,17 @@ Yet another markdown editor. Work in progress
## Features
- Markdown editing with syntax highlighting
- File tree navigation
- Git integration for version control
- Dark and light theme support
- Multiple workspaces
- Math equation support (MathJax)
- Code syntax highlighting
- **Editing & Content**
- **Rich Markdown Editing** - Full-featured editor with syntax highlighting and live preview
- **Wikilinks Support** - Create interconnected notes with `[[wikilink]]` syntax and smart autocomplete
- **Math Equations** - Render beautiful mathematical expressions with MathJax support
- **Code Highlighting** - Syntax highlighting for code blocks in multiple languages
- **Organization & Workflow**
- **File Tree Navigation** - Organized folder structure with intuitive file management
- **Multi-Workspace** - Manage multiple projects and note collections in one place
- **Git Integration** - Built-in version control to track changes and collaborate safely
- **Customization**
- **Theme Flexibility** - Switch between dark and light modes to match your preference
## Prerequisites
@@ -22,33 +26,32 @@ Yet another markdown editor. Work in progress
## Configuration
Lemma can be configured using environment variables. Here are the available configuration options:
Lemma is configured using environment variables.
### Required Environment Variables
### Environment Variables
- `LEMMA_ADMIN_EMAIL`: Email address for the admin account
- `LEMMA_ADMIN_PASSWORD`: Password for the admin account
### Optional Environment Variables
- `LEMMA_ENV`: Set to "development" to enable development mode
- `LEMMA_DB_URL`: URL (Connection string) to the database. Supported databases are sqlite and postgres a (default: "./lemma.db")
- `LEMMA_WORKDIR`: Working directory for application data (default: "sqlite://lemma.db")
- `LEMMA_STATIC_PATH`: Path to static files (default: "../app/dist")
- `LEMMA_PORT`: Port to run the server on (default: "8080")
- `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication
- `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
- `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/encryption_key`
- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/jwt_signing_key`
- `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production)
- `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100)
- `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
| Variable | Required | Default | Description |
| --------------------------- | -------- | ------------------- | -------------------------------------------------------------------------------------------------------- |
| `LEMMA_ADMIN_EMAIL` | Yes | - | Email address for the admin account |
| `LEMMA_ADMIN_PASSWORD` | Yes | - | Password for the admin account |
| `LEMMA_ENV` | No | production | Set to "development" to enable development mode |
| `LEMMA_DB_URL` | No | `sqlite://lemma.db` | Database connection string (supports `sqlite://`, `sqlite3://`, `postgres://`, `postgresql://` prefixes) |
| `LEMMA_WORKDIR` | No | `./data` | Working directory for application data |
| `LEMMA_STATIC_PATH` | No | `../app/dist` | Path to static files |
| `LEMMA_PORT` | No | `8080` | Port to run the server on |
| `LEMMA_DOMAIN` | No | - | Domain name for cookie authentication |
| `LEMMA_CORS_ORIGINS` | No | - | Comma-separated list of allowed CORS origins |
| `LEMMA_ENCRYPTION_KEY` | No | auto-generated | Base64-encoded 32-byte key for encrypting sensitive data |
| `LEMMA_JWT_SIGNING_KEY` | No | auto-generated | Key used for signing JWT tokens |
| `LEMMA_LOG_LEVEL` | No | DEBUG/INFO\* | Logging level (\*DEBUG in dev, INFO in production) |
| `LEMMA_RATE_LIMIT_REQUESTS` | No | `100` | Number of allowed requests per window |
| `LEMMA_RATE_LIMIT_WINDOW` | No | `15m` | Duration of the rate limit window |
### Security Keys
Both the encryption key and JWT signing key are automatically generated on first startup if not provided via environment variables. The keys are stored in `{LEMMA_WORKDIR}/secrets/` with restrictive file permissions (0600).
Security keys (`LEMMA_ENCRYPTION_KEY` and `LEMMA_JWT_SIGNING_KEY`) are automatically generated on first startup if not provided. Keys are stored in `{LEMMA_WORKDIR}/secrets/`.
**Important**: Back up the `secrets` directory! If these keys are lost, encrypted data will become inaccessible and all users will need to re-authenticate.
**Important:** Back up the `secrets` directory!
## Running the backend server

103
app/package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/autocomplete": "^6.19.1",
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.5.2",
@@ -396,6 +397,18 @@
"node": ">=18"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz",
"integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
@@ -421,24 +434,6 @@
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-css/node_modules/@codemirror/autocomplete": {
"version": "6.18.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz",
"integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
@@ -456,24 +451,6 @@
"@lezer/html": "^1.3.0"
}
},
"node_modules/@codemirror/lang-html/node_modules/@codemirror/autocomplete": {
"version": "6.18.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz",
"integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
@@ -489,24 +466,6 @@
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript/node_modules/@codemirror/autocomplete": {
"version": "6.18.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz",
"integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript/node_modules/@codemirror/lint": {
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz",
@@ -533,24 +492,6 @@
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown/node_modules/@codemirror/autocomplete": {
"version": "6.18.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz",
"integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz",
@@ -3647,24 +3588,6 @@
"@codemirror/view": "^6.0.0"
}
},
"node_modules/codemirror/node_modules/@codemirror/autocomplete": {
"version": "6.18.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz",
"integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/codemirror/node_modules/@codemirror/lint": {
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz",

View File

@@ -29,6 +29,7 @@
},
"homepage": "https://github.com/LordMathis/Lemma#readme",
"dependencies": {
"@codemirror/autocomplete": "^6.19.1",
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.5.2",

View File

@@ -104,6 +104,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect}
files={[]}
/>
</TestWrapper>
);
@@ -121,6 +122,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect}
files={[]}
/>
</TestWrapper>
);
@@ -138,6 +140,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect}
files={[]}
/>
</TestWrapper>
);
@@ -157,6 +160,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect}
files={[]}
/>
</TestWrapper>
);
@@ -179,6 +183,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect}
files={[]}
/>
</TestWrapper>
);
@@ -208,6 +213,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect}
files={[]}
/>
</TestWrapper>
);

View File

@@ -4,6 +4,7 @@ import Editor from './Editor';
import MarkdownPreview from './MarkdownPreview';
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
import { useWorkspace } from '@/contexts/WorkspaceContext';
import type { FileNode } from '../../types/models';
type ViewTab = 'source' | 'preview';
@@ -14,6 +15,7 @@ interface ContentViewProps {
handleContentChange: (content: string) => void;
handleSave: (filePath: string, content: string) => Promise<boolean>;
handleFileSelect: (filePath: string | null) => Promise<void>;
files: FileNode[];
}
const ContentView: React.FC<ContentViewProps> = ({
@@ -23,6 +25,7 @@ const ContentView: React.FC<ContentViewProps> = ({
handleContentChange,
handleSave,
handleFileSelect,
files,
}) => {
const { currentWorkspace } = useWorkspace();
if (!currentWorkspace) {
@@ -67,6 +70,7 @@ const ContentView: React.FC<ContentViewProps> = ({
handleContentChange={handleContentChange}
handleSave={handleSave}
selectedFile={selectedFile}
files={files}
/>
) : (
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />

View File

@@ -1,17 +1,22 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useMemo } from 'react';
import { basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark';
import { autocompletion } from '@codemirror/autocomplete';
import { useWorkspace } from '../../hooks/useWorkspace';
import { createWikiLinkCompletions } from '../../utils/wikiLinkCompletion';
import { flattenFileTree } from '../../utils/fileHelpers';
import type { FileNode } from '../../types/models';
interface EditorProps {
content: string;
handleContentChange: (content: string) => void;
handleSave: (filePath: string, content: string) => Promise<boolean>;
selectedFile: string;
files: FileNode[];
}
const Editor: React.FC<EditorProps> = ({
@@ -19,11 +24,19 @@ const Editor: React.FC<EditorProps> = ({
handleContentChange,
handleSave,
selectedFile,
files,
}) => {
const { colorScheme } = useWorkspace();
const { colorScheme, currentWorkspace } = useWorkspace();
const editorRef = useRef<HTMLDivElement>(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(() => {
const handleEditorSave = (view: EditorView): boolean => {
void handleSave(selectedFile, view.state.doc.toString());
@@ -71,6 +84,12 @@ const Editor: React.FC<EditorProps> = ({
}),
theme,
colorScheme === 'dark' ? oneDark : [],
autocompletion({
override: [createWikiLinkCompletions(flatFiles)],
activateOnTyping: true,
maxRenderedOptions: 10,
closeOnBlur: true,
}),
],
});
@@ -87,7 +106,7 @@ const Editor: React.FC<EditorProps> = ({
};
// TODO: Refactor
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colorScheme, handleContentChange, handleSave, selectedFile]);
}, [colorScheme, handleContentChange, handleSave, selectedFile, flatFiles]);
useEffect(() => {
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {

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

View File

@@ -53,6 +53,7 @@ const Layout: React.FC = () => {
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
loadFileList={loadFileList}
files={files}
/>
</Container>
</AppShell.Main>

View File

@@ -131,6 +131,7 @@ describe('MainContent', () => {
selectedFile="docs/guide.md"
handleFileSelect={mockHandleFileSelect}
loadFileList={mockLoadFileList}
files={[]}
/>
</TestWrapper>
);
@@ -156,6 +157,7 @@ describe('MainContent', () => {
selectedFile="test.md"
handleFileSelect={mockHandleFileSelect}
loadFileList={mockLoadFileList}
files={[]}
/>
</TestWrapper>
);
@@ -172,6 +174,7 @@ describe('MainContent', () => {
selectedFile="test.md"
handleFileSelect={mockHandleFileSelect}
loadFileList={mockLoadFileList}
files={[]}
/>
</TestWrapper>
);
@@ -188,6 +191,7 @@ describe('MainContent', () => {
selectedFile={null}
handleFileSelect={mockHandleFileSelect}
loadFileList={mockLoadFileList}
files={[]}
/>
</TestWrapper>
);

View File

@@ -12,6 +12,7 @@ import { useFileContent } from '../../hooks/useFileContent';
import { useFileOperations } from '../../hooks/useFileOperations';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useModalContext } from '../../contexts/ModalContext';
import type { FileNode } from '../../types/models';
type ViewTab = 'source' | 'preview';
@@ -19,12 +20,14 @@ interface MainContentProps {
selectedFile: string | null;
handleFileSelect: (filePath: string | null) => Promise<void>;
loadFileList: () => Promise<void>;
files: FileNode[];
}
const MainContent: React.FC<MainContentProps> = ({
selectedFile,
handleFileSelect,
loadFileList,
files,
}) => {
const [activeTab, setActiveTab] = useState<ViewTab>('source');
const {
@@ -161,6 +164,7 @@ const MainContent: React.FC<MainContentProps> = ({
handleContentChange={handleContentChange}
handleSave={handleSaveFile}
handleFileSelect={handleFileSelect}
files={files}
/>
</Box>
<CreateFileModal onCreateFile={handleCreateFile} />

View File

@@ -29,6 +29,31 @@ vi.mock('../../../contexts/ModalContext', () => ({
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
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
@@ -47,6 +72,8 @@ describe('CreateFileModal', () => {
mockOnCreateFile.mockReset();
mockOnCreateFile.mockResolvedValue(undefined);
mockModalContext.setNewFileModalVisible.mockClear();
mockLoadFileList.mockClear();
mockLoadFileList.mockResolvedValue(undefined);
});
describe('Modal Visibility and Content', () => {

View File

@@ -1,6 +1,9 @@
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 { useFileList } from '../../../hooks/useFileList';
import { FolderSelector } from '../../files/FolderSelector';
interface CreateFileModalProps {
onCreateFile: (fileName: string) => Promise<void>;
@@ -8,16 +11,49 @@ interface CreateFileModalProps {
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
const [fileName, setFileName] = useState<string>('');
const [selectedFolder, setSelectedFolder] = useState<string>('');
const [popoverOpened, setPopoverOpened] = useState<boolean>(false);
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
const { files, loadFileList } = useFileList();
const handleSubmit = async (): Promise<void> => {
if (fileName) {
await onCreateFile(fileName.trim());
const fullPath = selectedFolder
? `${selectedFolder}/${fileName.trim()}`
: fileName.trim();
await onCreateFile(fullPath);
setFileName('');
setSelectedFolder('');
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 => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
@@ -28,27 +64,82 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
return (
<Modal
opened={newFileModalVisible}
onClose={() => setNewFileModalVisible(false)}
onClose={handleClose}
title="Create New File"
centered
size="sm"
>
<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
label="File Name"
type="text"
placeholder="Enter file name"
placeholder="example.md"
data-testid="file-name-input"
value={fileName}
onChange={(event) => setFileName(event.currentTarget.value)}
onKeyDown={handleKeyDown}
mb="md"
mb="xs"
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">
<Button
variant="default"
onClick={() => setNewFileModalVisible(false)}
onClick={handleClose}
data-testid="cancel-create-file-button"
>
Cancel

View File

@@ -1,5 +1,17 @@
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.
@@ -15,3 +27,65 @@ export const getFileUrl = (workspaceName: string, filePath: string) => {
workspaceName
)}/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;
}

View 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
View 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()
);
}

View File

@@ -240,17 +240,13 @@ export function remarkWikiLinks(workspaceName: string) {
continue;
}
const lookupFileName: string = match.isImage
? match.fileName
: addMarkdownExtension(match.fileName);
// If the filename contains a path separator, treat it as a full path
// This handles wikilinks with paths like [[folder/subfolder/file]]
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) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
@@ -266,9 +262,37 @@ export function remarkWikiLinks(workspaceName: string) {
);
}
} else {
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
// It's just a filename - look it up to find the full path
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) {
console.debug('File lookup failed:', match.fileName, error);

View 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,
};
}