vecfinder

Document searching tool based on embedding vectors
git clone git://git.finwo.net/app/vecfinder
Log | Files | Refs

commit 34583f3464ede2612ffc906a0bbe46437167feff
parent dd9d7d30ab0380a8f399f9003a36510c0b6d841a
Author: finwo <finwo@pm.me>
Date:   Sat, 21 Mar 2026 05:53:13 +0100

Add bat mode for highlighting

Diffstat:
Mvecfinder.js | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
1 file changed, 111 insertions(+), 32 deletions(-)

diff --git a/vecfinder.js b/vecfinder.js @@ -6,6 +6,7 @@ const http = require('node:http'); const readline = require('node:readline'); const { stdin, stdout, stderr } = process; const EventEmitter = require('node:events'); +const { execSync, spawn } = require('node:child_process'); // Configuration const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.config', 'vecfinder'); @@ -27,6 +28,32 @@ async function loadConfig() { } catch {} } +let hasBat = null; + +function checkBat() { + if (hasBat !== null) return hasBat; + try { + execSync('which bat', { stdio: 'ignore' }); + hasBat = true; + } catch { + hasBat = false; + } + return hasBat; +} + +function stripAnsi(str) { + return str.replace(/\x1b\[[0-9;]*m/g, ''); +} + +async function fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + // Cache function cachePath(filePath) { return path.join(CACHE_DIR, filePath); @@ -271,7 +298,7 @@ async function indexDirectory(store, dirPath) { // UI class UI { - constructor(store) { + constructor(store, useBatFlag = null) { this.store = store; this.query = ''; this.selectedIndex = 0; @@ -283,6 +310,9 @@ class UI { this._previewWidth = null; this.spinnerFrame = 0; this.spinnerInterval = null; + this.useBat = useBatFlag !== null ? useBatFlag : checkBat(); + this._batOutput = null; + this._batFileId = null; this.store.on('documentAdded', () => this.render()); this.store.on('queryUpdated', () => { @@ -325,6 +355,13 @@ class UI { this.listOffset = Math.max(0, Math.min(this.listOffset, Math.max(0, results.length - maxHeight))); } + getMaxPreviewOffset(maxHeight) { + if (this.useBat && this._batOutput) { + return Math.max(0, this._batOutput.split('\n').length - maxHeight); + } + return this._previewWrapped ? Math.max(0, this._previewWrapped.length - maxHeight) : 0; + } + handleKeyPress(str, key) { if (!key) return; @@ -338,7 +375,7 @@ class UI { this.selectedIndex--; this.previewOffset = 0; const height = process.stdout.rows || 24; - const maxHeight = height - 4; + const maxHeight = height - 7; this.adjustListOffset(maxHeight); this.render(); } @@ -351,7 +388,7 @@ class UI { this.selectedIndex++; this.previewOffset = 0; const height = process.stdout.rows || 24; - const maxHeight = height - 4; + const maxHeight = height - 7; this.adjustListOffset(maxHeight); this.render(); } @@ -362,7 +399,7 @@ class UI { this.selectedIndex = 0; this.previewOffset = 0; const height = process.stdout.rows || 24; - const maxHeight = height - 4; + const maxHeight = height - 7; this.adjustListOffset(maxHeight); this.render(); return; @@ -373,7 +410,7 @@ class UI { this.selectedIndex = results.length - 1; this.previewOffset = 0; const height = process.stdout.rows || 24; - const maxHeight = height - 4; + const maxHeight = height - 7; this.adjustListOffset(maxHeight); this.render(); return; @@ -387,8 +424,8 @@ class UI { if (key.name === 'pagedown') { const height = process.stdout.rows || 24; - const previewHeight = height - 4; - const maxPreviewOffset = this._previewWrapped ? Math.max(0, this._previewWrapped.length - previewHeight) : 0; + const maxHeight = height - 7; + const maxPreviewOffset = this.getMaxPreviewOffset(maxHeight); this.previewOffset = Math.min(maxPreviewOffset, this.previewOffset + 10); this.render(); return; @@ -426,25 +463,44 @@ class UI { const leftWidth = Math.floor(width * 0.35); const rightWidth = width - leftWidth - 1; - const maxHeight = height - 4; + const headerHeight = 3; + const maxHeight = height - 4 - headerHeight; const results = this.store.getResults(); - - // Header const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - if (this.store.consumersActive > 0) { - stderr.write(`${frames[this.spinnerFrame % frames.length]} VecFinder\n`); + const spinner = this.store.consumersActive > 0 ? frames[this.spinnerFrame % frames.length] : '\u2713'; + + const titleDecorWidth = '┤ VecFinder ├'.length; + const dashSpace = leftWidth - titleDecorWidth; + const titleCenterLeft = Math.floor(dashSpace / 2); + const titleCenterRight = dashSpace - titleCenterLeft; + + // Title bar + stderr.write('─'.repeat(titleCenterLeft) + '┤ VecFinder ├' + '─'.repeat(titleCenterRight) + '┬' + '─'.repeat(rightWidth) + '\n'); + + // File header line + let leftHeader = 'Files ' + spinner; + leftHeader = leftHeader.padEnd(leftWidth, ' ').slice(0, leftWidth); + + let rightHeader = ''; + const selectedResult = results[this.selectedIndex]; + if (selectedResult) { + const title = 'File: ' + selectedResult.id; + rightHeader = title.padEnd(rightWidth, ' ').slice(0, rightWidth); } else { - stderr.write('\u2713 VecFinder\n'); + rightHeader = ''.padEnd(rightWidth, ' '); } - stderr.write('─'.repeat(leftWidth) + '┬' + '─'.repeat(rightWidth) + '\n'); + + stderr.write(leftHeader + '│' + rightHeader + '\n'); + + // Separator line + stderr.write('─'.repeat(leftWidth) + '┼' + '─'.repeat(rightWidth) + '\n'); // Rows for (let row = 0; row < maxHeight; row++) { const result = results[row + this.listOffset]; const isSelected = (row + this.listOffset === this.selectedIndex); - // Left pane - name left, score right let left; if (result) { let name = result.id; @@ -461,30 +517,50 @@ class UI { } left = left.padEnd(leftWidth, ' ').slice(0, leftWidth); - // Right pane - preview for selected file, limited by pane height let right = ''; - const selectedResult = results[this.selectedIndex]; + const isBatMode = this.useBat && selectedResult?.metadata?.path; if (selectedResult) { - if (!this._previewWrapped || this._previewFileId !== selectedResult.id || this._previewWidth !== rightWidth) { - const lines = selectedResult.content.split('\n'); - this._previewWrapped = []; - for (const line of lines) { - if (line.length === 0) { - this._previewWrapped.push(''); - } else { - for (let i = 0; i < line.length; i += rightWidth) { - this._previewWrapped.push(line.slice(i, i + rightWidth)); + if (isBatMode) { + if (!this._batOutput || this._batFileId !== selectedResult.id) { + try { + this._batOutput = execSync(`bat --color=always --style=numbers --terminal-width=${rightWidth} --wrap=character "${selectedResult.metadata.path}"`, { + maxBuffer: 1024 * 1024, + encoding: 'utf8', + }); + } catch { + this._batOutput = null; + } + this._batFileId = selectedResult.id; + } + if (this._batOutput) { + const lines = this._batOutput.split('\n'); + const visibleLines = lines.slice(this.previewOffset, this.previewOffset + maxHeight); + const line = visibleLines[row] || ''; + const stripped = stripAnsi(line); + const padding = rightWidth - stripped.length; + right = line + ' '.repeat(Math.max(0, padding)) + '\x1b[0m'; + } + } else { + if (!this._previewWrapped || this._previewFileId !== selectedResult.id || this._previewWidth !== rightWidth) { + const lines = selectedResult.content.split('\n'); + this._previewWrapped = []; + for (const line of lines) { + if (line.length === 0) { + this._previewWrapped.push(''); + } else { + for (let i = 0; i < line.length; i += rightWidth) { + this._previewWrapped.push(line.slice(i, i + rightWidth)); + } } } + this._previewFileId = selectedResult.id; + this._previewWidth = rightWidth; } - this._previewFileId = selectedResult.id; - this._previewWidth = rightWidth; + right = this._previewWrapped[row + this.previewOffset] || ''; + right = right.padEnd(rightWidth, ' ').slice(0, rightWidth); } - right = this._previewWrapped[row + this.previewOffset] || ''; } - right = right.padEnd(rightWidth, ' ').slice(0, rightWidth); - // Write if (isSelected) { stderr.write(`\x1b[7m${left}\x1b[0m│${right}`); } else { @@ -501,11 +577,14 @@ class UI { // Main async function main() { + const args = process.argv.slice(2); + const useBatFlag = args.includes('--no-bat') ? false : null; + await loadConfig(); await fs.mkdir(CACHE_DIR, { recursive: true }); const store = new DocumentStore(); - const ui = new UI(store); + const ui = new UI(store, useBatFlag); ui.init();