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

@ -1,10 +1,17 @@
import path from "path";
import fs from "fs";
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
import { PtyClient } from "./pty-client.ts";
import { settingsDb } from "./settings-db.ts";
import { sessionDb } from "./session-db.ts";
import { btmsgDb } from "./btmsg-db.ts";
import { bttaskDb } from "./bttask-db.ts";
import { SidecarManager } from "./sidecar-manager.ts";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
import { randomUUID } from "crypto";
import { SearchDb } from "./search-db.ts";
import { homedir } from "os";
import { join } from "path";
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
@ -13,6 +20,8 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
const ptyClient = new PtyClient();
const sidecarManager = new SidecarManager();
const searchDb = new SearchDb();
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
for (let attempt = 1; attempt <= retries; attempt++) {
@ -200,6 +209,79 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
}
},
// ── File I/O handlers ────────────────────────────────────────────────
"files.list": async ({ path: dirPath }) => {
try {
const dirents = fs.readdirSync(dirPath, { withFileTypes: true });
const entries = dirents
.filter((d) => !d.name.startsWith("."))
.map((d) => {
let size = 0;
if (d.isFile()) {
try {
size = fs.statSync(path.join(dirPath, d.name)).size;
} catch { /* ignore stat errors */ }
}
return {
name: d.name,
type: (d.isDirectory() ? "dir" : "file") as "file" | "dir",
size,
};
})
.sort((a, b) => {
// Directories first, then alphabetical
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { entries };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.list]", error);
return { entries: [], error };
}
},
"files.read": async ({ path: filePath }) => {
try {
const stat = fs.statSync(filePath);
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
if (stat.size > MAX_SIZE) {
return { encoding: "utf8" as const, size: stat.size, error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` };
}
// Detect binary by reading first 8KB
const buf = Buffer.alloc(Math.min(8192, stat.size));
const fd = fs.openSync(filePath, "r");
fs.readSync(fd, buf, 0, buf.length, 0);
fs.closeSync(fd);
const isBinary = buf.includes(0); // null byte = binary
if (isBinary) {
const content = fs.readFileSync(filePath).toString("base64");
return { content, encoding: "base64" as const, size: stat.size };
}
const content = fs.readFileSync(filePath, "utf8");
return { content, encoding: "utf8" as const, size: stat.size };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.read]", error);
return { encoding: "utf8" as const, size: 0, error };
}
},
"files.write": async ({ path: filePath, content }) => {
try {
fs.writeFileSync(filePath, content, "utf8");
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.write]", error);
return { ok: false, error };
}
},
// ── Groups handlers ──────────────────────────────────────────────────
"groups.list": () => {
@ -445,6 +527,353 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
return { sessions: [] };
}
},
// ── Session persistence handlers ──────────────────────────────────
"session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }) => {
try {
sessionDb.saveSession({
projectId, sessionId, provider, status, costUsd,
inputTokens, outputTokens, model, error, createdAt, updatedAt,
});
return { ok: true };
} catch (err) {
console.error("[session.save]", err);
return { ok: false };
}
},
"session.load": ({ projectId }) => {
try {
return { session: sessionDb.loadSession(projectId) };
} catch (err) {
console.error("[session.load]", err);
return { session: null };
}
},
"session.list": ({ projectId }) => {
try {
return { sessions: sessionDb.listSessionsByProject(projectId) };
} catch (err) {
console.error("[session.list]", err);
return { sessions: [] };
}
},
"session.messages.save": ({ messages }) => {
try {
sessionDb.saveMessages(messages.map((m) => ({
sessionId: m.sessionId, msgId: m.msgId, role: m.role,
content: m.content, toolName: m.toolName, toolInput: m.toolInput,
timestamp: m.timestamp, costUsd: m.costUsd ?? 0,
inputTokens: m.inputTokens ?? 0, outputTokens: m.outputTokens ?? 0,
})));
return { ok: true };
} catch (err) {
console.error("[session.messages.save]", err);
return { ok: false };
}
},
"session.messages.load": ({ sessionId }) => {
try {
return { messages: sessionDb.loadMessages(sessionId) };
} catch (err) {
console.error("[session.messages.load]", err);
return { messages: [] };
}
},
// ── btmsg handlers ────────────────────────────────────────────────
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }) => {
try {
btmsgDb.registerAgent(id, name, role, groupId, tier, model);
return { ok: true };
} catch (err) {
console.error("[btmsg.registerAgent]", err);
return { ok: false };
}
},
"btmsg.getAgents": ({ groupId }) => {
try {
return { agents: btmsgDb.getAgents(groupId) };
} catch (err) {
console.error("[btmsg.getAgents]", err);
return { agents: [] };
}
},
"btmsg.sendMessage": ({ fromAgent, toAgent, content }) => {
try {
const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content);
return { ok: true, messageId };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[btmsg.sendMessage]", err);
return { ok: false, error };
}
},
"btmsg.listMessages": ({ agentId, otherId, limit }) => {
try {
return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) };
} catch (err) {
console.error("[btmsg.listMessages]", err);
return { messages: [] };
}
},
"btmsg.markRead": ({ agentId, messageIds }) => {
try {
btmsgDb.markRead(agentId, messageIds);
return { ok: true };
} catch (err) {
console.error("[btmsg.markRead]", err);
return { ok: false };
}
},
"btmsg.listChannels": ({ groupId }) => {
try {
return { channels: btmsgDb.listChannels(groupId) };
} catch (err) {
console.error("[btmsg.listChannels]", err);
return { channels: [] };
}
},
"btmsg.createChannel": ({ name, groupId, createdBy }) => {
try {
const channelId = btmsgDb.createChannel(name, groupId, createdBy);
return { ok: true, channelId };
} catch (err) {
console.error("[btmsg.createChannel]", err);
return { ok: false };
}
},
"btmsg.getChannelMessages": ({ channelId, limit }) => {
try {
return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) };
} catch (err) {
console.error("[btmsg.getChannelMessages]", err);
return { messages: [] };
}
},
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }) => {
try {
const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content);
return { ok: true, messageId };
} catch (err) {
console.error("[btmsg.sendChannelMessage]", err);
return { ok: false };
}
},
"btmsg.heartbeat": ({ agentId }) => {
try {
btmsgDb.heartbeat(agentId);
return { ok: true };
} catch (err) {
console.error("[btmsg.heartbeat]", err);
return { ok: false };
}
},
"btmsg.getDeadLetters": ({ limit }) => {
try {
return { letters: btmsgDb.getDeadLetters(limit ?? 50) };
} catch (err) {
console.error("[btmsg.getDeadLetters]", err);
return { letters: [] };
}
},
"btmsg.logAudit": ({ agentId, eventType, detail }) => {
try {
btmsgDb.logAudit(agentId, eventType, detail);
return { ok: true };
} catch (err) {
console.error("[btmsg.logAudit]", err);
return { ok: false };
}
},
"btmsg.getAuditLog": ({ limit }) => {
try {
return { entries: btmsgDb.getAuditLog(limit ?? 100) };
} catch (err) {
console.error("[btmsg.getAuditLog]", err);
return { entries: [] };
}
},
// ── bttask handlers ───────────────────────────────────────────────
"bttask.listTasks": ({ groupId }) => {
try {
return { tasks: bttaskDb.listTasks(groupId) };
} catch (err) {
console.error("[bttask.listTasks]", err);
return { tasks: [] };
}
},
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }) => {
try {
const taskId = bttaskDb.createTask(title, description, priority, groupId, createdBy, assignedTo);
return { ok: true, taskId };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[bttask.createTask]", err);
return { ok: false, error };
}
},
"bttask.updateTaskStatus": ({ taskId, status, expectedVersion }) => {
try {
const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion);
return { ok: true, newVersion };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[bttask.updateTaskStatus]", err);
return { ok: false, error };
}
},
"bttask.deleteTask": ({ taskId }) => {
try {
bttaskDb.deleteTask(taskId);
return { ok: true };
} catch (err) {
console.error("[bttask.deleteTask]", err);
return { ok: false };
}
},
"bttask.addComment": ({ taskId, agentId, content }) => {
try {
const commentId = bttaskDb.addComment(taskId, agentId, content);
return { ok: true, commentId };
} catch (err) {
console.error("[bttask.addComment]", err);
return { ok: false };
}
},
"bttask.listComments": ({ taskId }) => {
try {
return { comments: bttaskDb.listComments(taskId) };
} catch (err) {
console.error("[bttask.listComments]", err);
return { comments: [] };
}
},
"bttask.reviewQueueCount": ({ groupId }) => {
try {
return { count: bttaskDb.reviewQueueCount(groupId) };
} catch (err) {
console.error("[bttask.reviewQueueCount]", err);
return { count: 0 };
}
},
// ── Search handlers ──────────────────────────────────────────────────
"search.query": ({ query, limit }) => {
try {
const results = searchDb.searchAll(query, limit ?? 20);
return { results };
} catch (err) {
console.error("[search.query]", err);
return { results: [] };
}
},
"search.indexMessage": ({ sessionId, role, content }) => {
try {
searchDb.indexMessage(sessionId, role, content);
return { ok: true };
} catch (err) {
console.error("[search.indexMessage]", err);
return { ok: false };
}
},
"search.rebuild": () => {
try {
searchDb.rebuildIndex();
return { ok: true };
} catch (err) {
console.error("[search.rebuild]", err);
return { ok: false };
}
},
// ── Plugin handlers ──────────────────────────────────────────────────
"plugin.discover": () => {
try {
const plugins: Array<{
id: string; name: string; version: string;
description: string; main: string; permissions: string[];
}> = [];
if (!fs.existsSync(PLUGINS_DIR)) return { plugins };
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = join(PLUGINS_DIR, entry.name, "plugin.json");
if (!fs.existsSync(manifestPath)) continue;
try {
const raw = fs.readFileSync(manifestPath, "utf-8");
const manifest = JSON.parse(raw);
plugins.push({
id: manifest.id ?? entry.name,
name: manifest.name ?? entry.name,
version: manifest.version ?? "0.0.0",
description: manifest.description ?? "",
main: manifest.main ?? "index.js",
permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
});
} catch (parseErr) {
console.error(`[plugin.discover] Bad manifest in ${entry.name}:`, parseErr);
}
}
return { plugins };
} catch (err) {
console.error("[plugin.discover]", err);
return { plugins: [] };
}
},
"plugin.readFile": ({ pluginId, filePath }) => {
try {
// Path traversal protection: resolve and verify within plugins dir
const pluginDir = join(PLUGINS_DIR, pluginId);
const resolved = path.resolve(pluginDir, filePath);
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
return { ok: false, content: "", error: "Path traversal blocked" };
}
if (!fs.existsSync(resolved)) {
return { ok: false, content: "", error: "File not found" };
}
const content = fs.readFileSync(resolved, "utf-8");
return { ok: true, content };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[plugin.readFile]", err);
return { ok: false, content: "", error };
}
},
},
messages: {},