feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O

- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save,
  dirty tracking, save-on-blur
- PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load
- CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header
- FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading,
  file type routing (code→editor, pdf→viewer, csv→table, images→display)
- 10MB size gate, binary detection, base64 encoding for non-text files
This commit is contained in:
Hibryda 2026-03-22 01:36:02 +01:00
parent 29a3370e79
commit 252fca70df
22 changed files with 8116 additions and 227 deletions

View file

@ -0,0 +1,202 @@
/**
* FTS5 full-text search database bun:sqlite.
*
* Three virtual tables: search_messages, search_tasks, search_btmsg.
* Provides indexing and unified search across all tables.
* DB path: ~/.local/share/agor/search.db
*/
import { Database } from "bun:sqlite";
import { homedir } from "os";
import { mkdirSync } from "fs";
import { join } from "path";
// ── Types ────────────────────────────────────────────────────────────────────
export interface SearchResult {
resultType: string;
id: string;
title: string;
snippet: string;
score: number;
}
// ── DB path ──────────────────────────────────────────────────────────────────
const DATA_DIR = join(homedir(), ".local", "share", "agor");
const DB_PATH = join(DATA_DIR, "search.db");
// ── SearchDb class ───────────────────────────────────────────────────────────
export class SearchDb {
private db: Database;
constructor(dbPath?: string) {
const path = dbPath ?? DB_PATH;
const dir = join(path, "..");
mkdirSync(dir, { recursive: true });
this.db = new Database(path);
this.db.run("PRAGMA journal_mode = WAL");
this.db.run("PRAGMA busy_timeout = 2000");
this.createTables();
}
private createTables(): void {
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS search_messages USING fts5(
session_id,
role,
content,
timestamp
)
`);
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS search_tasks USING fts5(
task_id,
title,
description,
status,
assigned_to
)
`);
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS search_btmsg USING fts5(
message_id,
from_agent,
to_agent,
content,
channel_name
)
`);
}
/** Index an agent message. */
indexMessage(sessionId: string, role: string, content: string): void {
const ts = new Date().toISOString();
this.db.run(
"INSERT INTO search_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
[sessionId, role, content, ts],
);
}
/** Index a task. */
indexTask(taskId: string, title: string, description: string, status: string, assignedTo: string): void {
this.db.run(
"INSERT INTO search_tasks (task_id, title, description, status, assigned_to) VALUES (?, ?, ?, ?, ?)",
[taskId, title, description, status, assignedTo],
);
}
/** Index a btmsg message. */
indexBtmsg(msgId: string, fromAgent: string, toAgent: string, content: string, channel: string): void {
this.db.run(
"INSERT INTO search_btmsg (message_id, from_agent, to_agent, content, channel_name) VALUES (?, ?, ?, ?, ?)",
[msgId, fromAgent, toAgent, content, channel],
);
}
/** Search across all FTS5 tables. */
searchAll(query: string, limit = 20): SearchResult[] {
if (!query.trim()) return [];
const results: SearchResult[] = [];
// Search messages
try {
const msgRows = this.db
.prepare(
`SELECT session_id, role,
snippet(search_messages, 2, '<b>', '</b>', '...', 32) as snip,
rank
FROM search_messages
WHERE search_messages MATCH ?
ORDER BY rank
LIMIT ?`,
)
.all(query, limit) as Array<{ session_id: string; role: string; snip: string; rank: number }>;
for (const row of msgRows) {
results.push({
resultType: "message",
id: row.session_id,
title: row.role,
snippet: row.snip ?? "",
score: Math.abs(row.rank ?? 0),
});
}
} catch {
// FTS5 syntax error — skip messages
}
// Search tasks
try {
const taskRows = this.db
.prepare(
`SELECT task_id, title,
snippet(search_tasks, 2, '<b>', '</b>', '...', 32) as snip,
rank
FROM search_tasks
WHERE search_tasks MATCH ?
ORDER BY rank
LIMIT ?`,
)
.all(query, limit) as Array<{ task_id: string; title: string; snip: string; rank: number }>;
for (const row of taskRows) {
results.push({
resultType: "task",
id: row.task_id,
title: row.title,
snippet: row.snip ?? "",
score: Math.abs(row.rank ?? 0),
});
}
} catch {
// FTS5 syntax error — skip tasks
}
// Search btmsg
try {
const btmsgRows = this.db
.prepare(
`SELECT message_id, from_agent,
snippet(search_btmsg, 3, '<b>', '</b>', '...', 32) as snip,
rank
FROM search_btmsg
WHERE search_btmsg MATCH ?
ORDER BY rank
LIMIT ?`,
)
.all(query, limit) as Array<{ message_id: string; from_agent: string; snip: string; rank: number }>;
for (const row of btmsgRows) {
results.push({
resultType: "btmsg",
id: row.message_id,
title: row.from_agent,
snippet: row.snip ?? "",
score: Math.abs(row.rank ?? 0),
});
}
} catch {
// FTS5 syntax error — skip btmsg
}
// Sort by score (lower = more relevant for FTS5 rank)
results.sort((a, b) => a.score - b.score);
return results.slice(0, limit);
}
/** Drop and recreate all FTS5 tables. */
rebuildIndex(): void {
this.db.run("DROP TABLE IF EXISTS search_messages");
this.db.run("DROP TABLE IF EXISTS search_tasks");
this.db.run("DROP TABLE IF EXISTS search_btmsg");
this.createTables();
}
close(): void {
this.db.close();
}
}