feat(electrobun): groups, cloning, shortcuts, custom window — all 5 features
Groups Sidebar: - SQLite groups table (4 seeded: Development, Testing, DevOps, Research) - Left icon rail with emoji group icons, Ctrl+1-4 switching - Active group highlighted, projects filtered by group Project Cloning: - Clone button on project cards (fork icon) - git worktree add via Bun.spawn (array form, no shell strings) - 3-clone limit, branch name validation, pending-status pattern - Clone cards: WT badge + branch name + accent top border - Chain link SVG icons between linked clones in grid Keyboard Shortcuts: - keybinding-store.svelte.ts: 16 defaults across 4 categories - Two-scope: document capture + terminal focus guard - KeyboardSettings.svelte: search, click-to-capture, conflict detection - Per-binding reset + Reset All Custom Window: - titleBarStyle: "hidden" — no native title bar - Vertical "AGOR" text in left sidebar (writing-mode: vertical-rl) - Floating window controls badge (minimize/maximize/close) - Draggable region via -webkit-app-region: drag - Window frame persisted to SQLite (debounced 500ms) Window is resizable by default (Electrobun BrowserWindow).
This commit is contained in:
parent
5032021915
commit
a020f59cb4
14 changed files with 1741 additions and 189 deletions
|
|
@ -3,14 +3,13 @@ import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
|
|||
import { PtyClient } from "./pty-client.ts";
|
||||
import { settingsDb } from "./settings-db.ts";
|
||||
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
|
||||
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||
|
||||
// ── PTY daemon client ────────────────────────────────────────────────────────
|
||||
|
||||
// Resolve daemon socket directory. agor-ptyd writes ptyd.sock and ptyd.token
|
||||
// into $XDG_RUNTIME_DIR/agor or ~/.local/share/agor/run.
|
||||
const ptyClient = new PtyClient();
|
||||
|
||||
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
||||
|
|
@ -34,19 +33,27 @@ async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
|||
return false;
|
||||
}
|
||||
|
||||
// ── Clone helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
|
||||
|
||||
async function gitWorktreeAdd(mainRepoPath: string, worktreePath: string, branchName: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const proc = Bun.spawn(
|
||||
["git", "worktree", "add", worktreePath, "-b", branchName],
|
||||
{ cwd: mainRepoPath, stderr: "pipe", stdout: "pipe" }
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
const errText = await new Response(proc.stderr).text();
|
||||
return { ok: false, error: errText.trim() || `git exited with code ${exitCode}` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── RPC definition ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* BrowserView.defineRPC defines handlers that the WebView can call.
|
||||
* The schema type parameter describes what the Bun side handles (requests from
|
||||
* WebView) and what messages Bun sends to the WebView.
|
||||
*
|
||||
* Pattern: handlers.requests = WebView→Bun calls
|
||||
* handlers.messages = Bun→WebView fire-and-forget
|
||||
* To push a message to the WebView: rpc.send["pty.output"](payload)
|
||||
*/
|
||||
const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
||||
maxRequestTime: 10_000,
|
||||
maxRequestTime: 15_000,
|
||||
handlers: {
|
||||
requests: {
|
||||
"pty.create": async ({ sessionId, cols, rows, cwd }) => {
|
||||
|
|
@ -55,8 +62,6 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
}
|
||||
try {
|
||||
ptyClient.createSession({ id: sessionId, cols, rows, cwd });
|
||||
// Don't call subscribe() — CreateSession already auto-subscribes
|
||||
// the creating client and starts a fanout task.
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -77,7 +82,7 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
},
|
||||
|
||||
"pty.resize": ({ sessionId, cols, rows }) => {
|
||||
if (!ptyClient.isConnected) return { ok: true }; // Best-effort
|
||||
if (!ptyClient.isConnected) return { ok: true };
|
||||
try {
|
||||
ptyClient.resize(sessionId, cols, rows);
|
||||
} catch { /* ignore */ }
|
||||
|
|
@ -182,29 +187,174 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Groups handlers ──────────────────────────────────────────────────
|
||||
|
||||
"groups.list": () => {
|
||||
try {
|
||||
return { groups: settingsDb.listGroups() };
|
||||
} catch (err) {
|
||||
console.error("[groups.list]", err);
|
||||
return { groups: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Project clone handler ────────────────────────────────────────────
|
||||
|
||||
"project.clone": async ({ projectId, branchName }) => {
|
||||
try {
|
||||
if (!BRANCH_RE.test(branchName)) {
|
||||
return { ok: false, error: "Invalid branch name. Use only letters, numbers, /, _, -, ." };
|
||||
}
|
||||
|
||||
const source = settingsDb.getProject(projectId);
|
||||
if (!source) {
|
||||
return { ok: false, error: `Project not found: ${projectId}` };
|
||||
}
|
||||
|
||||
// Determine the authoritative main repo path
|
||||
const mainRepoPath = source.mainRepoPath ?? source.cwd;
|
||||
|
||||
// Count existing clones
|
||||
const allProjects = settingsDb.listProjects();
|
||||
const existingClones = allProjects.filter(
|
||||
(p) => p.cloneOf === projectId || (source.cloneOf && p.cloneOf === source.cloneOf)
|
||||
);
|
||||
if (existingClones.length >= 3) {
|
||||
return { ok: false, error: "Maximum 3 clones per project reached" };
|
||||
}
|
||||
|
||||
const cloneIndex = existingClones.length + 1;
|
||||
const worktreePath = `${mainRepoPath}-wt-${cloneIndex}`;
|
||||
|
||||
const gitResult = await gitWorktreeAdd(mainRepoPath, worktreePath, branchName);
|
||||
if (!gitResult.ok) {
|
||||
return { ok: false, error: gitResult.error };
|
||||
}
|
||||
|
||||
const cloneId = `${projectId}-clone-${cloneIndex}-${randomUUID().slice(0, 8)}`;
|
||||
const cloneConfig = {
|
||||
id: cloneId,
|
||||
name: `${source.name} [${branchName}]`,
|
||||
cwd: worktreePath,
|
||||
accent: source.accent,
|
||||
provider: source.provider,
|
||||
profile: source.profile,
|
||||
model: source.model,
|
||||
groupId: source.groupId ?? "dev",
|
||||
mainRepoPath,
|
||||
cloneOf: projectId,
|
||||
worktreePath,
|
||||
worktreeBranch: branchName,
|
||||
cloneIndex,
|
||||
};
|
||||
|
||||
settingsDb.setProject(cloneId, cloneConfig);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
project: { id: cloneId, config: JSON.stringify(cloneConfig) },
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[project.clone]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Window control handlers ──────────────────────────────────────────
|
||||
|
||||
"window.minimize": () => {
|
||||
try {
|
||||
mainWindow.minimize();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[window.minimize]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"window.maximize": () => {
|
||||
try {
|
||||
const frame = mainWindow.getFrame();
|
||||
// Heuristic: if window is already near the screen edge, unmaximize
|
||||
if (frame.x <= 0 && frame.y <= 0) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[window.maximize]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"window.close": () => {
|
||||
try {
|
||||
mainWindow.close();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[window.close]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"window.getFrame": () => {
|
||||
try {
|
||||
return mainWindow.getFrame();
|
||||
} catch {
|
||||
return { x: 0, y: 0, width: 1400, height: 900 };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Keybinding handlers ──────────────────────────────────────────────
|
||||
|
||||
"keybindings.getAll": () => {
|
||||
try {
|
||||
return { keybindings: settingsDb.getKeybindings() };
|
||||
} catch (err) {
|
||||
console.error("[keybindings.getAll]", err);
|
||||
return { keybindings: {} };
|
||||
}
|
||||
},
|
||||
|
||||
"keybindings.set": ({ id, chord }) => {
|
||||
try {
|
||||
settingsDb.setKeybinding(id, chord);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[keybindings.set]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
|
||||
"keybindings.reset": ({ id }) => {
|
||||
try {
|
||||
settingsDb.deleteKeybinding(id);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[keybindings.reset]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
messages: {
|
||||
// Messages section defines what the WebView can *send* to Bun (fire-and-forget).
|
||||
// We don't expect any inbound messages from the WebView in this direction.
|
||||
},
|
||||
messages: {},
|
||||
},
|
||||
});
|
||||
|
||||
// ── Forward daemon events to WebView ────────────────────────────────────────
|
||||
|
||||
// session_output: forward each chunk to the WebView as a "pty.output" message.
|
||||
ptyClient.on("session_output", (msg) => {
|
||||
if (msg.type !== "session_output") return;
|
||||
try {
|
||||
// data is already base64 from the daemon — pass it straight through.
|
||||
rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data });
|
||||
} catch (err) {
|
||||
console.error("[pty.output] forward error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// session_closed: notify the WebView so it can display "[Process exited]".
|
||||
ptyClient.on("session_closed", (msg) => {
|
||||
if (msg.type !== "session_closed") return;
|
||||
try {
|
||||
|
|
@ -237,15 +387,22 @@ connectToDaemon();
|
|||
|
||||
const url = await getMainViewUrl();
|
||||
|
||||
// Restore persisted window frame if available
|
||||
const savedX = Number(settingsDb.getSetting("win_x") ?? 100);
|
||||
const savedY = Number(settingsDb.getSetting("win_y") ?? 100);
|
||||
const savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400);
|
||||
const savedHeight = Number(settingsDb.getSetting("win_height") ?? 900);
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
title: "Agent Orchestrator — Electrobun",
|
||||
title: "Agent Orchestrator",
|
||||
titleBarStyle: "hidden",
|
||||
url,
|
||||
rpc,
|
||||
frame: {
|
||||
width: 1400,
|
||||
height: 900,
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: isNaN(savedWidth) ? 1400 : savedWidth,
|
||||
height: isNaN(savedHeight) ? 900 : savedHeight,
|
||||
x: isNaN(savedX) ? 100 : savedX,
|
||||
y: isNaN(savedY) ? 100 : savedY,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,26 @@ CREATE TABLE IF NOT EXISTS custom_themes (
|
|||
name TEXT NOT NULL,
|
||||
palette TEXT NOT NULL -- JSON blob
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
position INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keybindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
chord TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// Seed default groups (idempotent via INSERT OR IGNORE)
|
||||
const SEED_GROUPS = `
|
||||
INSERT OR IGNORE INTO groups VALUES ('dev', 'Development', '🔧', 0);
|
||||
INSERT OR IGNORE INTO groups VALUES ('test', 'Testing', '🧪', 1);
|
||||
INSERT OR IGNORE INTO groups VALUES ('ops', 'DevOps', '🚀', 2);
|
||||
INSERT OR IGNORE INTO groups VALUES ('research', 'Research', '🔬', 3);
|
||||
`;
|
||||
|
||||
// ── SettingsDb class ─────────────────────────────────────────────────────────
|
||||
|
|
@ -51,6 +71,18 @@ export interface ProjectConfig {
|
|||
provider?: string;
|
||||
profile?: string;
|
||||
model?: string;
|
||||
/** Group this project belongs to. Defaults to 'dev'. */
|
||||
groupId?: string;
|
||||
/** For clones: path of the source repo (worktree parent). */
|
||||
mainRepoPath?: string;
|
||||
/** For clones: ID of the original project this was cloned from. */
|
||||
cloneOf?: string;
|
||||
/** For clones: absolute path to the git worktree. */
|
||||
worktreePath?: string;
|
||||
/** For clones: branch name checked out in the worktree. */
|
||||
worktreeBranch?: string;
|
||||
/** 1-indexed clone number within the parent (1–3). */
|
||||
cloneIndex?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +92,13 @@ export interface CustomTheme {
|
|||
palette: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export class SettingsDb {
|
||||
private db: Database;
|
||||
|
||||
|
|
@ -69,6 +108,7 @@ export class SettingsDb {
|
|||
|
||||
this.db = new Database(DB_PATH);
|
||||
this.db.exec(SCHEMA);
|
||||
this.db.exec(SEED_GROUPS);
|
||||
|
||||
// Seed schema_version row if missing
|
||||
const version = this.db
|
||||
|
|
@ -136,6 +176,14 @@ export class SettingsDb {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Groups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
listGroups(): Group[] {
|
||||
return this.db
|
||||
.query<Group, []>("SELECT id, name, icon, position FROM groups ORDER BY position ASC")
|
||||
.all();
|
||||
}
|
||||
|
||||
// ── Custom Themes ─────────────────────────────────────────────────────────
|
||||
|
||||
getCustomThemes(): CustomTheme[] {
|
||||
|
|
@ -166,6 +214,25 @@ export class SettingsDb {
|
|||
this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// ── Keybindings ───────────────────────────────────────────────────────────
|
||||
|
||||
getKeybindings(): Record<string, string> {
|
||||
const rows = this.db
|
||||
.query<{ id: string; chord: string }, []>("SELECT id, chord FROM keybindings")
|
||||
.all();
|
||||
return Object.fromEntries(rows.map((r) => [r.id, r.chord]));
|
||||
}
|
||||
|
||||
setKeybinding(id: string, chord: string): void {
|
||||
this.db
|
||||
.query("INSERT INTO keybindings (id, chord) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET chord = excluded.chord")
|
||||
.run(id, chord);
|
||||
}
|
||||
|
||||
deleteKeybinding(id: string): void {
|
||||
this.db.query("DELETE FROM keybindings WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
close(): void {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue