|
|
|
|
@@ -1,6 +1,5 @@
|
|
|
|
|
import * as vscode from "vscode";
|
|
|
|
|
import * as path from "path";
|
|
|
|
|
import * as fs from "fs/promises";
|
|
|
|
|
import { createReadStream } from "fs";
|
|
|
|
|
import * as readline from "readline";
|
|
|
|
|
|
|
|
|
|
@@ -50,9 +49,14 @@ function extractSearchText(pattern: string): string {
|
|
|
|
|
* Parse a ctags file using streaming readline.
|
|
|
|
|
* Avoids loading the entire file into memory as a single string.
|
|
|
|
|
*/
|
|
|
|
|
function streamParseCtagsFile(tagsPath: string): Promise<CtagsEntry[]> {
|
|
|
|
|
function streamParseCtagsFile(
|
|
|
|
|
tagsPath: string,
|
|
|
|
|
onProgress?: (count: number) => void,
|
|
|
|
|
): Promise<CtagsEntry[]> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const entries: CtagsEntry[] = [];
|
|
|
|
|
let progressCounter = 0;
|
|
|
|
|
const progressInterval = 50000; // report every 50K entries
|
|
|
|
|
const rl = readline.createInterface({
|
|
|
|
|
input: createReadStream(tagsPath, { encoding: "utf-8" }),
|
|
|
|
|
crlfDelay: Infinity,
|
|
|
|
|
@@ -118,9 +122,17 @@ function streamParseCtagsFile(tagsPath: string): Promise<CtagsEntry[]> {
|
|
|
|
|
lineNumber,
|
|
|
|
|
scope,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
progressCounter++;
|
|
|
|
|
if (onProgress && progressCounter % progressInterval === 0) {
|
|
|
|
|
onProgress(progressCounter);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
rl.on("close", () => resolve(entries));
|
|
|
|
|
rl.on("close", () => {
|
|
|
|
|
if (onProgress) { onProgress(entries.length); }
|
|
|
|
|
resolve(entries);
|
|
|
|
|
});
|
|
|
|
|
rl.on("error", (err: Error) => reject(err));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
@@ -224,21 +236,31 @@ function substringSearch(db: TagDatabase, query: string, limit: number): CtagsEn
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- URI cache ----
|
|
|
|
|
|
|
|
|
|
/** Cache of file URIs to avoid redundant Uri.joinPath calls */
|
|
|
|
|
let fileUriCache = new Map<string, vscode.Uri>();
|
|
|
|
|
|
|
|
|
|
function getFileUri(relPath: string): vscode.Uri {
|
|
|
|
|
let uri = fileUriCache.get(relPath);
|
|
|
|
|
if (!uri) {
|
|
|
|
|
uri = vscode.Uri.joinPath(wsRootUri, relPath);
|
|
|
|
|
fileUriCache.set(relPath, uri);
|
|
|
|
|
}
|
|
|
|
|
return uri;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Lazy line number resolution ----
|
|
|
|
|
|
|
|
|
|
/** Cache of file contents for lazy resolution, keyed by absolute path */
|
|
|
|
|
const fileContentCache = new Map<string, string[] | null>();
|
|
|
|
|
|
|
|
|
|
async function getFileLines(absPath: string): Promise<string[] | null> {
|
|
|
|
|
const cached = fileContentCache.get(absPath);
|
|
|
|
|
if (cached !== undefined) { return cached; }
|
|
|
|
|
/**
|
|
|
|
|
* Get file contents via VS Code's document model.
|
|
|
|
|
* Uses already-loaded buffers when available, picks up unsaved changes,
|
|
|
|
|
* and lets VS Code manage its own caching — no duplicate memory usage.
|
|
|
|
|
*/
|
|
|
|
|
async function getDocument(uri: vscode.Uri): Promise<vscode.TextDocument | null> {
|
|
|
|
|
try {
|
|
|
|
|
const content = await fs.readFile(absPath, "utf-8");
|
|
|
|
|
const lines = content.split("\n");
|
|
|
|
|
fileContentCache.set(absPath, lines);
|
|
|
|
|
return lines;
|
|
|
|
|
return await vscode.workspace.openTextDocument(uri);
|
|
|
|
|
} catch {
|
|
|
|
|
fileContentCache.set(absPath, null);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -247,7 +269,7 @@ async function getFileLines(absPath: string): Promise<string[] | null> {
|
|
|
|
|
* Resolve the line number of an entry lazily. If already resolved, returns immediately.
|
|
|
|
|
* Otherwise reads the source file (with caching) and finds the pattern.
|
|
|
|
|
*/
|
|
|
|
|
async function resolveLineNumber(entry: CtagsEntry, root: string): Promise<number> {
|
|
|
|
|
async function resolveLineNumber(entry: CtagsEntry, rootUri: vscode.Uri): Promise<number> {
|
|
|
|
|
if (entry.lineNumber >= 0) { return entry.lineNumber; }
|
|
|
|
|
|
|
|
|
|
const searchText = extractSearchText(entry.pattern);
|
|
|
|
|
@@ -256,15 +278,14 @@ async function resolveLineNumber(entry: CtagsEntry, root: string): Promise<numbe
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const absPath = path.join(root, entry.file);
|
|
|
|
|
const lines = await getFileLines(absPath);
|
|
|
|
|
if (!lines) {
|
|
|
|
|
const doc = await getDocument(getFileUri(entry.file));
|
|
|
|
|
if (!doc) {
|
|
|
|
|
entry.lineNumber = 0;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
if (lines[i].includes(searchText)) {
|
|
|
|
|
for (let i = 0; i < doc.lineCount; i++) {
|
|
|
|
|
if (doc.lineAt(i).text.includes(searchText)) {
|
|
|
|
|
entry.lineNumber = i;
|
|
|
|
|
return i;
|
|
|
|
|
}
|
|
|
|
|
@@ -275,36 +296,18 @@ async function resolveLineNumber(entry: CtagsEntry, root: string): Promise<numbe
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve line numbers for a batch of entries.
|
|
|
|
|
* Groups by file to avoid redundant reads.
|
|
|
|
|
*/
|
|
|
|
|
async function resolveEntries(entries: CtagsEntry[], root: string): Promise<void> {
|
|
|
|
|
// Collect unique files that need resolution
|
|
|
|
|
const filesToResolve = new Set<string>();
|
|
|
|
|
async function resolveEntries(entries: CtagsEntry[], rootUri: vscode.Uri): Promise<void> {
|
|
|
|
|
for (const e of entries) {
|
|
|
|
|
if (e.lineNumber === -1) {
|
|
|
|
|
filesToResolve.add(e.file);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Pre-load files in parallel (up to 20 at a time)
|
|
|
|
|
const files = Array.from(filesToResolve);
|
|
|
|
|
const batchSize = 20;
|
|
|
|
|
for (let i = 0; i < files.length; i += batchSize) {
|
|
|
|
|
const batch = files.slice(i, i + batchSize);
|
|
|
|
|
await Promise.all(batch.map(f => getFileLines(path.join(root, f))));
|
|
|
|
|
}
|
|
|
|
|
// Now resolve all entries (file contents are cached)
|
|
|
|
|
for (const e of entries) {
|
|
|
|
|
if (e.lineNumber === -1) {
|
|
|
|
|
await resolveLineNumber(e, root);
|
|
|
|
|
await resolveLineNumber(e, rootUri);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function entryToLocation(entry: CtagsEntry, root: string): vscode.Location {
|
|
|
|
|
function entryToLocation(entry: CtagsEntry): vscode.Location {
|
|
|
|
|
const ln = entry.lineNumber >= 0 ? entry.lineNumber : 0;
|
|
|
|
|
const uri = vscode.Uri.file(path.join(root, entry.file));
|
|
|
|
|
const pos = new vscode.Position(ln, 0);
|
|
|
|
|
return new vscode.Location(uri, pos);
|
|
|
|
|
return new vscode.Location(getFileUri(entry.file), new vscode.Position(ln, 0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function entryToSymbolKind(kind: string): vscode.SymbolKind {
|
|
|
|
|
@@ -358,6 +361,7 @@ function formatDuration(ms: number): string {
|
|
|
|
|
|
|
|
|
|
let db: TagDatabase = { entries: [], nameIndex: new Map(), fileIndex: new Map(), sorted: [] };
|
|
|
|
|
let wsRoot = "";
|
|
|
|
|
let wsRootUri: vscode.Uri;
|
|
|
|
|
let statusBarItem: vscode.StatusBarItem;
|
|
|
|
|
let isLoading = false;
|
|
|
|
|
|
|
|
|
|
@@ -376,7 +380,7 @@ function updateStatusBar() {
|
|
|
|
|
statusBarItem.show();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadTags(): Promise<boolean> {
|
|
|
|
|
async function loadTags(progress?: vscode.Progress<{ message?: string; increment?: number }>): Promise<boolean> {
|
|
|
|
|
if (!wsRoot) { return false; }
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
logInfo("Load already in progress, skipping.");
|
|
|
|
|
@@ -391,17 +395,23 @@ async function loadTags(): Promise<boolean> {
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Stage 1: Stream-parse the file
|
|
|
|
|
logInfo("Stage 1/3: Stream-parsing tags file...");
|
|
|
|
|
logInfo("Stage 1/2: Stream-parsing tags file...");
|
|
|
|
|
const t1 = performance.now();
|
|
|
|
|
|
|
|
|
|
let fileSizeKB = "?";
|
|
|
|
|
let fileSizeBytes = 0;
|
|
|
|
|
try {
|
|
|
|
|
const stat = await fs.stat(tagsPath);
|
|
|
|
|
fileSizeKB = (stat.size / 1024).toFixed(1);
|
|
|
|
|
const stat = await vscode.workspace.fs.stat(vscode.Uri.file(tagsPath));
|
|
|
|
|
fileSizeBytes = stat.size;
|
|
|
|
|
fileSizeKB = (fileSizeBytes / 1024).toFixed(1);
|
|
|
|
|
logInfo(` File size: ${fileSizeKB} KB`);
|
|
|
|
|
} catch { /* stat failed, continue anyway */ }
|
|
|
|
|
|
|
|
|
|
const entries = await streamParseCtagsFile(tagsPath);
|
|
|
|
|
const entries = await streamParseCtagsFile(tagsPath, (count) => {
|
|
|
|
|
const msg = `Parsing tags... ${(count / 1000).toFixed(0)}K entries`;
|
|
|
|
|
statusBarItem.text = `$(sync~spin) vsctags: ${(count / 1000).toFixed(0)}K`;
|
|
|
|
|
if (progress) { progress.report({ message: msg }); }
|
|
|
|
|
});
|
|
|
|
|
const parseTime = performance.now() - t1;
|
|
|
|
|
const withLineNum = entries.filter(e => e.lineNumber >= 0).length;
|
|
|
|
|
const needsResolve = entries.length - withLineNum;
|
|
|
|
|
@@ -409,7 +419,9 @@ async function loadTags(): Promise<boolean> {
|
|
|
|
|
logInfo(` ${withLineNum} with line numbers, ${needsResolve} need lazy resolution`);
|
|
|
|
|
|
|
|
|
|
// Stage 2: Build indexes (name, file, sorted)
|
|
|
|
|
logInfo("Stage 2/3: Building indexes...");
|
|
|
|
|
logInfo("Stage 2/2: Building indexes...");
|
|
|
|
|
if (progress) { progress.report({ message: `Building index for ${entries.length} tags...` }); }
|
|
|
|
|
statusBarItem.text = `$(sync~spin) vsctags: indexing...`;
|
|
|
|
|
const t2 = performance.now();
|
|
|
|
|
const newDb = buildDatabase(entries);
|
|
|
|
|
const indexTime = performance.now() - t2;
|
|
|
|
|
@@ -418,11 +430,8 @@ async function loadTags(): Promise<boolean> {
|
|
|
|
|
logInfo(` Sorted array: ${newDb.sorted.length} entries`);
|
|
|
|
|
logInfo(` Index build took ${formatDuration(indexTime)}`);
|
|
|
|
|
|
|
|
|
|
// Stage 3: Clear file content cache (stale data from previous load)
|
|
|
|
|
logInfo("Stage 3/3: Clearing resolution cache...");
|
|
|
|
|
fileContentCache.clear();
|
|
|
|
|
|
|
|
|
|
// Commit
|
|
|
|
|
// Commit — clear caches before swapping
|
|
|
|
|
fileUriCache.clear();
|
|
|
|
|
db = newDb;
|
|
|
|
|
|
|
|
|
|
// Summary
|
|
|
|
|
@@ -475,9 +484,9 @@ class CtagsDefinitionProvider implements vscode.DefinitionProvider {
|
|
|
|
|
const entries = db.nameIndex.get(word);
|
|
|
|
|
if (!entries || entries.length === 0) { return undefined; }
|
|
|
|
|
// Lazy-resolve line numbers before navigating
|
|
|
|
|
await resolveEntries(entries, wsRoot);
|
|
|
|
|
await resolveEntries(entries, wsRootUri);
|
|
|
|
|
logInfo(`Definition lookup: "${word}" -> ${entries.length} result(s)`);
|
|
|
|
|
return entries.map((e) => entryToLocation(e, wsRoot));
|
|
|
|
|
return entries.map((e) => entryToLocation(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -490,7 +499,7 @@ class CtagsHoverProvider implements vscode.HoverProvider {
|
|
|
|
|
if (!word) { return undefined; }
|
|
|
|
|
const entries = db.nameIndex.get(word);
|
|
|
|
|
if (!entries || entries.length === 0) { return undefined; }
|
|
|
|
|
await resolveEntries(entries, wsRoot);
|
|
|
|
|
await resolveEntries(entries, wsRootUri);
|
|
|
|
|
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
@@ -505,44 +514,72 @@ class CtagsHoverProvider implements vscode.HoverProvider {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class CtagsWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider {
|
|
|
|
|
provideWorkspaceSymbols(query: string): vscode.SymbolInformation[] {
|
|
|
|
|
public async provideWorkspaceSymbols(query: string, token: vscode.CancellationToken): Promise<vscode.SymbolInformation[]> {
|
|
|
|
|
const t = performance.now();
|
|
|
|
|
const limit = 500;
|
|
|
|
|
const lowerQuery = query.toLowerCase();
|
|
|
|
|
const limit = 200;
|
|
|
|
|
const lowerQuery = query.toLowerCase().trim();
|
|
|
|
|
|
|
|
|
|
let results: CtagsEntry[];
|
|
|
|
|
|
|
|
|
|
if (lowerQuery.length === 0) {
|
|
|
|
|
// No query: return first N entries
|
|
|
|
|
results = db.entries.slice(0, limit);
|
|
|
|
|
} else {
|
|
|
|
|
// Try prefix search first (O(log n + k))
|
|
|
|
|
results = prefixSearch(db, lowerQuery, limit);
|
|
|
|
|
|
|
|
|
|
// If prefix didn't fill up, supplement with substring matches
|
|
|
|
|
if (results.length < limit) {
|
|
|
|
|
const prefixSet = new Set(results);
|
|
|
|
|
const substringResults = substringSearch(db, lowerQuery, limit);
|
|
|
|
|
for (const entry of substringResults) {
|
|
|
|
|
if (!prefixSet.has(entry)) {
|
|
|
|
|
results.push(entry);
|
|
|
|
|
if (results.length >= limit) { break; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!lowerQuery) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const symbols = results.map(entry =>
|
|
|
|
|
new vscode.SymbolInformation(
|
|
|
|
|
entry.name,
|
|
|
|
|
entryToSymbolKind(entry.kind),
|
|
|
|
|
entry.scope,
|
|
|
|
|
entryToLocation(entry, wsRoot),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
if (token.isCancellationRequested) {
|
|
|
|
|
throw new vscode.CancellationError();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logInfo(`Workspace symbol search: "${query}" -> ${symbols.length} result(s) in ${formatDuration(performance.now() - t)}`);
|
|
|
|
|
return symbols;
|
|
|
|
|
try {
|
|
|
|
|
// Fast prefix search via binary search — O(log n + k), synchronous
|
|
|
|
|
const results = prefixSearch(db, lowerQuery, limit);
|
|
|
|
|
|
|
|
|
|
if (token.isCancellationRequested) {``
|
|
|
|
|
throw new vscode.CancellationError();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const symbols = results.map(entry =>
|
|
|
|
|
new vscode.SymbolInformation(
|
|
|
|
|
entry.name,
|
|
|
|
|
entryToSymbolKind(entry.kind),
|
|
|
|
|
entry.scope,
|
|
|
|
|
entryToLocation(entry),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (symbols.length > 0) {
|
|
|
|
|
const s = symbols[0];
|
|
|
|
|
logInfo(`Workspace symbol search: "${query}" -> ${symbols.length} result(s) in ${formatDuration(performance.now() - t)}, sample: uri=${s.location.uri.toString()}, scheme=${s.location.uri.scheme}, line=${s.location.range.start.line}`);
|
|
|
|
|
} else {
|
|
|
|
|
logInfo(`Workspace symbol search: "${query}" -> 0 result(s) in ${formatDuration(performance.now() - t)}`);
|
|
|
|
|
}
|
|
|
|
|
return symbols;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof vscode.CancellationError) {
|
|
|
|
|
logInfo(`Workspace symbol search cancelled for "${query}" after ${formatDuration(performance.now() - t)}`);
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
logError(`Workspace symbol search failed for "${query}": ${err}`);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async resolveWorkspaceSymbol(symbol: vscode.SymbolInformation): Promise<vscode.SymbolInformation | undefined> {
|
|
|
|
|
const t = performance.now();
|
|
|
|
|
// Resolve the exact line number when the user selects a symbol
|
|
|
|
|
const relPath = vscode.workspace.asRelativePath(symbol.location.uri, false);
|
|
|
|
|
const entries = db.nameIndex.get(symbol.name);
|
|
|
|
|
if (!entries) {
|
|
|
|
|
logInfo(`resolveWorkspaceSymbol: "${symbol.name}" no entries, ${formatDuration(performance.now() - t)}`);
|
|
|
|
|
return symbol;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const match = entries.find(e => e.file === relPath);
|
|
|
|
|
if (match) {
|
|
|
|
|
if (match.lineNumber === -1) {
|
|
|
|
|
await resolveLineNumber(match, wsRootUri);
|
|
|
|
|
}
|
|
|
|
|
symbol.location = entryToLocation(match);
|
|
|
|
|
}
|
|
|
|
|
logInfo(`resolveWorkspaceSymbol: "${symbol.name}" in ${formatDuration(performance.now() - t)}`);
|
|
|
|
|
return symbol;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -565,7 +602,7 @@ class CtagsDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
|
|
|
|
|
entry.name,
|
|
|
|
|
entryToSymbolKind(entry.kind),
|
|
|
|
|
entry.scope,
|
|
|
|
|
entryToLocation(entry, wsRoot),
|
|
|
|
|
entryToLocation(entry),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
@@ -583,9 +620,9 @@ class CtagsReferenceProvider implements vscode.ReferenceProvider {
|
|
|
|
|
if (!word) { return undefined; }
|
|
|
|
|
const entries = db.nameIndex.get(word);
|
|
|
|
|
if (!entries || entries.length === 0) { return undefined; }
|
|
|
|
|
await resolveEntries(entries, wsRoot);
|
|
|
|
|
await resolveEntries(entries, wsRootUri);
|
|
|
|
|
logInfo(`References lookup: "${word}" -> ${entries.length} result(s)`);
|
|
|
|
|
return entries.map((e) => entryToLocation(e, wsRoot));
|
|
|
|
|
return entries.map((e) => entryToLocation(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -602,7 +639,8 @@ export function activate(context: vscode.ExtensionContext) {
|
|
|
|
|
logInfo("No workspace folder open. Extension idle.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
wsRoot = folders[0].uri.fsPath;
|
|
|
|
|
wsRootUri = folders[0].uri;
|
|
|
|
|
wsRoot = wsRootUri.fsPath;
|
|
|
|
|
logInfo(`Workspace root: ${wsRoot}`);
|
|
|
|
|
|
|
|
|
|
// Status bar
|
|
|
|
|
@@ -612,10 +650,10 @@ export function activate(context: vscode.ExtensionContext) {
|
|
|
|
|
|
|
|
|
|
// Initial load with progress
|
|
|
|
|
vscode.window.withProgress(
|
|
|
|
|
{ location: vscode.ProgressLocation.Window, title: "vsctags: Loading tags..." },
|
|
|
|
|
{ location: vscode.ProgressLocation.Window, title: "vsctags" },
|
|
|
|
|
async (progress) => {
|
|
|
|
|
progress.report({ message: "Reading tags file..." });
|
|
|
|
|
const ok = await loadTags();
|
|
|
|
|
progress.report({ message: "Loading tags..." });
|
|
|
|
|
const ok = await loadTags(progress);
|
|
|
|
|
if (ok) {
|
|
|
|
|
progress.report({ message: `Loaded ${db.entries.length} tags` });
|
|
|
|
|
}
|
|
|
|
|
@@ -634,24 +672,31 @@ export function activate(context: vscode.ExtensionContext) {
|
|
|
|
|
);
|
|
|
|
|
logInfo("Language providers registered (definition, hover, workspace symbols, document symbols, references)");
|
|
|
|
|
|
|
|
|
|
// Watch the tags file for changes
|
|
|
|
|
// Watch the tags file for changes (debounced)
|
|
|
|
|
const tagsPattern = new vscode.RelativePattern(folders[0], "tags");
|
|
|
|
|
const watcher = vscode.workspace.createFileSystemWatcher(tagsPattern);
|
|
|
|
|
const debounceMs = 2000;
|
|
|
|
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
|
|
|
|
|
|
watcher.onDidChange(async () => {
|
|
|
|
|
logInfo("Tags file changed on disk. Reloading...");
|
|
|
|
|
await loadTags();
|
|
|
|
|
vscode.window.showInformationMessage(`[vsctags] Reloaded ${db.entries.length} tags.`);
|
|
|
|
|
});
|
|
|
|
|
watcher.onDidCreate(async () => {
|
|
|
|
|
logInfo("Tags file created. Loading...");
|
|
|
|
|
await loadTags();
|
|
|
|
|
vscode.window.showInformationMessage(`[vsctags] Loaded ${db.entries.length} tags.`);
|
|
|
|
|
});
|
|
|
|
|
function debouncedReload(reason: string) {
|
|
|
|
|
if (debounceTimer) { clearTimeout(debounceTimer); }
|
|
|
|
|
logInfo(`Tags file ${reason}. Waiting ${debounceMs}ms for stability...`);
|
|
|
|
|
statusBarItem.text = "$(sync~spin) vsctags: file changed...";
|
|
|
|
|
debounceTimer = setTimeout(async () => {
|
|
|
|
|
debounceTimer = undefined;
|
|
|
|
|
logInfo("Debounce elapsed, reloading tags.");
|
|
|
|
|
await loadTags();
|
|
|
|
|
vscode.window.showInformationMessage(`[vsctags] Reloaded ${db.entries.length} tags.`);
|
|
|
|
|
}, debounceMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watcher.onDidChange(() => debouncedReload("changed"));
|
|
|
|
|
watcher.onDidCreate(() => debouncedReload("created"));
|
|
|
|
|
watcher.onDidDelete(() => {
|
|
|
|
|
if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = undefined; }
|
|
|
|
|
logInfo("Tags file deleted.");
|
|
|
|
|
db = { entries: [], nameIndex: new Map(), fileIndex: new Map(), sorted: [] };
|
|
|
|
|
fileContentCache.clear();
|
|
|
|
|
fileUriCache.clear();
|
|
|
|
|
updateStatusBar();
|
|
|
|
|
vscode.window.showInformationMessage("[vsctags] Tags file removed.");
|
|
|
|
|
});
|
|
|
|
|
@@ -665,7 +710,7 @@ export function activate(context: vscode.ExtensionContext) {
|
|
|
|
|
logInfo("Manual reload requested.");
|
|
|
|
|
const ok = await vscode.window.withProgress(
|
|
|
|
|
{ location: vscode.ProgressLocation.Notification, title: "vsctags: Reloading tags..." },
|
|
|
|
|
async () => loadTags(),
|
|
|
|
|
async (progress) => loadTags(progress),
|
|
|
|
|
);
|
|
|
|
|
if (ok) {
|
|
|
|
|
vscode.window.showInformationMessage(`[vsctags] Reloaded ${db.entries.length} tags.`);
|
|
|
|
|
@@ -682,10 +727,217 @@ export function activate(context: vscode.ExtensionContext) {
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Welcome page command
|
|
|
|
|
context.subscriptions.push(
|
|
|
|
|
vscode.commands.registerCommand("vsctags.showWelcome", () => {
|
|
|
|
|
showWelcomePage(context);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Auto-show welcome page on first install
|
|
|
|
|
const hasShownWelcome = context.globalState.get<boolean>("vsctags.welcomeShown");
|
|
|
|
|
if (!hasShownWelcome) {
|
|
|
|
|
showWelcomePage(context);
|
|
|
|
|
context.globalState.update("vsctags.welcomeShown", true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logInfo("Extension activated.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Welcome Page ----
|
|
|
|
|
|
|
|
|
|
async function showWelcomePage(context: vscode.ExtensionContext) {
|
|
|
|
|
const panel = vscode.window.createWebviewPanel(
|
|
|
|
|
"vsctags.welcome",
|
|
|
|
|
"vsctags — Welcome",
|
|
|
|
|
vscode.ViewColumn.One,
|
|
|
|
|
{ enableScripts: false },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let markdown = "";
|
|
|
|
|
try {
|
|
|
|
|
// Try both cases — vsce may lowercase the filename in the VSIX
|
|
|
|
|
let bytes: Uint8Array | undefined;
|
|
|
|
|
for (const name of ["README.md", "readme.md"]) {
|
|
|
|
|
try {
|
|
|
|
|
bytes = await vscode.workspace.fs.readFile(vscode.Uri.joinPath(context.extensionUri, name));
|
|
|
|
|
break;
|
|
|
|
|
} catch { /* try next */ }
|
|
|
|
|
}
|
|
|
|
|
if (bytes) {
|
|
|
|
|
markdown = Buffer.from(bytes).toString("utf-8");
|
|
|
|
|
} else {
|
|
|
|
|
logError(`README not found at extensionUri: ${context.extensionUri.toString()}`);
|
|
|
|
|
markdown = "# vsctags\n\nREADME.md not found.";
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
markdown = "# vsctags\n\nREADME.md not found.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
panel.webview.html = renderMarkdownHtml(markdown);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Minimal Markdown to HTML renderer — no dependencies */
|
|
|
|
|
function renderMarkdownHtml(md: string): string {
|
|
|
|
|
let html = escapeHtml(md);
|
|
|
|
|
|
|
|
|
|
// Code blocks (``` ... ```)
|
|
|
|
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => {
|
|
|
|
|
return `<pre><code class="language-${lang}">${code.trim()}</code></pre>`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Inline code
|
|
|
|
|
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
|
|
|
|
|
|
|
|
// Tables
|
|
|
|
|
html = html.replace(/((?:^\|.+\|\s*$\n?)+)/gm, (_m, table: string) => {
|
|
|
|
|
const rows = table.trim().split("\n").filter(r => r.trim().length > 0);
|
|
|
|
|
if (rows.length < 2) { return table; }
|
|
|
|
|
|
|
|
|
|
// Check if second row is separator (|---|---|)
|
|
|
|
|
const isSep = /^\|[\s\-:|]+\|$/.test(rows[1].trim());
|
|
|
|
|
if (!isSep) { return table; }
|
|
|
|
|
|
|
|
|
|
const parseRow = (row: string) =>
|
|
|
|
|
row.split("|").slice(1, -1).map(c => c.trim());
|
|
|
|
|
|
|
|
|
|
const headerCells = parseRow(rows[0]);
|
|
|
|
|
const thead = "<tr>" + headerCells.map(c => `<th>${c}</th>`).join("") + "</tr>";
|
|
|
|
|
|
|
|
|
|
const bodyRows = rows.slice(2).map(row => {
|
|
|
|
|
const cells = parseRow(row);
|
|
|
|
|
return "<tr>" + cells.map(c => `<td>${c}</td>`).join("") + "</tr>";
|
|
|
|
|
}).join("\n");
|
|
|
|
|
|
|
|
|
|
return `<table><thead>${thead}</thead><tbody>${bodyRows}</tbody></table>`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Headers
|
|
|
|
|
html = html.replace(/^######\s+(.+)$/gm, "<h6>$1</h6>");
|
|
|
|
|
html = html.replace(/^#####\s+(.+)$/gm, "<h5>$1</h5>");
|
|
|
|
|
html = html.replace(/^####\s+(.+)$/gm, "<h4>$1</h4>");
|
|
|
|
|
html = html.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>");
|
|
|
|
|
html = html.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>");
|
|
|
|
|
html = html.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>");
|
|
|
|
|
|
|
|
|
|
// Bold + italic
|
|
|
|
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
|
|
|
|
|
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
|
|
|
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
|
|
|
|
|
|
|
|
|
// Links
|
|
|
|
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
|
|
|
|
|
|
|
|
// Unordered lists
|
|
|
|
|
html = html.replace(/((?:^- .+$\n?)+)/gm, (_m, block: string) => {
|
|
|
|
|
const items = block.trim().split("\n").map(l => `<li>${l.replace(/^- /, "")}</li>`);
|
|
|
|
|
return `<ul>${items.join("\n")}</ul>`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Ordered lists
|
|
|
|
|
html = html.replace(/((?:^\d+\.\s.+$\n?)+)/gm, (_m, block: string) => {
|
|
|
|
|
const items = block.trim().split("\n").map(l => `<li>${l.replace(/^\d+\.\s/, "")}</li>`);
|
|
|
|
|
return `<ol>${items.join("\n")}</ol>`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Horizontal rules
|
|
|
|
|
html = html.replace(/^---$/gm, "<hr>");
|
|
|
|
|
|
|
|
|
|
// Paragraphs — wrap remaining loose lines
|
|
|
|
|
html = html.replace(/^(?!<[a-z])(\S.+)$/gm, "<p>$1</p>");
|
|
|
|
|
|
|
|
|
|
// Clean up empty lines
|
|
|
|
|
html = html.replace(/\n{3,}/g, "\n\n");
|
|
|
|
|
|
|
|
|
|
return `<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<style>
|
|
|
|
|
body {
|
|
|
|
|
font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
|
|
|
|
font-size: var(--vscode-font-size, 14px);
|
|
|
|
|
color: var(--vscode-foreground);
|
|
|
|
|
background: var(--vscode-editor-background);
|
|
|
|
|
max-width: 800px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: 24px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
}
|
|
|
|
|
h1, h2, h3, h4, h5, h6 {
|
|
|
|
|
color: var(--vscode-foreground);
|
|
|
|
|
border-bottom: 1px solid var(--vscode-panel-border, #333);
|
|
|
|
|
padding-bottom: 4px;
|
|
|
|
|
margin-top: 24px;
|
|
|
|
|
}
|
|
|
|
|
h1 { font-size: 2em; }
|
|
|
|
|
h2 { font-size: 1.5em; }
|
|
|
|
|
h3 { font-size: 1.25em; }
|
|
|
|
|
code {
|
|
|
|
|
font-family: var(--vscode-editor-font-family, 'Menlo', 'Consolas', monospace);
|
|
|
|
|
background: var(--vscode-textCodeBlock-background, rgba(128,128,128,0.15));
|
|
|
|
|
padding: 2px 5px;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
font-size: 0.9em;
|
|
|
|
|
}
|
|
|
|
|
pre {
|
|
|
|
|
background: var(--vscode-textCodeBlock-background, rgba(128,128,128,0.15));
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
}
|
|
|
|
|
pre code {
|
|
|
|
|
background: none;
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
table {
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
width: 100%;
|
|
|
|
|
margin: 16px 0;
|
|
|
|
|
}
|
|
|
|
|
th, td {
|
|
|
|
|
border: 1px solid var(--vscode-panel-border, #444);
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
}
|
|
|
|
|
th {
|
|
|
|
|
background: var(--vscode-textCodeBlock-background, rgba(128,128,128,0.15));
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
a {
|
|
|
|
|
color: var(--vscode-textLink-foreground, #3794ff);
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
}
|
|
|
|
|
a:hover {
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
}
|
|
|
|
|
ul, ol {
|
|
|
|
|
padding-left: 24px;
|
|
|
|
|
}
|
|
|
|
|
li {
|
|
|
|
|
margin: 4px 0;
|
|
|
|
|
}
|
|
|
|
|
hr {
|
|
|
|
|
border: none;
|
|
|
|
|
border-top: 1px solid var(--vscode-panel-border, #333);
|
|
|
|
|
margin: 24px 0;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
${html}
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(text: string): string {
|
|
|
|
|
return text
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
.replace(/>/g, ">");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deactivate() {
|
|
|
|
|
logInfo("Extension deactivated.");
|
|
|
|
|
fileContentCache.clear();
|
|
|
|
|
}
|
|
|
|
|
|