mirror of
https://github.com/lordmathis/lemma.git
synced 2025-12-22 09:34:22 +00:00
Add autocompletion for wiki links
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -164,4 +164,7 @@ main
|
|||||||
data
|
data
|
||||||
|
|
||||||
# Feature specifications
|
# Feature specifications
|
||||||
spec.md
|
spec.md
|
||||||
|
|
||||||
|
# Go debug files
|
||||||
|
__debug_bin*
|
||||||
103
app/package-lock.json
generated
103
app/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.19.1",
|
||||||
"@codemirror/commands": "^6.10.0",
|
"@codemirror/commands": "^6.10.0",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
@@ -396,6 +397,18 @@
|
|||||||
"node": ">=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": {
|
"node_modules/@codemirror/commands": {
|
||||||
"version": "6.10.0",
|
"version": "6.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
|
||||||
@@ -421,24 +434,6 @@
|
|||||||
"@lezer/css": "^1.1.7"
|
"@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": {
|
"node_modules/@codemirror/lang-html": {
|
||||||
"version": "6.4.9",
|
"version": "6.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
|
||||||
@@ -456,24 +451,6 @@
|
|||||||
"@lezer/html": "^1.3.0"
|
"@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": {
|
"node_modules/@codemirror/lang-javascript": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
|
||||||
@@ -489,24 +466,6 @@
|
|||||||
"@lezer/javascript": "^1.0.0"
|
"@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": {
|
"node_modules/@codemirror/lang-javascript/node_modules/@codemirror/lint": {
|
||||||
"version": "6.8.2",
|
"version": "6.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz",
|
||||||
@@ -533,24 +492,6 @@
|
|||||||
"@lezer/markdown": "^1.0.0"
|
"@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": {
|
"node_modules/@codemirror/language": {
|
||||||
"version": "6.10.3",
|
"version": "6.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz",
|
||||||
@@ -3647,24 +3588,6 @@
|
|||||||
"@codemirror/view": "^6.0.0"
|
"@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": {
|
"node_modules/codemirror/node_modules/@codemirror/lint": {
|
||||||
"version": "6.8.2",
|
"version": "6.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/LordMathis/Lemma#readme",
|
"homepage": "https://github.com/LordMathis/Lemma#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.19.1",
|
||||||
"@codemirror/commands": "^6.10.0",
|
"@codemirror/commands": "^6.10.0",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -121,6 +122,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -138,6 +140,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -157,6 +160,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -179,6 +183,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -208,6 +213,7 @@ describe('ContentView', () => {
|
|||||||
handleContentChange={mockHandleContentChange}
|
handleContentChange={mockHandleContentChange}
|
||||||
handleSave={mockHandleSave}
|
handleSave={mockHandleSave}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Editor from './Editor';
|
|||||||
import MarkdownPreview from './MarkdownPreview';
|
import MarkdownPreview from './MarkdownPreview';
|
||||||
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
|
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
|
||||||
import { useWorkspace } from '@/contexts/WorkspaceContext';
|
import { useWorkspace } from '@/contexts/WorkspaceContext';
|
||||||
|
import type { FileNode } from '../../types/models';
|
||||||
|
|
||||||
type ViewTab = 'source' | 'preview';
|
type ViewTab = 'source' | 'preview';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ interface ContentViewProps {
|
|||||||
handleContentChange: (content: string) => void;
|
handleContentChange: (content: string) => void;
|
||||||
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
handleFileSelect: (filePath: string | null) => Promise<void>;
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
files: FileNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentView: React.FC<ContentViewProps> = ({
|
const ContentView: React.FC<ContentViewProps> = ({
|
||||||
@@ -23,6 +25,7 @@ const ContentView: React.FC<ContentViewProps> = ({
|
|||||||
handleContentChange,
|
handleContentChange,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
|
files,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
if (!currentWorkspace) {
|
if (!currentWorkspace) {
|
||||||
@@ -67,6 +70,7 @@ const ContentView: React.FC<ContentViewProps> = ({
|
|||||||
handleContentChange={handleContentChange}
|
handleContentChange={handleContentChange}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
|
files={files}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />
|
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useMemo } from 'react';
|
||||||
import { basicSetup } from 'codemirror';
|
import { basicSetup } from 'codemirror';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
import { markdown } from '@codemirror/lang-markdown';
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
import { defaultKeymap } from '@codemirror/commands';
|
import { defaultKeymap } from '@codemirror/commands';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { autocompletion } from '@codemirror/autocomplete';
|
||||||
import { useWorkspace } from '../../hooks/useWorkspace';
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
|
import { createWikiLinkCompletions } from '../../utils/wikiLinkCompletion';
|
||||||
|
import { flattenFileTree } from '../../utils/fileHelpers';
|
||||||
|
import type { FileNode } from '../../types/models';
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
handleContentChange: (content: string) => void;
|
handleContentChange: (content: string) => void;
|
||||||
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
selectedFile: string;
|
selectedFile: string;
|
||||||
|
files: FileNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Editor: React.FC<EditorProps> = ({
|
const Editor: React.FC<EditorProps> = ({
|
||||||
@@ -19,11 +24,19 @@ const Editor: React.FC<EditorProps> = ({
|
|||||||
handleContentChange,
|
handleContentChange,
|
||||||
handleSave,
|
handleSave,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
|
files,
|
||||||
}) => {
|
}) => {
|
||||||
const { colorScheme } = useWorkspace();
|
const { colorScheme, currentWorkspace } = useWorkspace();
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
|
// Flatten file tree for autocompletion, respecting showHiddenFiles setting
|
||||||
|
const showHiddenFiles = currentWorkspace?.showHiddenFiles || false;
|
||||||
|
const flatFiles = useMemo(
|
||||||
|
() => flattenFileTree(files, showHiddenFiles),
|
||||||
|
[files, showHiddenFiles]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEditorSave = (view: EditorView): boolean => {
|
const handleEditorSave = (view: EditorView): boolean => {
|
||||||
void handleSave(selectedFile, view.state.doc.toString());
|
void handleSave(selectedFile, view.state.doc.toString());
|
||||||
@@ -71,6 +84,12 @@ const Editor: React.FC<EditorProps> = ({
|
|||||||
}),
|
}),
|
||||||
theme,
|
theme,
|
||||||
colorScheme === 'dark' ? oneDark : [],
|
colorScheme === 'dark' ? oneDark : [],
|
||||||
|
autocompletion({
|
||||||
|
override: [createWikiLinkCompletions(flatFiles)],
|
||||||
|
activateOnTyping: true,
|
||||||
|
maxRenderedOptions: 10,
|
||||||
|
closeOnBlur: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +106,7 @@ const Editor: React.FC<EditorProps> = ({
|
|||||||
};
|
};
|
||||||
// TODO: Refactor
|
// TODO: Refactor
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [colorScheme, handleContentChange, handleSave, selectedFile]);
|
}, [colorScheme, handleContentChange, handleSave, selectedFile, flatFiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const Layout: React.FC = () => {
|
|||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
handleFileSelect={handleFileSelect}
|
handleFileSelect={handleFileSelect}
|
||||||
loadFileList={loadFileList}
|
loadFileList={loadFileList}
|
||||||
|
files={files}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ describe('MainContent', () => {
|
|||||||
selectedFile="docs/guide.md"
|
selectedFile="docs/guide.md"
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
loadFileList={mockLoadFileList}
|
loadFileList={mockLoadFileList}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -156,6 +157,7 @@ describe('MainContent', () => {
|
|||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
loadFileList={mockLoadFileList}
|
loadFileList={mockLoadFileList}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -172,6 +174,7 @@ describe('MainContent', () => {
|
|||||||
selectedFile="test.md"
|
selectedFile="test.md"
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
loadFileList={mockLoadFileList}
|
loadFileList={mockLoadFileList}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
@@ -188,6 +191,7 @@ describe('MainContent', () => {
|
|||||||
selectedFile={null}
|
selectedFile={null}
|
||||||
handleFileSelect={mockHandleFileSelect}
|
handleFileSelect={mockHandleFileSelect}
|
||||||
loadFileList={mockLoadFileList}
|
loadFileList={mockLoadFileList}
|
||||||
|
files={[]}
|
||||||
/>
|
/>
|
||||||
</TestWrapper>
|
</TestWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useFileContent } from '../../hooks/useFileContent';
|
|||||||
import { useFileOperations } from '../../hooks/useFileOperations';
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||||
import { useModalContext } from '../../contexts/ModalContext';
|
import { useModalContext } from '../../contexts/ModalContext';
|
||||||
|
import type { FileNode } from '../../types/models';
|
||||||
|
|
||||||
type ViewTab = 'source' | 'preview';
|
type ViewTab = 'source' | 'preview';
|
||||||
|
|
||||||
@@ -19,12 +20,14 @@ interface MainContentProps {
|
|||||||
selectedFile: string | null;
|
selectedFile: string | null;
|
||||||
handleFileSelect: (filePath: string | null) => Promise<void>;
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
loadFileList: () => Promise<void>;
|
loadFileList: () => Promise<void>;
|
||||||
|
files: FileNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MainContent: React.FC<MainContentProps> = ({
|
const MainContent: React.FC<MainContentProps> = ({
|
||||||
selectedFile,
|
selectedFile,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
loadFileList,
|
loadFileList,
|
||||||
|
files,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState<ViewTab>('source');
|
const [activeTab, setActiveTab] = useState<ViewTab>('source');
|
||||||
const {
|
const {
|
||||||
@@ -161,6 +164,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
handleContentChange={handleContentChange}
|
handleContentChange={handleContentChange}
|
||||||
handleSave={handleSaveFile}
|
handleSave={handleSaveFile}
|
||||||
handleFileSelect={handleFileSelect}
|
handleFileSelect={handleFileSelect}
|
||||||
|
files={files}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<CreateFileModal onCreateFile={handleCreateFile} />
|
<CreateFileModal onCreateFile={handleCreateFile} />
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { API_BASE_URL } from '@/types/api';
|
import { API_BASE_URL } from '@/types/api';
|
||||||
import { IMAGE_EXTENSIONS } from '@/types/models';
|
import { IMAGE_EXTENSIONS, type FileNode } from '@/types/models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a flattened file for searching and autocompletion
|
||||||
|
*/
|
||||||
|
export interface FlatFile {
|
||||||
|
name: string; // "meeting-notes.md"
|
||||||
|
path: string; // "work/2024/meeting-notes.md"
|
||||||
|
displayPath: string; // "work/2024/meeting-notes"
|
||||||
|
nameWithoutExt: string; // "meeting-notes"
|
||||||
|
parentFolder: string; // "work/2024"
|
||||||
|
isImage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the given file path has an image extension.
|
* Checks if the given file path has an image extension.
|
||||||
@@ -15,3 +27,65 @@ export const getFileUrl = (workspaceName: string, filePath: string) => {
|
|||||||
workspaceName
|
workspaceName
|
||||||
)}/files/content?file_path=${encodeURIComponent(filePath)}`;
|
)}/files/content?file_path=${encodeURIComponent(filePath)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively flattens FileNode tree into searchable array
|
||||||
|
* Precomputes display strings and metadata for performance
|
||||||
|
*
|
||||||
|
* @param nodes - Array of FileNode from the file tree
|
||||||
|
* @param showHiddenFiles - Whether to include hidden files (files/folders starting with .)
|
||||||
|
* @returns Array of FlatFile objects ready for searching
|
||||||
|
*/
|
||||||
|
export function flattenFileTree(nodes: FileNode[], showHiddenFiles = false): FlatFile[] {
|
||||||
|
const result: FlatFile[] = [];
|
||||||
|
|
||||||
|
function traverse(node: FileNode) {
|
||||||
|
// Skip hidden files and folders if showHiddenFiles is false
|
||||||
|
// Hidden files/folders are those that start with a dot (.)
|
||||||
|
if (!showHiddenFiles && node.name.startsWith('.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process files, not folders (folders have children)
|
||||||
|
if (!node.children) {
|
||||||
|
const name = node.name;
|
||||||
|
const path = node.path;
|
||||||
|
const isImage = isImageFile(path);
|
||||||
|
|
||||||
|
// Remove extension for display (except for images)
|
||||||
|
let nameWithoutExt = name;
|
||||||
|
let displayPath = path;
|
||||||
|
|
||||||
|
if (name.endsWith('.md')) {
|
||||||
|
nameWithoutExt = name.slice(0, -3);
|
||||||
|
displayPath = path.slice(0, -3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parent folder path
|
||||||
|
const lastSlashIndex = path.lastIndexOf('/');
|
||||||
|
const parentFolder = lastSlashIndex > 0 ? path.slice(0, lastSlashIndex) : '';
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
displayPath,
|
||||||
|
nameWithoutExt,
|
||||||
|
parentFolder,
|
||||||
|
isImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process children
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
traverse(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
traverse(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
129
app/src/utils/fuzzyMatch.ts
Normal file
129
app/src/utils/fuzzyMatch.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Result of a fuzzy match operation
|
||||||
|
*/
|
||||||
|
export interface MatchResult {
|
||||||
|
matched: boolean;
|
||||||
|
score: number; // Higher is better
|
||||||
|
matchedIndices: number[]; // For highlighting
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoring weights for match quality
|
||||||
|
*/
|
||||||
|
const SCORING = {
|
||||||
|
consecutiveMatch: 15,
|
||||||
|
wordBoundaryMatch: 10,
|
||||||
|
camelCaseMatch: 10,
|
||||||
|
firstCharMatch: 15,
|
||||||
|
gapPenalty: -1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs fuzzy matching between query and target string
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* - Sequential character matching (order matters)
|
||||||
|
* - Bonus for consecutive matches
|
||||||
|
* - Bonus for word boundary matches
|
||||||
|
* - Bonus for camelCase matches
|
||||||
|
* - Case-insensitive by default
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* query: "mtno"
|
||||||
|
* target: "meeting-notes"
|
||||||
|
* → matches: [0, 4, 8, 9], score: 85
|
||||||
|
*
|
||||||
|
* @param query - The search string
|
||||||
|
* @param target - The string to search in
|
||||||
|
* @returns MatchResult with matched status, score, and matched indices
|
||||||
|
*/
|
||||||
|
export function fuzzyMatch(query: string, target: string): MatchResult {
|
||||||
|
if (!query) {
|
||||||
|
return { matched: true, score: 0, matchedIndices: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryLower = query.toLowerCase();
|
||||||
|
const targetLower = target.toLowerCase();
|
||||||
|
|
||||||
|
const matchedIndices: number[] = [];
|
||||||
|
let score = 0;
|
||||||
|
let queryIndex = 0;
|
||||||
|
let previousMatchIndex = -1;
|
||||||
|
|
||||||
|
// Try to match all query characters in order
|
||||||
|
for (let targetIndex = 0; targetIndex < targetLower.length; targetIndex++) {
|
||||||
|
if (queryIndex >= queryLower.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryLower[queryIndex] === targetLower[targetIndex]) {
|
||||||
|
matchedIndices.push(targetIndex);
|
||||||
|
|
||||||
|
// Bonus for first character match
|
||||||
|
if (targetIndex === 0) {
|
||||||
|
score += SCORING.firstCharMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for consecutive matches
|
||||||
|
if (previousMatchIndex === targetIndex - 1) {
|
||||||
|
score += SCORING.consecutiveMatch;
|
||||||
|
} else if (previousMatchIndex >= 0) {
|
||||||
|
// Penalty for gaps
|
||||||
|
const gap = targetIndex - previousMatchIndex - 1;
|
||||||
|
score += gap * SCORING.gapPenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for word boundary matches
|
||||||
|
if (isWordBoundary(target, targetIndex)) {
|
||||||
|
score += SCORING.wordBoundaryMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for camelCase matches
|
||||||
|
if (isCamelCaseMatch(target, targetIndex)) {
|
||||||
|
score += SCORING.camelCaseMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMatchIndex = targetIndex;
|
||||||
|
queryIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All query characters must be matched
|
||||||
|
const matched = queryIndex === queryLower.length;
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, score: 0, matchedIndices: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost score for matches with higher character coverage
|
||||||
|
const coverage = matchedIndices.length / target.length;
|
||||||
|
score += coverage * 50;
|
||||||
|
|
||||||
|
return { matched, score, matchedIndices };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a character at the given index is at a word boundary
|
||||||
|
* Word boundaries are: start of string, after space, after dash, after slash
|
||||||
|
*/
|
||||||
|
function isWordBoundary(str: string, index: number): boolean {
|
||||||
|
if (index === 0) return true;
|
||||||
|
const prevChar = str[index - 1];
|
||||||
|
return prevChar === ' ' || prevChar === '-' || prevChar === '/' || prevChar === '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a character at the given index is a camelCase boundary
|
||||||
|
* (lowercase followed by uppercase)
|
||||||
|
*/
|
||||||
|
function isCamelCaseMatch(str: string, index: number): boolean {
|
||||||
|
if (index === 0 || index >= str.length) return false;
|
||||||
|
const currentChar = str[index];
|
||||||
|
const prevChar = str[index - 1];
|
||||||
|
if (!currentChar || !prevChar) return false;
|
||||||
|
return (
|
||||||
|
currentChar === currentChar.toUpperCase() &&
|
||||||
|
currentChar !== currentChar.toLowerCase() &&
|
||||||
|
prevChar === prevChar.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -240,17 +240,13 @@ export function remarkWikiLinks(workspaceName: string) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookupFileName: string = match.isImage
|
// If the filename contains a path separator, treat it as a full path
|
||||||
? match.fileName
|
// This handles wikilinks with paths like [[folder/subfolder/file]]
|
||||||
: addMarkdownExtension(match.fileName);
|
let filePath: string;
|
||||||
|
if (match.fileName.includes('/')) {
|
||||||
|
// It's already a full path - use it directly
|
||||||
|
filePath = match.isImage ? match.fileName : addMarkdownExtension(match.fileName);
|
||||||
|
|
||||||
const paths: string[] = await lookupFileByName(
|
|
||||||
workspaceName,
|
|
||||||
lookupFileName
|
|
||||||
);
|
|
||||||
|
|
||||||
if (paths && paths.length > 0 && paths[0]) {
|
|
||||||
const filePath: string = paths[0];
|
|
||||||
if (match.isImage) {
|
if (match.isImage) {
|
||||||
newNodes.push(
|
newNodes.push(
|
||||||
createImageNode(workspaceName, filePath, match.displayText)
|
createImageNode(workspaceName, filePath, match.displayText)
|
||||||
@@ -266,9 +262,37 @@ export function remarkWikiLinks(workspaceName: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newNodes.push(
|
// It's just a filename - look it up to find the full path
|
||||||
createNotFoundLink(match.fileName, match.displayText, baseUrl)
|
const lookupFileName: string = match.isImage
|
||||||
|
? match.fileName
|
||||||
|
: addMarkdownExtension(match.fileName);
|
||||||
|
|
||||||
|
const paths: string[] = await lookupFileByName(
|
||||||
|
workspaceName,
|
||||||
|
lookupFileName
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (paths && paths.length > 0 && paths[0]) {
|
||||||
|
filePath = paths[0];
|
||||||
|
if (match.isImage) {
|
||||||
|
newNodes.push(
|
||||||
|
createImageNode(workspaceName, filePath, match.displayText)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newNodes.push(
|
||||||
|
createFileLink(
|
||||||
|
filePath,
|
||||||
|
match.displayText,
|
||||||
|
match.heading,
|
||||||
|
baseUrl
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newNodes.push(
|
||||||
|
createNotFoundLink(match.fileName, match.displayText, baseUrl)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug('File lookup failed:', match.fileName, error);
|
console.debug('File lookup failed:', match.fileName, error);
|
||||||
|
|||||||
239
app/src/utils/wikiLinkCompletion.ts
Normal file
239
app/src/utils/wikiLinkCompletion.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import type {
|
||||||
|
CompletionContext,
|
||||||
|
CompletionResult,
|
||||||
|
Completion,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
|
import type { FlatFile } from './fileHelpers';
|
||||||
|
import { fuzzyMatch } from './fuzzyMatch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiki link context detection result
|
||||||
|
*/
|
||||||
|
interface WikiLinkContext {
|
||||||
|
isWikiLink: boolean;
|
||||||
|
isImage: boolean; // true if ![[
|
||||||
|
query: string; // partial text after [[
|
||||||
|
from: number; // cursor position to replace from
|
||||||
|
to: number; // cursor position to replace to
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates CodeMirror autocompletion source for wiki links
|
||||||
|
*
|
||||||
|
* @param files - Flattened file list from workspace
|
||||||
|
* @returns CompletionSource function
|
||||||
|
*/
|
||||||
|
export function createWikiLinkCompletions(
|
||||||
|
files: FlatFile[]
|
||||||
|
): (context: CompletionContext) => CompletionResult | null {
|
||||||
|
return (context: CompletionContext): CompletionResult | null => {
|
||||||
|
const wikiContext = detectWikiLinkContext(context);
|
||||||
|
|
||||||
|
if (!wikiContext.isWikiLink) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and rank files based on query
|
||||||
|
const rankedFiles = filterAndRankFiles(
|
||||||
|
wikiContext.query,
|
||||||
|
files,
|
||||||
|
wikiContext.isImage,
|
||||||
|
50
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rankedFiles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to completion options
|
||||||
|
const options = rankedFiles.map((file) => formatCompletion(file));
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: wikiContext.from,
|
||||||
|
to: wikiContext.to,
|
||||||
|
options,
|
||||||
|
// Don't set filter or validFor - let CodeMirror re-trigger our completion
|
||||||
|
// source on every keystroke so we can re-filter with fuzzy matching
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if cursor is inside a wiki link and extracts context
|
||||||
|
*
|
||||||
|
* Detection logic:
|
||||||
|
* 1. Search backwards from cursor for [[ or ![[
|
||||||
|
* 2. Ensure no closing ]] between opener and cursor
|
||||||
|
* 3. Extract partial query (text after [[ and before cursor)
|
||||||
|
* 4. Determine if image link (![[) or regular ([[)
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* "[[meeti|ng" → { isWikiLink: true, query: "meeti", ... }
|
||||||
|
* "![[img|" → { isWikiLink: true, isImage: true, query: "img", ... }
|
||||||
|
* "regular text|" → { isWikiLink: false }
|
||||||
|
*/
|
||||||
|
function detectWikiLinkContext(context: CompletionContext): WikiLinkContext {
|
||||||
|
const { state, pos } = context;
|
||||||
|
const line = state.doc.lineAt(pos);
|
||||||
|
const textBefore = state.sliceDoc(line.from, pos);
|
||||||
|
|
||||||
|
// Look for [[ or ![[
|
||||||
|
const imageWikiLinkMatch = textBefore.lastIndexOf('![[');
|
||||||
|
const regularWikiLinkMatch = textBefore.lastIndexOf('[[');
|
||||||
|
|
||||||
|
// Determine which one is closer to cursor
|
||||||
|
let isImage = false;
|
||||||
|
let openerIndex = -1;
|
||||||
|
|
||||||
|
if (imageWikiLinkMatch > regularWikiLinkMatch) {
|
||||||
|
isImage = true;
|
||||||
|
openerIndex = imageWikiLinkMatch;
|
||||||
|
} else if (regularWikiLinkMatch >= 0) {
|
||||||
|
openerIndex = regularWikiLinkMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no opener found, not in a wiki link
|
||||||
|
if (openerIndex < 0) {
|
||||||
|
return {
|
||||||
|
isWikiLink: false,
|
||||||
|
isImage: false,
|
||||||
|
query: '',
|
||||||
|
from: pos,
|
||||||
|
to: pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the absolute position of the opener in the document
|
||||||
|
const openerPos = line.from + openerIndex;
|
||||||
|
const openerLength = isImage ? 3 : 2; // ![[ or [[
|
||||||
|
const queryStartPos = openerPos + openerLength;
|
||||||
|
|
||||||
|
// Check if there's a closing ]] between opener and cursor
|
||||||
|
const textAfterOpener = textBefore.slice(openerIndex);
|
||||||
|
const closingIndex = textAfterOpener.indexOf(']]');
|
||||||
|
|
||||||
|
if (closingIndex >= 0 && closingIndex < textAfterOpener.length - 2) {
|
||||||
|
// Found ]] before cursor, so we're not inside a wiki link
|
||||||
|
return {
|
||||||
|
isWikiLink: false,
|
||||||
|
isImage: false,
|
||||||
|
query: '',
|
||||||
|
from: pos,
|
||||||
|
to: pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the query (text between [[ and cursor)
|
||||||
|
const query = state.sliceDoc(queryStartPos, pos);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWikiLink: true,
|
||||||
|
isImage,
|
||||||
|
query,
|
||||||
|
from: queryStartPos,
|
||||||
|
to: pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters files and ranks by relevance
|
||||||
|
*
|
||||||
|
* Ranking priority:
|
||||||
|
* 1. File type match (images for ![[, markdown for [[)
|
||||||
|
* 2. Fuzzy match score
|
||||||
|
* 3. Exact filename match > path component match
|
||||||
|
* 4. Shorter paths (prefer root over deeply nested)
|
||||||
|
* 5. Alphabetical for ties
|
||||||
|
*
|
||||||
|
* @param query - User's partial input
|
||||||
|
* @param files - All available files
|
||||||
|
* @param isImage - Whether to filter for images
|
||||||
|
* @param maxResults - Limit returned results (default: 50)
|
||||||
|
*/
|
||||||
|
function filterAndRankFiles(
|
||||||
|
query: string,
|
||||||
|
files: FlatFile[],
|
||||||
|
isImage: boolean,
|
||||||
|
maxResults = 50
|
||||||
|
): FlatFile[] {
|
||||||
|
// If query is empty, show all matching file types
|
||||||
|
if (!query) {
|
||||||
|
const filtered = files.filter((f) => f.isImage === isImage);
|
||||||
|
return filtered.slice(0, maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoredFile {
|
||||||
|
file: FlatFile;
|
||||||
|
score: number;
|
||||||
|
nameScore: number;
|
||||||
|
pathScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scored: ScoredFile[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Filter by file type
|
||||||
|
if (file.isImage !== isImage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try matching against different fields
|
||||||
|
const nameMatch = fuzzyMatch(query, file.nameWithoutExt);
|
||||||
|
const pathMatch = fuzzyMatch(query, file.displayPath);
|
||||||
|
|
||||||
|
// Use the best match
|
||||||
|
if (nameMatch.matched || pathMatch.matched) {
|
||||||
|
// Prefer name matches over path matches
|
||||||
|
const nameScore = nameMatch.matched ? nameMatch.score : 0;
|
||||||
|
const pathScore = pathMatch.matched ? pathMatch.score : 0;
|
||||||
|
|
||||||
|
// Name matches get higher priority
|
||||||
|
const totalScore = nameScore * 2 + pathScore;
|
||||||
|
|
||||||
|
// Penalize deeply nested files slightly
|
||||||
|
const depth = file.path.split('/').length;
|
||||||
|
const depthPenalty = depth * 0.5;
|
||||||
|
|
||||||
|
scored.push({
|
||||||
|
file,
|
||||||
|
score: totalScore - depthPenalty,
|
||||||
|
nameScore,
|
||||||
|
pathScore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (descending), then alphabetically
|
||||||
|
scored.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) {
|
||||||
|
return b.score - a.score;
|
||||||
|
}
|
||||||
|
return a.file.displayPath.localeCompare(b.file.displayPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return top results
|
||||||
|
return scored.slice(0, maxResults).map((s) => s.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts FlatFile to CodeMirror Completion object
|
||||||
|
*
|
||||||
|
* Format rules:
|
||||||
|
* - Markdown files: show path without .md extension
|
||||||
|
* - Images: show full path with extension
|
||||||
|
* - Display includes path relative to workspace root
|
||||||
|
* - Apply text: full path (no extension for .md)
|
||||||
|
*
|
||||||
|
* Example outputs:
|
||||||
|
* work/2024/meeting-notes (for .md)
|
||||||
|
* assets/screenshot.png (for image)
|
||||||
|
*/
|
||||||
|
function formatCompletion(file: FlatFile): Completion {
|
||||||
|
return {
|
||||||
|
label: file.displayPath,
|
||||||
|
apply: file.displayPath,
|
||||||
|
type: file.isImage ? 'image' : 'file',
|
||||||
|
detail: file.parentFolder || '/',
|
||||||
|
boost: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user