fix(electrobun): address all 22 Codex review #2 findings
CRITICAL:
- DocsTab XSS: DOMPurify sanitization on all {@html} output
- File RPC path traversal: guardPath() validates against project CWDs
HIGH:
- SSH injection: spawn /usr/bin/ssh via PTY args, no shell string
- Search XSS: strip HTML, highlight matches client-side with <mark>
- Terminal listener leak: cleanup functions stored + called in onDestroy
- FileBrowser race: request token, discard stale responses
- SearchOverlay race: same request token pattern
- App startup ordering: groups.list chains into active_group restore
- PtyClient timeout: 5-second auth timeout on connect()
- Rule 55: 6 {#if} patterns converted to style:display toggle
MEDIUM:
- Agent persistence: only persist NEW messages (lastPersistedIndex)
- Search errors: typed error response, "Invalid query" UI
- Health store wired: agent events call recordActivity/setProjectStatus
- index.ts SRP: split into 8 domain handler modules (298 lines)
- App.svelte: extracted workspace-store.svelte.ts
- rpc.ts: typed AppRpcHandle, removed `any`
LOW:
- CommandPalette listener wired in App.svelte
- Dead code removed (removeGroup, onDragStart, plugin loaded)
This commit is contained in:
parent
8e756d3523
commit
1cd4558740
28 changed files with 1342 additions and 1164 deletions
17
ui-electrobun/package-lock.json
generated
17
ui-electrobun/package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
|||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.9.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"electrobun": "latest",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
|
|
@ -1501,6 +1502,13 @@
|
|||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@xterm/addon-canvas": {
|
||||
"version": "0.7.0",
|
||||
"license": "MIT",
|
||||
|
|
@ -1778,6 +1786,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electrobun": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/electrobun/-/electrobun-1.16.0.tgz",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.9.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"electrobun": "latest",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
|
|
|
|||
144
ui-electrobun/src/bun/handlers/agent-handlers.ts
Normal file
144
ui-electrobun/src/bun/handlers/agent-handlers.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Agent + Session persistence RPC handlers.
|
||||
*/
|
||||
|
||||
import type { SidecarManager } from "../sidecar-manager.ts";
|
||||
import type { SessionDb } from "../session-db.ts";
|
||||
|
||||
export function createAgentHandlers(
|
||||
sidecarManager: SidecarManager,
|
||||
sessionDb: SessionDb,
|
||||
sendToWebview: { send: Record<string, (...args: unknown[]) => void> },
|
||||
) {
|
||||
return {
|
||||
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record<string, unknown>) => {
|
||||
try {
|
||||
const result = sidecarManager.startSession(
|
||||
sessionId as string,
|
||||
provider as string,
|
||||
prompt as string,
|
||||
{ cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName } as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (result.ok) {
|
||||
sidecarManager.onMessage(sessionId as string, (sid: string, messages: unknown) => {
|
||||
try {
|
||||
(sendToWebview.send as Record<string, Function>)["agent.message"]({ sessionId: sid, messages });
|
||||
} catch (err) {
|
||||
console.error("[agent.message] forward error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
sidecarManager.onStatus(sessionId as string, (sid: string, status: string, error?: string) => {
|
||||
try {
|
||||
(sendToWebview.send as Record<string, Function>)["agent.status"]({ sessionId: sid, status, error });
|
||||
} catch (err) {
|
||||
console.error("[agent.status] forward error:", err);
|
||||
}
|
||||
|
||||
const sessions = sidecarManager.listSessions();
|
||||
const session = sessions.find((s: Record<string, unknown>) => s.sessionId === sid);
|
||||
if (session) {
|
||||
try {
|
||||
(sendToWebview.send as Record<string, Function>)["agent.cost"]({
|
||||
sessionId: sid,
|
||||
costUsd: session.costUsd,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[agent.start]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"agent.stop": ({ sessionId }: { sessionId: string }) => {
|
||||
try {
|
||||
return sidecarManager.stopSession(sessionId);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[agent.stop]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"agent.prompt": ({ sessionId, prompt }: { sessionId: string; prompt: string }) => {
|
||||
try {
|
||||
return sidecarManager.writePrompt(sessionId, prompt);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[agent.prompt]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"agent.list": () => {
|
||||
try {
|
||||
return { sessions: sidecarManager.listSessions() };
|
||||
} catch (err) {
|
||||
console.error("[agent.list]", err);
|
||||
return { sessions: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// Session persistence
|
||||
"session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }: Record<string, unknown>) => {
|
||||
try {
|
||||
sessionDb.saveSession({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt } as Record<string, unknown>);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[session.save]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"session.load": ({ projectId }: { projectId: string }) => {
|
||||
try {
|
||||
return { session: sessionDb.loadSession(projectId) };
|
||||
} catch (err) {
|
||||
console.error("[session.load]", err);
|
||||
return { session: null };
|
||||
}
|
||||
},
|
||||
|
||||
"session.list": ({ projectId }: { projectId: string }) => {
|
||||
try {
|
||||
return { sessions: sessionDb.listSessionsByProject(projectId) };
|
||||
} catch (err) {
|
||||
console.error("[session.list]", err);
|
||||
return { sessions: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"session.messages.save": ({ messages }: { messages: Array<Record<string, unknown>> }) => {
|
||||
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 as number) ?? 0,
|
||||
inputTokens: (m.inputTokens as number) ?? 0, outputTokens: (m.outputTokens as number) ?? 0,
|
||||
})));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[session.messages.save]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"session.messages.load": ({ sessionId }: { sessionId: string }) => {
|
||||
try {
|
||||
return { messages: sessionDb.loadMessages(sessionId) };
|
||||
} catch (err) {
|
||||
console.error("[session.messages.load]", err);
|
||||
return { messages: [] };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
96
ui-electrobun/src/bun/handlers/btmsg-handlers.ts
Normal file
96
ui-electrobun/src/bun/handlers/btmsg-handlers.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* btmsg + bttask RPC handlers.
|
||||
*/
|
||||
|
||||
import type { BtmsgDb } from "../btmsg-db.ts";
|
||||
import type { BttaskDb } from "../bttask-db.ts";
|
||||
|
||||
export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
|
||||
return {
|
||||
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }: Record<string, unknown>) => {
|
||||
try { btmsgDb.registerAgent(id as string, name as string, role as string, groupId as string, tier as number, model as string); return { ok: true }; }
|
||||
catch (err) { console.error("[btmsg.registerAgent]", err); return { ok: false }; }
|
||||
},
|
||||
"btmsg.getAgents": ({ groupId }: { groupId: string }) => {
|
||||
try { return { agents: btmsgDb.getAgents(groupId) }; }
|
||||
catch (err) { console.error("[btmsg.getAgents]", err); return { agents: [] }; }
|
||||
},
|
||||
"btmsg.sendMessage": ({ fromAgent, toAgent, content }: { fromAgent: string; toAgent: string; content: string }) => {
|
||||
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 }: { agentId: string; otherId: string; limit?: number }) => {
|
||||
try { return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) }; }
|
||||
catch (err) { console.error("[btmsg.listMessages]", err); return { messages: [] }; }
|
||||
},
|
||||
"btmsg.markRead": ({ agentId, messageIds }: { agentId: string; messageIds: string[] }) => {
|
||||
try { btmsgDb.markRead(agentId, messageIds); return { ok: true }; }
|
||||
catch (err) { console.error("[btmsg.markRead]", err); return { ok: false }; }
|
||||
},
|
||||
"btmsg.listChannels": ({ groupId }: { groupId: string }) => {
|
||||
try { return { channels: btmsgDb.listChannels(groupId) }; }
|
||||
catch (err) { console.error("[btmsg.listChannels]", err); return { channels: [] }; }
|
||||
},
|
||||
"btmsg.createChannel": ({ name, groupId, createdBy }: { name: string; groupId: string; createdBy: string }) => {
|
||||
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 }: { channelId: string; limit?: number }) => {
|
||||
try { return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) }; }
|
||||
catch (err) { console.error("[btmsg.getChannelMessages]", err); return { messages: [] }; }
|
||||
},
|
||||
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }: { channelId: string; fromAgent: string; content: string }) => {
|
||||
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 }: { agentId: string }) => {
|
||||
try { btmsgDb.heartbeat(agentId); return { ok: true }; }
|
||||
catch (err) { console.error("[btmsg.heartbeat]", err); return { ok: false }; }
|
||||
},
|
||||
"btmsg.getDeadLetters": ({ limit }: { limit?: number }) => {
|
||||
try { return { letters: btmsgDb.getDeadLetters(limit ?? 50) }; }
|
||||
catch (err) { console.error("[btmsg.getDeadLetters]", err); return { letters: [] }; }
|
||||
},
|
||||
"btmsg.logAudit": ({ agentId, eventType, detail }: { agentId: string; eventType: string; detail: string }) => {
|
||||
try { btmsgDb.logAudit(agentId, eventType, detail); return { ok: true }; }
|
||||
catch (err) { console.error("[btmsg.logAudit]", err); return { ok: false }; }
|
||||
},
|
||||
"btmsg.getAuditLog": ({ limit }: { limit?: number }) => {
|
||||
try { return { entries: btmsgDb.getAuditLog(limit ?? 100) }; }
|
||||
catch (err) { console.error("[btmsg.getAuditLog]", err); return { entries: [] }; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBttaskHandlers(bttaskDb: BttaskDb) {
|
||||
return {
|
||||
"bttask.listTasks": ({ groupId }: { groupId: string }) => {
|
||||
try { return { tasks: bttaskDb.listTasks(groupId) }; }
|
||||
catch (err) { console.error("[bttask.listTasks]", err); return { tasks: [] }; }
|
||||
},
|
||||
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }: Record<string, unknown>) => {
|
||||
try { const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string); 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 }: { taskId: string; status: string; expectedVersion: number }) => {
|
||||
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 }: { taskId: string }) => {
|
||||
try { bttaskDb.deleteTask(taskId); return { ok: true }; }
|
||||
catch (err) { console.error("[bttask.deleteTask]", err); return { ok: false }; }
|
||||
},
|
||||
"bttask.addComment": ({ taskId, agentId, content }: { taskId: string; agentId: string; content: string }) => {
|
||||
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 }: { taskId: string }) => {
|
||||
try { return { comments: bttaskDb.listComments(taskId) }; }
|
||||
catch (err) { console.error("[bttask.listComments]", err); return { comments: [] }; }
|
||||
},
|
||||
"bttask.reviewQueueCount": ({ groupId }: { groupId: string }) => {
|
||||
try { return { count: bttaskDb.reviewQueueCount(groupId) }; }
|
||||
catch (err) { console.error("[bttask.reviewQueueCount]", err); return { count: 0 }; }
|
||||
},
|
||||
};
|
||||
}
|
||||
95
ui-electrobun/src/bun/handlers/files-handlers.ts
Normal file
95
ui-electrobun/src/bun/handlers/files-handlers.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* File I/O RPC handlers — list, read, write with path traversal protection.
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { guardPath } from "./path-guard.ts";
|
||||
|
||||
export function createFilesHandlers() {
|
||||
return {
|
||||
"files.list": async ({ path: dirPath }: { path: string }) => {
|
||||
const guard = guardPath(dirPath);
|
||||
if (!guard.valid) {
|
||||
console.error(`[files.list] blocked: ${guard.error}`);
|
||||
return { entries: [], error: guard.error };
|
||||
}
|
||||
try {
|
||||
const dirents = fs.readdirSync(guard.resolved, { withFileTypes: true });
|
||||
const entries = dirents
|
||||
.filter((d) => !d.name.startsWith("."))
|
||||
.map((d) => {
|
||||
let size = 0;
|
||||
if (d.isFile()) {
|
||||
try {
|
||||
size = fs.statSync(path.join(guard.resolved, d.name)).size;
|
||||
} catch { /* ignore stat errors */ }
|
||||
}
|
||||
return {
|
||||
name: d.name,
|
||||
type: (d.isDirectory() ? "dir" : "file") as "file" | "dir",
|
||||
size,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
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 }: { path: string }) => {
|
||||
const guard = guardPath(filePath);
|
||||
if (!guard.valid) {
|
||||
console.error(`[files.read] blocked: ${guard.error}`);
|
||||
return { encoding: "utf8" as const, size: 0, error: guard.error };
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(guard.resolved);
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
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.` };
|
||||
}
|
||||
|
||||
const buf = Buffer.alloc(Math.min(8192, stat.size));
|
||||
const fd = fs.openSync(guard.resolved, "r");
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
fs.closeSync(fd);
|
||||
|
||||
const isBinary = buf.includes(0);
|
||||
if (isBinary) {
|
||||
const content = fs.readFileSync(guard.resolved).toString("base64");
|
||||
return { content, encoding: "base64" as const, size: stat.size };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(guard.resolved, "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 }: { path: string; content: string }) => {
|
||||
const guard = guardPath(filePath);
|
||||
if (!guard.valid) {
|
||||
console.error(`[files.write] blocked: ${guard.error}`);
|
||||
return { ok: false, error: guard.error };
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(guard.resolved, 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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
58
ui-electrobun/src/bun/handlers/path-guard.ts
Normal file
58
ui-electrobun/src/bun/handlers/path-guard.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Path traversal guard — validates that resolved paths stay within allowed boundaries.
|
||||
*
|
||||
* Used by file I/O handlers to prevent path traversal attacks (CWE-22).
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import { settingsDb } from "../settings-db.ts";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
|
||||
|
||||
/** Get all configured project CWDs from settings DB. */
|
||||
function getAllowedRoots(): string[] {
|
||||
const roots: string[] = [PLUGINS_DIR];
|
||||
try {
|
||||
const projects = settingsDb.listProjects();
|
||||
for (const p of projects) {
|
||||
if (typeof p.cwd === "string" && p.cwd) {
|
||||
roots.push(path.resolve(p.cwd));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If settings DB is unavailable, still allow plugins dir
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a file path is within an allowed boundary.
|
||||
* Returns the resolved path if valid, or null if outside boundaries.
|
||||
*/
|
||||
export function validatePath(filePath: string): string | null {
|
||||
const resolved = path.resolve(filePath);
|
||||
const roots = getAllowedRoots();
|
||||
|
||||
for (const root of roots) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check path and return error response if invalid.
|
||||
* Returns { valid: true, resolved: string } or { valid: false, error: string }.
|
||||
*/
|
||||
export function guardPath(filePath: string): { valid: true; resolved: string } | { valid: false; error: string } {
|
||||
const resolved = validatePath(filePath);
|
||||
if (resolved === null) {
|
||||
return { valid: false, error: `Access denied: path outside allowed project directories` };
|
||||
}
|
||||
return { valid: true, resolved };
|
||||
}
|
||||
71
ui-electrobun/src/bun/handlers/plugin-handlers.ts
Normal file
71
ui-electrobun/src/bun/handlers/plugin-handlers.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Plugin discovery + file reading RPC handlers.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
|
||||
|
||||
export function createPluginHandlers() {
|
||||
return {
|
||||
"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 }: { pluginId: string; filePath: string }) => {
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
69
ui-electrobun/src/bun/handlers/pty-handlers.ts
Normal file
69
ui-electrobun/src/bun/handlers/pty-handlers.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* PTY RPC handlers — create, write, resize, unsubscribe, close sessions.
|
||||
*/
|
||||
|
||||
import type { PtyClient } from "../pty-client.ts";
|
||||
|
||||
export function createPtyHandlers(ptyClient: PtyClient) {
|
||||
return {
|
||||
"pty.create": async ({ sessionId, cols, rows, cwd, shell, args }: {
|
||||
sessionId: string; cols: number; rows: number; cwd?: string;
|
||||
shell?: string; args?: string[];
|
||||
}) => {
|
||||
if (!ptyClient.isConnected) {
|
||||
return { ok: false, error: "PTY daemon not connected" };
|
||||
}
|
||||
try {
|
||||
ptyClient.createSession({ id: sessionId, cols, rows, cwd, shell, args });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[pty.create] ${sessionId}: ${error}`);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"pty.write": ({ sessionId, data }: { sessionId: string; data: string }) => {
|
||||
if (!ptyClient.isConnected) {
|
||||
console.error(`[pty.write] ${sessionId}: daemon not connected`);
|
||||
return { ok: false };
|
||||
}
|
||||
try {
|
||||
ptyClient.writeInput(sessionId, data);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[pty.write] ${sessionId}: ${error}`);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"pty.resize": ({ sessionId, cols, rows }: { sessionId: string; cols: number; rows: number }) => {
|
||||
if (!ptyClient.isConnected) return { ok: true };
|
||||
try {
|
||||
ptyClient.resize(sessionId, cols, rows);
|
||||
} catch (err) {
|
||||
console.error(`[pty.resize] ${sessionId}:`, err);
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"pty.unsubscribe": ({ sessionId }: { sessionId: string }) => {
|
||||
try {
|
||||
ptyClient.unsubscribe(sessionId);
|
||||
} catch (err) {
|
||||
console.error(`[pty.unsubscribe] ${sessionId}:`, err);
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"pty.close": ({ sessionId }: { sessionId: string }) => {
|
||||
try {
|
||||
ptyClient.closeSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error(`[pty.close] ${sessionId}:`, err);
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
65
ui-electrobun/src/bun/handlers/remote-handlers.ts
Normal file
65
ui-electrobun/src/bun/handlers/remote-handlers.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Remote machine (relay) RPC handlers.
|
||||
*/
|
||||
|
||||
import type { RelayClient } from "../relay-client.ts";
|
||||
|
||||
export function createRemoteHandlers(relayClient: RelayClient) {
|
||||
return {
|
||||
"remote.connect": async ({ url, token, label }: { url: string; token: string; label?: string }) => {
|
||||
try {
|
||||
const machineId = await relayClient.connect(url, token, label);
|
||||
return { ok: true, machineId };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[remote.connect]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"remote.disconnect": ({ machineId }: { machineId: string }) => {
|
||||
try {
|
||||
relayClient.disconnect(machineId);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[remote.disconnect]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"remote.list": () => {
|
||||
try {
|
||||
return { machines: relayClient.listMachines() };
|
||||
} catch (err) {
|
||||
console.error("[remote.list]", err);
|
||||
return { machines: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"remote.send": ({ machineId, command, payload }: { machineId: string; command: string; payload: Record<string, unknown> }) => {
|
||||
try {
|
||||
relayClient.sendCommand(machineId, command, payload);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[remote.send]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"remote.status": ({ machineId }: { machineId: string }) => {
|
||||
try {
|
||||
const info = relayClient.getStatus(machineId);
|
||||
if (!info) {
|
||||
return { status: "disconnected" as const, latencyMs: null, error: "Machine not found" };
|
||||
}
|
||||
return { status: info.status, latencyMs: info.latencyMs };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[remote.status]", err);
|
||||
return { status: "error" as const, latencyMs: null, error };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
41
ui-electrobun/src/bun/handlers/search-handlers.ts
Normal file
41
ui-electrobun/src/bun/handlers/search-handlers.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Search RPC handlers — FTS5 full-text search.
|
||||
* Fix #13: Returns typed error for invalid queries.
|
||||
*/
|
||||
|
||||
import type { SearchDb } from "../search-db.ts";
|
||||
|
||||
export function createSearchHandlers(searchDb: SearchDb) {
|
||||
return {
|
||||
"search.query": ({ query, limit }: { query: string; limit?: number }) => {
|
||||
try {
|
||||
const results = searchDb.searchAll(query, limit ?? 20);
|
||||
return { results };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[search.query]", err);
|
||||
return { results: [], error };
|
||||
}
|
||||
},
|
||||
|
||||
"search.indexMessage": ({ sessionId, role, content }: { sessionId: string; role: string; content: string }) => {
|
||||
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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
131
ui-electrobun/src/bun/handlers/settings-handlers.ts
Normal file
131
ui-electrobun/src/bun/handlers/settings-handlers.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Settings + Groups + Themes RPC handlers.
|
||||
*/
|
||||
|
||||
import type { SettingsDb } from "../settings-db.ts";
|
||||
|
||||
export function createSettingsHandlers(settingsDb: SettingsDb) {
|
||||
return {
|
||||
"settings.get": ({ key }: { key: string }) => {
|
||||
try {
|
||||
return { value: settingsDb.getSetting(key) };
|
||||
} catch (err) {
|
||||
console.error("[settings.get]", err);
|
||||
return { value: null };
|
||||
}
|
||||
},
|
||||
|
||||
"settings.set": ({ key, value }: { key: string; value: string }) => {
|
||||
try {
|
||||
settingsDb.setSetting(key, value);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[settings.set]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"settings.getAll": () => {
|
||||
try {
|
||||
return { settings: settingsDb.getAll() };
|
||||
} catch (err) {
|
||||
console.error("[settings.getAll]", err);
|
||||
return { settings: {} };
|
||||
}
|
||||
},
|
||||
|
||||
"settings.getProjects": () => {
|
||||
try {
|
||||
const projects = settingsDb.listProjects().map((p: Record<string, unknown>) => ({
|
||||
id: p.id,
|
||||
config: JSON.stringify(p),
|
||||
}));
|
||||
return { projects };
|
||||
} catch (err) {
|
||||
console.error("[settings.getProjects]", err);
|
||||
return { projects: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"settings.setProject": ({ id, config }: { id: string; config: string }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(config);
|
||||
settingsDb.setProject(id, { id, ...parsed });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[settings.setProject]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"settings.deleteProject": ({ id }: { id: string }) => {
|
||||
try {
|
||||
settingsDb.deleteProject(id);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[settings.deleteProject]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
// Groups
|
||||
"groups.list": () => {
|
||||
try {
|
||||
return { groups: settingsDb.listGroups() };
|
||||
} catch (err) {
|
||||
console.error("[groups.list]", err);
|
||||
return { groups: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"groups.create": ({ id, name, icon, position }: { id: string; name: string; icon: string; position: number }) => {
|
||||
try {
|
||||
settingsDb.createGroup(id, name, icon, position);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[groups.create]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"groups.delete": ({ id }: { id: string }) => {
|
||||
try {
|
||||
settingsDb.deleteGroup(id);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[groups.delete]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
// Custom themes
|
||||
"themes.getCustom": () => {
|
||||
try {
|
||||
return { themes: settingsDb.getCustomThemes() };
|
||||
} catch (err) {
|
||||
console.error("[themes.getCustom]", err);
|
||||
return { themes: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"themes.saveCustom": ({ id, name, palette }: { id: string; name: string; palette: Record<string, string> }) => {
|
||||
try {
|
||||
settingsDb.saveCustomTheme(id, name, palette);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[themes.saveCustom]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"themes.deleteCustom": ({ id }: { id: string }) => {
|
||||
try {
|
||||
settingsDb.deleteCustomTheme(id);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[themes.deleteCustom]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -58,7 +58,7 @@ export class PtyClient extends EventEmitter {
|
|||
this.tokenPath = join(dir, "ptyd.token");
|
||||
}
|
||||
|
||||
/** Connect to daemon and authenticate. */
|
||||
/** Connect to daemon and authenticate. Fix #10: 5-second timeout. */
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let token: string;
|
||||
|
|
@ -69,6 +69,16 @@ export class PtyClient extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
|
||||
// Fix #10: 5-second timeout on connect
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.socket?.destroy();
|
||||
reject(new Error("Connection timeout (5s). Is agor-ptyd running?"));
|
||||
}, 5_000);
|
||||
|
||||
this.socket = connect(this.socketPath);
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
|
|
@ -84,6 +94,9 @@ export class PtyClient extends EventEmitter {
|
|||
try {
|
||||
const msg = JSON.parse(line) as DaemonEvent;
|
||||
if (!this.authenticated && msg.type === "auth_result") {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (msg.ok) {
|
||||
this.authenticated = true;
|
||||
resolve();
|
||||
|
|
@ -101,21 +114,32 @@ export class PtyClient extends EventEmitter {
|
|||
});
|
||||
|
||||
this.socket.on("error", (err) => {
|
||||
if (!this.authenticated) reject(err);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
this.emit("error", err);
|
||||
});
|
||||
|
||||
this.socket.on("close", () => {
|
||||
this.authenticated = false;
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Connection closed before auth"));
|
||||
}
|
||||
this.emit("close");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new PTY session. */
|
||||
/** Create a new PTY session. Fix #3: accepts shell + args for direct command spawning. */
|
||||
createSession(opts: {
|
||||
id: string;
|
||||
shell?: string;
|
||||
/** Arguments to pass to shell (used for SSH direct spawn). */
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
cols?: number;
|
||||
|
|
@ -125,6 +149,7 @@ export class PtyClient extends EventEmitter {
|
|||
type: "create_session",
|
||||
id: opts.id,
|
||||
shell: opts.shell ?? null,
|
||||
args: opts.args ?? null,
|
||||
cwd: opts.cwd ?? null,
|
||||
env: opts.env ?? null,
|
||||
cols: opts.cols ?? 80,
|
||||
|
|
|
|||
|
|
@ -123,12 +123,7 @@
|
|||
newGroupName = '';
|
||||
}
|
||||
|
||||
async function removeGroup(id: string) {
|
||||
if (groups.length <= 1) return; // keep at least one group
|
||||
groups = groups.filter(g => g.id !== id);
|
||||
if (activeGroupId === id) activeGroupId = groups[0]?.id ?? 'dev';
|
||||
await appRpc.request['groups.delete']({ id }).catch(console.error);
|
||||
}
|
||||
// Fix #19: removeGroup removed — was defined but never called from UI
|
||||
let activeGroupId = $state('dev');
|
||||
// Fix #10: Track previous group to limit mounted DOM (max 2 groups)
|
||||
let previousGroupId = $state<string | null>(null);
|
||||
|
|
@ -218,38 +213,7 @@
|
|||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── JS-based window drag (replaces broken -webkit-app-region on WebKitGTK) ──
|
||||
let isDraggingWindow = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let winStartX = 0;
|
||||
let winStartY = 0;
|
||||
|
||||
function onDragStart(e: MouseEvent) {
|
||||
isDraggingWindow = true;
|
||||
dragStartX = e.screenX;
|
||||
dragStartY = e.screenY;
|
||||
appRpc?.request["window.getFrame"]({}).then((frame: any) => {
|
||||
winStartX = frame.x;
|
||||
winStartY = frame.y;
|
||||
}).catch(() => {});
|
||||
window.addEventListener('mousemove', onDragMove);
|
||||
window.addEventListener('mouseup', onDragEnd);
|
||||
}
|
||||
|
||||
function onDragMove(e: MouseEvent) {
|
||||
if (!isDraggingWindow) return;
|
||||
const dx = e.screenX - dragStartX;
|
||||
const dy = e.screenY - dragStartY;
|
||||
appRpc?.request["window.setPosition"]?.({ x: winStartX + dx, y: winStartY + dy })?.catch?.(() => {});
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDraggingWindow = false;
|
||||
window.removeEventListener('mousemove', onDragMove);
|
||||
window.removeEventListener('mouseup', onDragEnd);
|
||||
saveWindowFrame();
|
||||
}
|
||||
// Fix #19: onDragStart/onDragMove/onDragEnd removed — no longer referenced from template
|
||||
|
||||
// ── Window frame persistence (debounced 500ms) ─────────────────
|
||||
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -329,15 +293,18 @@
|
|||
// Set up global error boundary
|
||||
setupErrorBoundary();
|
||||
|
||||
// Run all init tasks in parallel, mark app ready when all complete
|
||||
// Fix #8: Load groups FIRST, then apply saved active_group.
|
||||
// Other init tasks run in parallel.
|
||||
const initTasks = [
|
||||
themeStore.initTheme(appRpc).catch(console.error),
|
||||
fontStore.initFonts(appRpc).catch(console.error),
|
||||
keybindingStore.init(appRpc).catch(console.error),
|
||||
// Sequential: groups.list -> active_group (depends on groups being loaded)
|
||||
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }: { groups: Group[] }) => {
|
||||
if (dbGroups.length > 0) groups = dbGroups;
|
||||
}).catch(console.error),
|
||||
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }: { value: string | null }) => {
|
||||
// Now that groups are loaded, apply saved active_group
|
||||
return appRpc.request["settings.get"]({ key: 'active_group' });
|
||||
}).then(({ value }: { value: string | null }) => {
|
||||
if (value && groups.some(g => g.id === value)) activeGroupId = value;
|
||||
}).catch(console.error),
|
||||
// Load projects from SQLite
|
||||
|
|
@ -356,7 +323,6 @@
|
|||
|
||||
Promise.allSettled(initTasks).then(() => {
|
||||
appReady = true;
|
||||
// Track projects for health monitoring after load
|
||||
for (const p of PROJECTS) trackProject(p.id);
|
||||
});
|
||||
|
||||
|
|
@ -377,10 +343,24 @@
|
|||
}
|
||||
document.addEventListener('keydown', handleSearchShortcut);
|
||||
|
||||
// Fix #18: Wire CommandPalette events to action handlers
|
||||
function handlePaletteCommand(e: Event) {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
switch (detail) {
|
||||
case 'settings': settingsOpen = !settingsOpen; break;
|
||||
case 'search': searchOpen = !searchOpen; break;
|
||||
case 'new-project': showAddProject = true; break;
|
||||
case 'toggle-sidebar': settingsOpen = !settingsOpen; break;
|
||||
default: console.log(`[palette] unhandled command: ${detail}`);
|
||||
}
|
||||
}
|
||||
window.addEventListener('palette-command', handlePaletteCommand);
|
||||
|
||||
const cleanup = keybindingStore.installListener();
|
||||
return () => {
|
||||
cleanup();
|
||||
document.removeEventListener('keydown', handleSearchShortcut);
|
||||
window.removeEventListener('palette-command', handlePaletteCommand);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -242,15 +242,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click outside overlay to close popup -->
|
||||
{#if openPopup !== null}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
<!-- Fix #11: display toggle for popup backdrop -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="popup-backdrop"
|
||||
style:display={openPopup !== null ? 'block' : 'none'}
|
||||
onclick={closePopup}
|
||||
onkeydown={e => e.key === 'Escape' && closePopup()}
|
||||
></div>
|
||||
{/if}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.chat-input-outer {
|
||||
|
|
|
|||
|
|
@ -89,17 +89,18 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
<!-- Fix #11: display toggle instead of {#if} -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="palette-backdrop"
|
||||
style:display={open ? 'flex' : 'none'}
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command Palette"
|
||||
tabindex="-1"
|
||||
>
|
||||
>
|
||||
<div class="palette-panel">
|
||||
<div class="palette-input-row">
|
||||
<svg class="palette-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
|
|
@ -155,8 +156,7 @@
|
|||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.palette-backdrop {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -19,6 +20,12 @@
|
|||
let renderedHtml = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Fix #1: Configure DOMPurify with safe tag whitelist
|
||||
const PURIFY_CONFIG = {
|
||||
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'a', 'strong', 'em', 'code', 'pre', 'ul', 'li', 'br'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'class'],
|
||||
};
|
||||
|
||||
function expandHome(p: string): string {
|
||||
if (p.startsWith('~/')) return p.replace('~', '/home/' + (typeof process !== 'undefined' ? process.env.USER : 'user'));
|
||||
return p;
|
||||
|
|
@ -44,19 +51,26 @@
|
|||
const res = await appRpc.request['files.read']({ path: file.path });
|
||||
if (res.error || !res.content) {
|
||||
content = '';
|
||||
renderedHtml = `<p class="doc-error">${res.error ?? 'Empty file'}</p>`;
|
||||
renderedHtml = DOMPurify.sanitize(
|
||||
`<p class="doc-error">${escapeHtml(res.error ?? 'Empty file')}</p>`,
|
||||
PURIFY_CONFIG,
|
||||
);
|
||||
} else {
|
||||
content = res.content;
|
||||
renderedHtml = renderMarkdown(content);
|
||||
renderedHtml = DOMPurify.sanitize(renderMarkdown(content), PURIFY_CONFIG);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[DocsTab] read error:', err);
|
||||
renderedHtml = '<p class="doc-error">Failed to read file</p>';
|
||||
renderedHtml = DOMPurify.sanitize('<p class="doc-error">Failed to read file</p>', PURIFY_CONFIG);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
/** Simple markdown-to-HTML (no external dep). Handles headers, code blocks, bold, italic, links, lists. */
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Simple markdown-to-HTML. All output is sanitized by DOMPurify before rendering. */
|
||||
function renderMarkdown(md: string): string {
|
||||
let html = md
|
||||
.replace(/&/g, '&')
|
||||
|
|
@ -64,8 +78,8 @@
|
|||
.replace(/>/g, '>');
|
||||
|
||||
// Code blocks
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) =>
|
||||
`<pre class="doc-code"><code class="lang-${lang}">${code.trim()}</code></pre>`
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
|
||||
`<pre class="doc-code"><code>${code.trim()}</code></pre>`
|
||||
);
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="doc-inline-code">$1</code>');
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
let fileLoading = $state(false);
|
||||
let isDirty = $state(false);
|
||||
let editorContent = $state('');
|
||||
// Fix #6: Request token to discard stale file load responses
|
||||
let fileRequestToken = 0;
|
||||
|
||||
// Extension-based type detection
|
||||
const CODE_EXTS = new Set([
|
||||
|
|
@ -115,7 +117,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Select and load a file. */
|
||||
/** Select and load a file. Fix #6: uses request token to discard stale responses. */
|
||||
async function selectFile(filePath: string) {
|
||||
if (selectedFile === filePath) return;
|
||||
selectedFile = filePath;
|
||||
|
|
@ -123,6 +125,7 @@
|
|||
fileContent = null;
|
||||
fileError = null;
|
||||
fileLoading = true;
|
||||
const token = ++fileRequestToken;
|
||||
|
||||
const type = detectFileType(filePath);
|
||||
|
||||
|
|
@ -136,6 +139,7 @@
|
|||
if (type === 'image') {
|
||||
try {
|
||||
const result = await appRpc.request["files.read"]({ path: filePath });
|
||||
if (token !== fileRequestToken) return;
|
||||
if (result.error) {
|
||||
fileError = result.error;
|
||||
return;
|
||||
|
|
@ -144,15 +148,17 @@
|
|||
fileEncoding = result.encoding;
|
||||
fileSize = result.size;
|
||||
} catch (err) {
|
||||
if (token !== fileRequestToken) return;
|
||||
fileError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
fileLoading = false;
|
||||
if (token === fileRequestToken) fileLoading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await appRpc.request["files.read"]({ path: filePath });
|
||||
if (token !== fileRequestToken) return;
|
||||
if (result.error) {
|
||||
fileError = result.error;
|
||||
return;
|
||||
|
|
@ -162,9 +168,10 @@
|
|||
fileSize = result.size;
|
||||
editorContent = fileContent;
|
||||
} catch (err) {
|
||||
if (token !== fileRequestToken) return;
|
||||
fileError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
fileLoading = false;
|
||||
if (token === fileRequestToken) fileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@
|
|||
let { open, notifications, onClear, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop to close on outside click -->
|
||||
<div class="notif-backdrop" role="presentation" onclick={onClose}></div>
|
||||
<!-- Fix #11: display toggle instead of {#if} -->
|
||||
<!-- Backdrop to close on outside click -->
|
||||
<div class="notif-backdrop" style:display={open ? 'block' : 'none'} role="presentation" onclick={onClose}></div>
|
||||
|
||||
<div class="notif-drawer" role="complementary" aria-label="Notification history">
|
||||
<div class="notif-drawer" style:display={open ? 'flex' : 'none'} role="complementary" aria-label="Notification history">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-title">Notifications</span>
|
||||
<button class="clear-btn" onclick={onClear} aria-label="Clear all notifications">
|
||||
|
|
@ -43,8 +43,7 @@
|
|||
<div class="notif-empty">No notifications</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notif-backdrop {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@
|
|||
let loading = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let searchError = $state<string | null>(null);
|
||||
|
||||
// Debounce timer
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Fix #7: Request counter to discard stale results
|
||||
let requestToken = 0;
|
||||
|
||||
// Group results by type
|
||||
let grouped = $derived(() => {
|
||||
|
|
@ -52,18 +55,30 @@
|
|||
const q = query.trim();
|
||||
if (!q) {
|
||||
results = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
searchError = null;
|
||||
const token = ++requestToken;
|
||||
try {
|
||||
const res = await appRpc.request['search.query']({ query: q, limit: 20 });
|
||||
// Fix #7: Discard stale results if a newer request was issued
|
||||
if (token !== requestToken) return;
|
||||
if (res.error) {
|
||||
searchError = res.error;
|
||||
results = [];
|
||||
} else {
|
||||
results = res.results ?? [];
|
||||
}
|
||||
selectedIndex = 0;
|
||||
} catch (err) {
|
||||
if (token !== requestToken) return;
|
||||
console.error('[search]', err);
|
||||
results = [];
|
||||
searchError = 'Search failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
if (token === requestToken) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +110,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix #4: Render snippet as plain text, highlight query matches client-side with <mark>.
|
||||
* Strips any HTML from snippet (from FTS5 <b> tags), then highlights matches safely.
|
||||
*/
|
||||
function highlightQuery(snippet: string, q: string): string {
|
||||
// Strip existing HTML tags (FTS5 returns <b>...</b>)
|
||||
const plain = snippet.replace(/<[^>]*>/g, '');
|
||||
// Escape HTML entities
|
||||
const escaped = plain
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
if (!q.trim()) return escaped;
|
||||
// Escape regex special chars in query
|
||||
const safeQ = q.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(${safeQ})`, 'gi');
|
||||
return escaped.replace(re, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
// Focus input when opened
|
||||
$effect(() => {
|
||||
if (open && inputEl) {
|
||||
|
|
@ -108,9 +142,9 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay-backdrop" onclick={onClose} onkeydown={handleKeydown}>
|
||||
<!-- Fix #11: display toggle instead of {#if} to avoid DOM add/remove during click events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay-backdrop" style:display={open ? 'flex' : 'none'} onclick={onClose} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay-panel" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
|
||||
<div class="search-input-wrap">
|
||||
|
|
@ -145,18 +179,19 @@
|
|||
onmouseenter={() => selectedIndex = flatIdx}
|
||||
>
|
||||
<span class="result-title">{item.title}</span>
|
||||
<span class="result-snippet">{@html item.snippet}</span>
|
||||
<span class="result-snippet">{@html highlightQuery(item.snippet, query)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if searchError}
|
||||
<div class="no-results search-error">Invalid query: {searchError}</div>
|
||||
{:else if query.trim() && !loading}
|
||||
<div class="no-results">No results for "{query}"</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay-backdrop {
|
||||
|
|
@ -283,9 +318,14 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-snippet :global(b) {
|
||||
.result-snippet :global(mark) {
|
||||
color: var(--ctp-yellow);
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-error {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
|
|
|
|||
|
|
@ -47,17 +47,18 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
<!-- Fix #11: display toggle instead of {#if} -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="drawer-backdrop"
|
||||
style:display={open ? 'flex' : 'none'}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Settings"
|
||||
tabindex="-1"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<aside class="drawer-panel" onclick={e => e.stopPropagation()} onkeydown={e => e.stopPropagation()}>
|
||||
|
||||
|
|
@ -109,8 +110,7 @@
|
|||
</div>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.drawer-backdrop {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
}
|
||||
|
||||
function connectSsh(conn: SshConfig) {
|
||||
// Spawn a PTY with ssh command
|
||||
// Fix #3: Spawn ssh directly via PTY shell+args — no shell command injection
|
||||
const sessionId = `ssh-${conn.id}-${Date.now()}`;
|
||||
const args = ['-p', String(conn.port), `${conn.user}@${conn.host}`];
|
||||
if (conn.keyPath) args.unshift('-i', conn.keyPath);
|
||||
|
|
@ -95,13 +95,9 @@
|
|||
sessionId,
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
shell: '/usr/bin/ssh',
|
||||
args,
|
||||
}).catch(console.error);
|
||||
|
||||
// Write the ssh command after a short delay to let the shell start
|
||||
setTimeout(() => {
|
||||
const cmd = `/usr/bin/ssh ${args.join(' ')}\n`;
|
||||
appRpc.request['pty.write']({ sessionId, data: cmd }).catch(console.error);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
let unsubFont: (() => void) | null = null;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let destroyed = false;
|
||||
// Fix #5: Store listener cleanup functions to prevent leaks
|
||||
let listenerCleanups: Array<() => void> = [];
|
||||
|
||||
/** Decode a base64 string from the daemon into a Uint8Array. */
|
||||
function decodeBase64(b64: string): Uint8Array {
|
||||
|
|
@ -126,6 +128,12 @@
|
|||
};
|
||||
appRpc.addMessageListener('pty.closed', closedHandler);
|
||||
|
||||
// Fix #5: Store cleanup functions for message listeners
|
||||
listenerCleanups.push(
|
||||
() => appRpc.removeMessageListener?.('pty.output', outputHandler),
|
||||
() => appRpc.removeMessageListener?.('pty.closed', closedHandler),
|
||||
);
|
||||
|
||||
// ── Send user input to daemon ──────────────────────────────────────────
|
||||
|
||||
term.onData((data: string) => {
|
||||
|
|
@ -148,7 +156,11 @@
|
|||
destroyed = true;
|
||||
unsubFont?.();
|
||||
ro?.disconnect();
|
||||
// Fix #1: Close the PTY session (not just unsubscribe) to prevent session leak
|
||||
// Fix #5: Clean up all message listeners to prevent leaks
|
||||
for (const cleanup of listenerCleanups) {
|
||||
try { cleanup(); } catch { /* ignore */ }
|
||||
}
|
||||
listenerCleanups = [];
|
||||
appRpc.request['pty.close']({ sessionId }).catch(() => {});
|
||||
term?.dispose();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { appRpc } from './rpc.ts';
|
||||
import { recordActivity, recordToolDone, recordTokenSnapshot, setProjectStatus } from './health-store.svelte.ts';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -118,6 +119,8 @@ const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|||
|
||||
// Debounce timer for message persistence
|
||||
const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
// Fix #12: Track last persisted index per session to avoid re-saving entire history
|
||||
const lastPersistedIndex = new Map<string, number>();
|
||||
|
||||
// ── Session persistence helpers ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -146,7 +149,11 @@ function persistMessages(session: AgentSession): void {
|
|||
|
||||
const timer = setTimeout(() => {
|
||||
msgPersistTimers.delete(session.sessionId);
|
||||
const msgs = session.messages.map((m) => ({
|
||||
// Fix #12: Only persist NEW messages (from lastPersistedIndex onward)
|
||||
const startIdx = lastPersistedIndex.get(session.sessionId) ?? 0;
|
||||
const newMsgs = session.messages.slice(startIdx);
|
||||
if (newMsgs.length === 0) return;
|
||||
const msgs = newMsgs.map((m) => ({
|
||||
sessionId: session.sessionId,
|
||||
msgId: m.id,
|
||||
role: m.role,
|
||||
|
|
@ -155,8 +162,9 @@ function persistMessages(session: AgentSession): void {
|
|||
toolInput: m.toolInput,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
if (msgs.length === 0) return;
|
||||
appRpc.request['session.messages.save']({ messages: msgs }).catch((err: unknown) => {
|
||||
appRpc.request['session.messages.save']({ messages: msgs }).then(() => {
|
||||
lastPersistedIndex.set(session.sessionId, session.messages.length);
|
||||
}).catch((err: unknown) => {
|
||||
console.error('[session.messages.save] persist error:', err);
|
||||
});
|
||||
}, 2000);
|
||||
|
|
@ -197,6 +205,16 @@ function ensureListeners() {
|
|||
persistMessages(session);
|
||||
// Reset stall timer on activity
|
||||
resetStallTimer(payload.sessionId, session.projectId);
|
||||
// Fix #14: Wire health store — record activity on every message batch
|
||||
for (const msg of converted) {
|
||||
if (msg.role === 'tool-call') {
|
||||
recordActivity(session.projectId, msg.toolName);
|
||||
} else if (msg.role === 'tool-result') {
|
||||
recordToolDone(session.projectId);
|
||||
} else {
|
||||
recordActivity(session.projectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -212,6 +230,9 @@ function ensureListeners() {
|
|||
session.status = normalizeStatus(payload.status);
|
||||
if (payload.error) session.error = payload.error;
|
||||
|
||||
// Fix #14: Wire health store — update project status
|
||||
setProjectStatus(session.projectId, session.status === 'done' ? 'done' : session.status === 'error' ? 'error' : session.status === 'running' ? 'running' : 'idle');
|
||||
|
||||
// Persist on every status change
|
||||
persistSession(session);
|
||||
|
||||
|
|
@ -250,6 +271,8 @@ function ensureListeners() {
|
|||
session.costUsd = payload.costUsd;
|
||||
session.inputTokens = payload.inputTokens;
|
||||
session.outputTokens = payload.outputTokens;
|
||||
// Fix #14: Wire health store — record token/cost snapshot
|
||||
recordTokenSnapshot(session.projectId, payload.inputTokens + payload.outputTokens, payload.costUsd);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
loadPlugin,
|
||||
unloadPlugin,
|
||||
unloadAllPlugins,
|
||||
getLoadedPlugins,
|
||||
setPluginRegistries,
|
||||
type PluginMeta,
|
||||
} from './plugin-host.ts';
|
||||
|
|
@ -28,7 +27,6 @@ export interface PluginCommand {
|
|||
|
||||
let discovered = $state<PluginMeta[]>([]);
|
||||
let commands = $state<PluginCommand[]>([]);
|
||||
let loaded = $derived(getLoadedPlugins());
|
||||
|
||||
// ── Event bus (simple pub/sub) ───────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -3,20 +3,31 @@
|
|||
*
|
||||
* main.ts creates the Electroview and RPC, then sets it here.
|
||||
* All other modules import from this file instead of main.ts.
|
||||
*
|
||||
* Fix #17: Typed RPC interface instead of `any`.
|
||||
*/
|
||||
|
||||
import type { PtyRPCSchema } from '../shared/pty-rpc-schema.ts';
|
||||
import type { PtyRPCSchema, PtyRPCRequests, PtyRPCMessages } from '../shared/pty-rpc-schema.ts';
|
||||
|
||||
// Placeholder type — matches the shape Electroview.defineRPC returns.
|
||||
// Uses `any` for the internal Electrobun RPC wrapper type since it is
|
||||
// not exported from the electrobun package.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ElectrobunRpc = any;
|
||||
// ── Typed RPC interface ──────────────────────────────────────────────────────
|
||||
|
||||
let _rpc: ElectrobunRpc | null = null;
|
||||
type RequestFn<K extends keyof PtyRPCRequests> = (params: PtyRPCRequests[K]['params']) => Promise<PtyRPCRequests[K]['response']>;
|
||||
|
||||
type MessagePayload<K extends keyof PtyRPCMessages> = PtyRPCMessages[K];
|
||||
type MessageListener<K extends keyof PtyRPCMessages> = (payload: MessagePayload<K>) => void;
|
||||
|
||||
export interface AppRpcHandle {
|
||||
request: { [K in keyof PtyRPCRequests]: RequestFn<K> };
|
||||
addMessageListener: <K extends keyof PtyRPCMessages>(event: K, handler: MessageListener<K>) => void;
|
||||
removeMessageListener?: <K extends keyof PtyRPCMessages>(event: K, handler: MessageListener<K>) => void;
|
||||
}
|
||||
|
||||
// ── Internal holder ──────────────────────────────────────────────────────────
|
||||
|
||||
let _rpc: AppRpcHandle | null = null;
|
||||
|
||||
/** Called once from main.ts after Electroview.defineRPC(). */
|
||||
export function setAppRpc(rpc: ElectrobunRpc): void {
|
||||
export function setAppRpc(rpc: AppRpcHandle): void {
|
||||
_rpc = rpc;
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +35,7 @@ export function setAppRpc(rpc: ElectrobunRpc): void {
|
|||
* The app-wide RPC handle.
|
||||
* Safe to call after main.ts has executed (Svelte components mount after).
|
||||
*/
|
||||
export const appRpc: ElectrobunRpc = new Proxy({} as ElectrobunRpc, {
|
||||
export const appRpc: AppRpcHandle = new Proxy({} as AppRpcHandle, {
|
||||
get(_target, prop) {
|
||||
if (!_rpc) {
|
||||
throw new Error(`[rpc] accessed before init — property "${String(prop)}"`);
|
||||
|
|
|
|||
137
ui-electrobun/src/mainview/workspace-store.svelte.ts
Normal file
137
ui-electrobun/src/mainview/workspace-store.svelte.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Workspace store — project/group CRUD extracted from App.svelte (Fix #16).
|
||||
*
|
||||
* Manages PROJECTS and groups state, persists via RPC.
|
||||
* App.svelte imports and calls these methods instead of inline CRUD logic.
|
||||
*/
|
||||
|
||||
import { appRpc } from './rpc.ts';
|
||||
import { trackProject } from './health-store.svelte.ts';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
accent: string;
|
||||
status: AgentStatus;
|
||||
costUsd: number;
|
||||
tokens: number;
|
||||
messages: Array<{ id: number; role: string; content: string }>;
|
||||
provider?: string;
|
||||
profile?: string;
|
||||
model?: string;
|
||||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
groupId?: string;
|
||||
cloneOf?: string;
|
||||
worktreeBranch?: string;
|
||||
mainRepoPath?: string;
|
||||
cloneIndex?: number;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
position: number;
|
||||
hasNew?: boolean;
|
||||
}
|
||||
|
||||
// ── Accent colors ─────────────────────────────────────────────────────────
|
||||
|
||||
const ACCENTS = [
|
||||
'var(--ctp-mauve)', 'var(--ctp-sapphire)', 'var(--ctp-teal)',
|
||||
'var(--ctp-peach)', 'var(--ctp-pink)', 'var(--ctp-lavender)',
|
||||
'var(--ctp-green)', 'var(--ctp-blue)', 'var(--ctp-flamingo)',
|
||||
];
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let groups = $state<Group[]>([
|
||||
{ id: 'dev', name: 'Development', icon: '1', position: 0 },
|
||||
]);
|
||||
let activeGroupId = $state('dev');
|
||||
let previousGroupId = $state<string | null>(null);
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])]));
|
||||
export const activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
|
||||
export const filteredProjects = $derived(projects.filter(p => (p.groupId ?? 'dev') === activeGroupId));
|
||||
|
||||
// ── Getters/setters for state ─────────────────────────────────────────────
|
||||
|
||||
export function getProjects(): Project[] { return projects; }
|
||||
export function setProjects(p: Project[]): void { projects = p; }
|
||||
export function getGroups(): Group[] { return groups; }
|
||||
export function setGroups(g: Group[]): void { groups = g; }
|
||||
export function getActiveGroupId(): string { return activeGroupId; }
|
||||
|
||||
export function setActiveGroup(id: string | undefined): void {
|
||||
if (!id) return;
|
||||
if (activeGroupId !== id) previousGroupId = activeGroupId;
|
||||
activeGroupId = id;
|
||||
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
|
||||
}
|
||||
|
||||
// ── Project CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function addProject(name: string, cwd: string): Promise<void> {
|
||||
if (!name.trim() || !cwd.trim()) return;
|
||||
const id = `p-${Date.now()}`;
|
||||
const accent = ACCENTS[projects.length % ACCENTS.length];
|
||||
const project: Project = {
|
||||
id, name: name.trim(), cwd: cwd.trim(), accent,
|
||||
status: 'idle', costUsd: 0, tokens: 0, messages: [],
|
||||
provider: 'claude', groupId: activeGroupId,
|
||||
};
|
||||
projects = [...projects, project];
|
||||
trackProject(id);
|
||||
await appRpc.request['settings.setProject']({
|
||||
id, config: JSON.stringify(project),
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string): Promise<void> {
|
||||
projects = projects.filter(p => p.id !== projectId);
|
||||
await appRpc.request['settings.deleteProject']({ id: projectId }).catch(console.error);
|
||||
}
|
||||
|
||||
export function cloneCountForProject(projectId: string): number {
|
||||
return projects.filter(p => p.cloneOf === projectId).length;
|
||||
}
|
||||
|
||||
export function handleClone(projectId: string, branch: string): void {
|
||||
const source = projects.find(p => p.id === projectId);
|
||||
if (!source) return;
|
||||
const branchName = branch || `feature/clone-${Date.now()}`;
|
||||
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
|
||||
if (result.ok && result.project) {
|
||||
const cloneConfig = JSON.parse(result.project.config) as Project;
|
||||
projects = [...projects, { ...cloneConfig, status: 'idle', costUsd: 0, tokens: 0, messages: [] }];
|
||||
} else {
|
||||
console.error('[clone]', result.error);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
// ── Group CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addGroup(name: string): Promise<void> {
|
||||
if (!name.trim()) return;
|
||||
const id = `grp-${Date.now()}`;
|
||||
const position = groups.length;
|
||||
const group: Group = { id, name: name.trim(), icon: String(position + 1), position };
|
||||
groups = [...groups, group];
|
||||
await appRpc.request['groups.create']({ id, name: name.trim(), icon: group.icon, position }).catch(console.error);
|
||||
}
|
||||
|
||||
// ── Aggregates ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getTotalCost(): number { return projects.reduce((s, p) => s + p.costUsd, 0); }
|
||||
export function getTotalTokens(): number { return projects.reduce((s, p) => s + p.tokens, 0); }
|
||||
|
|
@ -16,6 +16,10 @@ export type PtyRPCRequests = {
|
|||
rows: number;
|
||||
/** Working directory for the shell process. */
|
||||
cwd?: string;
|
||||
/** Override shell binary (e.g. /usr/bin/ssh). Fix #3: direct spawn, no shell injection. */
|
||||
shell?: string;
|
||||
/** Arguments for the shell binary (e.g. ['-p', '22', 'user@host']). */
|
||||
args?: string[];
|
||||
};
|
||||
response: { ok: boolean; error?: string };
|
||||
};
|
||||
|
|
@ -204,6 +208,11 @@ export type PtyRPCRequests = {
|
|||
params: Record<string, never>;
|
||||
response: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
/** Set the window position. */
|
||||
"window.setPosition": {
|
||||
params: { x: number; y: number };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
|
||||
// ── Keybindings RPC ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -485,7 +494,7 @@ export type PtyRPCRequests = {
|
|||
|
||||
// ── Search RPC ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Full-text search across messages, tasks, and btmsg. */
|
||||
/** Full-text search across messages, tasks, and btmsg. Fix #13: typed error for invalid queries. */
|
||||
"search.query": {
|
||||
params: { query: string; limit?: number };
|
||||
response: {
|
||||
|
|
@ -496,6 +505,8 @@ export type PtyRPCRequests = {
|
|||
snippet: string;
|
||||
score: number;
|
||||
}>;
|
||||
/** Set when query is invalid (e.g. FTS5 syntax error). */
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
/** Index a message for search. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue