mirror of
https://github.com/lordmathis/lemma.git
synced 2025-12-22 17:44:25 +00:00
Compare commits
62 Commits
c98ece29d9
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a90e86f4a | ||
| 35331eded2 | |||
|
|
1d2bd0d54c | ||
| 1701463204 | |||
|
|
053c642456 | ||
| b48103aa98 | |||
|
|
9489f3e880 | ||
| 853015a895 | |||
|
|
ebf4480aec | ||
| 8ab11d1c6d | |||
|
|
e1ca9096de | ||
| 92b4fcd5d9 | |||
|
|
8d7188c88d | ||
| ff49954003 | |||
|
|
a8dad80538 | ||
| 63c1950186 | |||
|
|
d51dfbb435 | ||
| 1f74284773 | |||
|
|
623c3843e4 | ||
| 34916dc6ad | |||
|
|
37bc1035d3 | ||
| 8678e2a12d | |||
| f94885482a | |||
| 6d5eab9f57 | |||
|
|
fbe44844a5 | ||
|
|
52075d1756 | ||
| 35c4ede667 | |||
|
|
6539579812 | ||
| d9f1a16d94 | |||
| 0999fa9315 | |||
| cc8c8fd414 | |||
| 9ba37b3342 | |||
| 140ccd6879 | |||
| 7424ce4385 | |||
| c998800990 | |||
| c86d627053 | |||
|
|
5837cf7316 | ||
| 92a3dcdab7 | |||
| 857b56fec2 | |||
| 7e48ec935c | |||
| b728c240b6 | |||
| e9aa611a41 | |||
| 441c31eb14 | |||
| fd75d6f8a0 | |||
|
|
f54852bbad | ||
|
|
9101acd171 | ||
|
|
d9a26830ff | ||
|
|
c095c4a049 | ||
| 728367a6f6 | |||
| dd854b755e | |||
| 7dd8e3c763 | |||
| 0c3847241b | |||
| 9a8f2e8a46 | |||
| 914f9a68f4 | |||
| 33c93e40d1 | |||
| aace38d1a0 | |||
| 71bd791c60 | |||
| 74aeeec42b | |||
| 2e7bd88a57 | |||
| 0579b8d0e5 | |||
| 93f484eb91 | |||
| bc49391b5c |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -11,7 +11,7 @@ updates:
|
||||
- "patch"
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
directory: "/server"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
|
||||
BIN
.github/screenshot.png
vendored
Normal file
BIN
.github/screenshot.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -164,4 +164,7 @@ main
|
||||
data
|
||||
|
||||
# Feature specifications
|
||||
spec.md
|
||||
spec.md
|
||||
|
||||
# Go debug files
|
||||
__debug_bin*
|
||||
61
README.md
61
README.md
@@ -4,15 +4,21 @@
|
||||
|
||||
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 +28,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
|
||||
|
||||
|
||||
2059
app/package-lock.json
generated
2059
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,21 +29,23 @@
|
||||
},
|
||||
"homepage": "https://github.com/LordMathis/Lemma#readme",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@mantine/core": "^8.3.7",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@mantine/modals": "^8.3.7",
|
||||
"@mantine/notifications": "^8.3.7",
|
||||
"@codemirror/view": "^6.39.5",
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"@mantine/core": "^8.3.10",
|
||||
"@mantine/hooks": "^8.3.10",
|
||||
"@mantine/modals": "^8.3.10",
|
||||
"@mantine/notifications": "^8.3.10",
|
||||
"@react-hook/resize-observer": "^2.0.2",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react": "^19.2.3",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-mathjax": "^7.1.0",
|
||||
"rehype-react": "^8.0.0",
|
||||
@@ -55,28 +57,28 @@
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^18.3.20",
|
||||
"@types/react-dom": "^18.3.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^4.0.8",
|
||||
"eslint": "^9.39.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"jsdom": "^27.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.93.3",
|
||||
"sass": "^1.97.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-compression2": "^2.3.1",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-compression2": "^2.4.0",
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -69,7 +69,19 @@ vi.mock('react-arborist', () => ({
|
||||
|
||||
// Mock resize observer hook
|
||||
vi.mock('@react-hook/resize-observer', () => ({
|
||||
default: vi.fn(),
|
||||
default: vi.fn(
|
||||
(
|
||||
_target: unknown,
|
||||
callback: (entry: { contentRect: { width: number; height: number } }) => void
|
||||
) => {
|
||||
// Immediately call the callback with a mock entry to provide size
|
||||
if (callback) {
|
||||
setTimeout(() => {
|
||||
callback({ contentRect: { width: 300, height: 600 } });
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock contexts
|
||||
@@ -172,7 +184,7 @@ describe('FileTree', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders file tree with files', () => {
|
||||
it('renders file tree with files', async () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
@@ -184,7 +196,9 @@ describe('FileTree', () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByTestId('file-tree')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('file-tree')).toBeInTheDocument();
|
||||
});
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||
});
|
||||
@@ -201,6 +215,10 @@ describe('FileTree', () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const fileNode = getByTestId('file-node-1');
|
||||
fireEvent.click(fileNode);
|
||||
|
||||
@@ -209,7 +227,7 @@ describe('FileTree', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out hidden files when showHiddenFiles is false', () => {
|
||||
it('filters out hidden files when showHiddenFiles is false', async () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
@@ -221,6 +239,10 @@ describe('FileTree', () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should show regular files
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||
@@ -229,7 +251,7 @@ describe('FileTree', () => {
|
||||
expect(queryByTestId('file-node-4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows hidden files when showHiddenFiles is true', () => {
|
||||
it('shows hidden files when showHiddenFiles is true', async () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
@@ -241,13 +263,17 @@ describe('FileTree', () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should show all files including hidden
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty tree when no files provided', () => {
|
||||
it('renders empty tree when no files provided', async () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
@@ -259,6 +285,10 @@ describe('FileTree', () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('file-tree')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tree = getByTestId('file-tree');
|
||||
expect(tree).toBeInTheDocument();
|
||||
expect(tree.children).toHaveLength(0);
|
||||
@@ -276,6 +306,10 @@ describe('FileTree', () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on folder (has children)
|
||||
const folderNode = getByTestId('file-node-2');
|
||||
fireEvent.click(folderNode);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState, useLayoutEffect, useCallback } from 'react';
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Tree, type NodeApi } from 'react-arborist';
|
||||
import {
|
||||
IconFile,
|
||||
@@ -23,15 +23,11 @@ interface FileTreeProps {
|
||||
loadFileList: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
|
||||
const useSize = (
|
||||
target: React.RefObject<HTMLElement | null>
|
||||
): Size | undefined => {
|
||||
const [size, setSize] = useState<Size>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (target.current) {
|
||||
setSize(target.current.getBoundingClientRect());
|
||||
}
|
||||
}, [target]);
|
||||
|
||||
useResizeObserver(target, (entry) => setSize(entry.contentRect));
|
||||
return size;
|
||||
};
|
||||
|
||||
269
app/src/components/files/FolderSelector.tsx
Normal file
269
app/src/components/files/FolderSelector.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useRef, 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 | null>
|
||||
): Size | undefined => {
|
||||
const [size, setSize] = useState<Size>();
|
||||
|
||||
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}
|
||||
handleFileSelect={handleFileSelect}
|
||||
loadFileList={loadFileList}
|
||||
files={files}
|
||||
/>
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -175,7 +175,7 @@ export interface DefaultFile {
|
||||
export const DEFAULT_FILE: DefaultFile = {
|
||||
name: 'New File.md',
|
||||
path: 'New File.md',
|
||||
content: '# Welcome to NovaMD\n\nStart editing here!',
|
||||
content: '# Welcome to Lemma\n\nStart editing here!',
|
||||
};
|
||||
|
||||
export interface FileNode {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,63 +1,63 @@
|
||||
module lemma
|
||||
|
||||
go 1.23.1
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.2
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-chi/httprate v0.14.1
|
||||
github.com/go-git/go-git/v5 v5.13.1
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/go-git/go-git/v5 v5.16.4
|
||||
github.com/go-playground/validator/v10 v10.29.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.23
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/unrolled/secure v1.17.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.3.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.1 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||
github.com/go-openapi/spec v0.20.6 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
167
server/go.sum
167
server/go.sum
@@ -7,58 +7,61 @@ github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6Xge
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk=
|
||||
github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM=
|
||||
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ=
|
||||
github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
|
||||
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA=
|
||||
github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M=
|
||||
github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
@@ -77,31 +80,28 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
|
||||
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -117,8 +117,8 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
@@ -132,76 +132,81 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
|
||||
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
|
||||
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc=
|
||||
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
|
||||
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
Reference in New Issue
Block a user