diff --git a/.gitignore b/.gitignore index d17cc3b..4cfdb52 100644 --- a/.gitignore +++ b/.gitignore @@ -164,4 +164,7 @@ main data # Feature specifications -spec.md \ No newline at end of file +spec.md + +# Go debug files +__debug_bin* \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index f98aa67..ebdfca7 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@codemirror/autocomplete": "^6.19.1", "@codemirror/commands": "^6.10.0", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/state": "^6.5.2", @@ -396,6 +397,18 @@ "node": ">=18" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz", + "integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, "node_modules/@codemirror/commands": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz", @@ -421,24 +434,6 @@ "@lezer/css": "^1.1.7" } }, - "node_modules/@codemirror/lang-css/node_modules/@codemirror/autocomplete": { - "version": "6.18.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz", - "integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" - } - }, "node_modules/@codemirror/lang-html": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", @@ -456,24 +451,6 @@ "@lezer/html": "^1.3.0" } }, - "node_modules/@codemirror/lang-html/node_modules/@codemirror/autocomplete": { - "version": "6.18.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz", - "integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" - } - }, "node_modules/@codemirror/lang-javascript": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", @@ -489,24 +466,6 @@ "@lezer/javascript": "^1.0.0" } }, - "node_modules/@codemirror/lang-javascript/node_modules/@codemirror/autocomplete": { - "version": "6.18.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz", - "integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" - } - }, "node_modules/@codemirror/lang-javascript/node_modules/@codemirror/lint": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz", @@ -533,24 +492,6 @@ "@lezer/markdown": "^1.0.0" } }, - "node_modules/@codemirror/lang-markdown/node_modules/@codemirror/autocomplete": { - "version": "6.18.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz", - "integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" - } - }, "node_modules/@codemirror/language": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz", @@ -3647,24 +3588,6 @@ "@codemirror/view": "^6.0.0" } }, - "node_modules/codemirror/node_modules/@codemirror/autocomplete": { - "version": "6.18.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz", - "integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" - } - }, "node_modules/codemirror/node_modules/@codemirror/lint": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz", diff --git a/app/package.json b/app/package.json index 942962b..26b6455 100644 --- a/app/package.json +++ b/app/package.json @@ -29,6 +29,7 @@ }, "homepage": "https://github.com/LordMathis/Lemma#readme", "dependencies": { + "@codemirror/autocomplete": "^6.19.1", "@codemirror/commands": "^6.10.0", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/state": "^6.5.2", diff --git a/app/src/components/editor/ContentView.test.tsx b/app/src/components/editor/ContentView.test.tsx index 706192c..4ba25d6 100644 --- a/app/src/components/editor/ContentView.test.tsx +++ b/app/src/components/editor/ContentView.test.tsx @@ -104,6 +104,7 @@ describe('ContentView', () => { handleContentChange={mockHandleContentChange} handleSave={mockHandleSave} handleFileSelect={mockHandleFileSelect} + files={[]} /> ); @@ -121,6 +122,7 @@ describe('ContentView', () => { handleContentChange={mockHandleContentChange} handleSave={mockHandleSave} handleFileSelect={mockHandleFileSelect} + files={[]} /> ); @@ -138,6 +140,7 @@ describe('ContentView', () => { handleContentChange={mockHandleContentChange} handleSave={mockHandleSave} handleFileSelect={mockHandleFileSelect} + files={[]} /> ); @@ -157,6 +160,7 @@ describe('ContentView', () => { handleContentChange={mockHandleContentChange} handleSave={mockHandleSave} handleFileSelect={mockHandleFileSelect} + files={[]} /> ); @@ -179,6 +183,7 @@ describe('ContentView', () => { handleContentChange={mockHandleContentChange} handleSave={mockHandleSave} handleFileSelect={mockHandleFileSelect} + files={[]} /> ); @@ -208,6 +213,7 @@ describe('ContentView', () => { handleContentChange={mockHandleContentChange} handleSave={mockHandleSave} handleFileSelect={mockHandleFileSelect} + files={[]} /> ); diff --git a/app/src/components/editor/ContentView.tsx b/app/src/components/editor/ContentView.tsx index 4d43b14..5937629 100644 --- a/app/src/components/editor/ContentView.tsx +++ b/app/src/components/editor/ContentView.tsx @@ -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; handleFileSelect: (filePath: string | null) => Promise; + files: FileNode[]; } const ContentView: React.FC = ({ @@ -23,6 +25,7 @@ const ContentView: React.FC = ({ handleContentChange, handleSave, handleFileSelect, + files, }) => { const { currentWorkspace } = useWorkspace(); if (!currentWorkspace) { @@ -67,6 +70,7 @@ const ContentView: React.FC = ({ handleContentChange={handleContentChange} handleSave={handleSave} selectedFile={selectedFile} + files={files} /> ) : ( diff --git a/app/src/components/editor/Editor.tsx b/app/src/components/editor/Editor.tsx index bbff99f..530495e 100644 --- a/app/src/components/editor/Editor.tsx +++ b/app/src/components/editor/Editor.tsx @@ -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; selectedFile: string; + files: FileNode[]; } const Editor: React.FC = ({ @@ -19,11 +24,19 @@ const Editor: React.FC = ({ handleContentChange, handleSave, selectedFile, + files, }) => { - const { colorScheme } = useWorkspace(); + const { colorScheme, currentWorkspace } = useWorkspace(); const editorRef = useRef(null); const viewRef = useRef(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 = ({ }), theme, colorScheme === 'dark' ? oneDark : [], + autocompletion({ + override: [createWikiLinkCompletions(flatFiles)], + activateOnTyping: true, + maxRenderedOptions: 10, + closeOnBlur: true, + }), ], }); @@ -87,7 +106,7 @@ const Editor: React.FC = ({ }; // 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()) { diff --git a/app/src/components/layout/Layout.tsx b/app/src/components/layout/Layout.tsx index 670dcfc..dfdc838 100644 --- a/app/src/components/layout/Layout.tsx +++ b/app/src/components/layout/Layout.tsx @@ -53,6 +53,7 @@ const Layout: React.FC = () => { selectedFile={selectedFile} handleFileSelect={handleFileSelect} loadFileList={loadFileList} + files={files} /> diff --git a/app/src/components/layout/MainContent.test.tsx b/app/src/components/layout/MainContent.test.tsx index 769206d..ac82b1b 100644 --- a/app/src/components/layout/MainContent.test.tsx +++ b/app/src/components/layout/MainContent.test.tsx @@ -131,6 +131,7 @@ describe('MainContent', () => { selectedFile="docs/guide.md" handleFileSelect={mockHandleFileSelect} loadFileList={mockLoadFileList} + files={[]} /> ); @@ -156,6 +157,7 @@ describe('MainContent', () => { selectedFile="test.md" handleFileSelect={mockHandleFileSelect} loadFileList={mockLoadFileList} + files={[]} /> ); @@ -172,6 +174,7 @@ describe('MainContent', () => { selectedFile="test.md" handleFileSelect={mockHandleFileSelect} loadFileList={mockLoadFileList} + files={[]} /> ); @@ -188,6 +191,7 @@ describe('MainContent', () => { selectedFile={null} handleFileSelect={mockHandleFileSelect} loadFileList={mockLoadFileList} + files={[]} /> ); diff --git a/app/src/components/layout/MainContent.tsx b/app/src/components/layout/MainContent.tsx index 67f30b6..98e4ed2 100644 --- a/app/src/components/layout/MainContent.tsx +++ b/app/src/components/layout/MainContent.tsx @@ -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; loadFileList: () => Promise; + files: FileNode[]; } const MainContent: React.FC = ({ selectedFile, handleFileSelect, loadFileList, + files, }) => { const [activeTab, setActiveTab] = useState('source'); const { @@ -161,6 +164,7 @@ const MainContent: React.FC = ({ handleContentChange={handleContentChange} handleSave={handleSaveFile} handleFileSelect={handleFileSelect} + files={files} /> diff --git a/app/src/utils/fileHelpers.ts b/app/src/utils/fileHelpers.ts index 2f56f8d..49486f8 100644 --- a/app/src/utils/fileHelpers.ts +++ b/app/src/utils/fileHelpers.ts @@ -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; +} diff --git a/app/src/utils/fuzzyMatch.ts b/app/src/utils/fuzzyMatch.ts new file mode 100644 index 0000000..1d2fe14 --- /dev/null +++ b/app/src/utils/fuzzyMatch.ts @@ -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() + ); +} diff --git a/app/src/utils/remarkWikiLinks.ts b/app/src/utils/remarkWikiLinks.ts index 8a5fbe8..e27db56 100644 --- a/app/src/utils/remarkWikiLinks.ts +++ b/app/src/utils/remarkWikiLinks.ts @@ -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); diff --git a/app/src/utils/wikiLinkCompletion.ts b/app/src/utils/wikiLinkCompletion.ts new file mode 100644 index 0000000..6cce4fe --- /dev/null +++ b/app/src/utils/wikiLinkCompletion.ts @@ -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, + }; +}