mirror of
https://github.com/lordmathis/lemma.git
synced 2025-12-25 02:44:22 +00:00
Add autocompletion for wiki links
This commit is contained in:
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