mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
feat: add dev-dashboard — browser-based workmux frontend
Web dashboard (Bun + xterm.js) that wraps workmux CLI commands and renders tmux windows in embedded browser terminals. Replaces direct tmux navigation with a sidebar-based UI at localhost:5111. - Bun HTTP server with REST API for worktree CRUD (add/rm/open/close/send) - Bun.Terminal PTY API to attach to tmux grouped sessions per worktree - xterm.js frontend with WebSocket bridge for real-time terminal I/O - Scrollback buffer for reconnection, ResizeObserver for dynamic fitting - Add direnv allow to worktree-env post-create hook for nix devshell Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
public/dist/
|
||||
bun.lock
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "windmill-dev-dashboard",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun build src/client/app.ts --outdir public/dist --minify",
|
||||
"dev": "bun run build && bun --watch src/server.ts",
|
||||
"start": "bun src/server.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Windmill Dev Dashboard</title>
|
||||
<link rel="stylesheet" href="/dist/app.css">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Windmill</h1>
|
||||
<button id="btn-new" title="New Worktree">+</button>
|
||||
</div>
|
||||
<ul id="worktree-list"></ul>
|
||||
</aside>
|
||||
<main id="main">
|
||||
<div id="top-bar">
|
||||
<span id="wt-name">Select a worktree</span>
|
||||
<div id="wt-actions" class="hidden">
|
||||
<span id="wt-status-badge"></span>
|
||||
<button id="btn-open" title="Open tmux window">Open</button>
|
||||
<button id="btn-close" title="Close tmux window">Close</button>
|
||||
<button id="btn-send" title="Send prompt to agent">Send</button>
|
||||
<button id="btn-remove" class="danger" title="Remove worktree">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-container"></div>
|
||||
<div id="placeholder">
|
||||
<p>Select a worktree from the sidebar to connect</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<dialog id="new-dialog">
|
||||
<form method="dialog">
|
||||
<h2>New Worktree</h2>
|
||||
<label>Branch name<input type="text" id="new-branch" required placeholder="my-feature"></label>
|
||||
<label>Prompt (optional)<textarea id="new-prompt" rows="3" placeholder="Fix the login bug..."></textarea></label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
|
||||
<button type="submit" class="primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="send-dialog">
|
||||
<form method="dialog">
|
||||
<h2>Send Prompt</h2>
|
||||
<label>Prompt<textarea id="send-prompt" rows="4" required placeholder="Implement the feature..."></textarea></label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
|
||||
<button type="submit" class="primary">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/dist/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,288 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--bg-sidebar: #161b22;
|
||||
--bg-topbar: #1c2128;
|
||||
--bg-hover: #21262d;
|
||||
--bg-active: #1f6feb33;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--danger: #f85149;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-header button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-header button:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
#worktree-list {
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#worktree-list li {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#worktree-list li:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
#worktree-list li.active {
|
||||
background: var(--bg-active);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.wt-branch {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wt-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.status-dot.working { background: var(--success); }
|
||||
.status-dot.waiting { background: var(--warning); }
|
||||
.status-dot.stopped { background: var(--text-muted); }
|
||||
.status-dot.error { background: var(--danger); }
|
||||
|
||||
/* Main */
|
||||
#main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-topbar);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
#wt-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#wt-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#wt-actions.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#wt-status-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #f8514922;
|
||||
}
|
||||
|
||||
/* Terminal */
|
||||
#terminal-container {
|
||||
flex: 1;
|
||||
display: none;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#terminal-container.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#placeholder.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
dialog {
|
||||
background: var(--bg-sidebar);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 440px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
dialog h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
dialog label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
dialog input,
|
||||
dialog textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
dialog input:focus,
|
||||
dialog textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* xterm overrides */
|
||||
.xterm {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface WorktreeInfo {
|
||||
branch: string;
|
||||
agent: string;
|
||||
mux: string;
|
||||
path: string;
|
||||
status: string;
|
||||
elapsed: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
let worktrees: WorktreeInfo[] = [];
|
||||
let selectedWorktree: string | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let term: Terminal | null = null;
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// --- DOM refs ---
|
||||
|
||||
const worktreeList = document.getElementById("worktree-list") as HTMLUListElement;
|
||||
const wtName = document.getElementById("wt-name") as HTMLSpanElement;
|
||||
const wtActions = document.getElementById("wt-actions") as HTMLDivElement;
|
||||
const wtStatusBadge = document.getElementById("wt-status-badge") as HTMLSpanElement;
|
||||
const terminalContainer = document.getElementById("terminal-container") as HTMLDivElement;
|
||||
const placeholder = document.getElementById("placeholder") as HTMLDivElement;
|
||||
|
||||
const btnNew = document.getElementById("btn-new") as HTMLButtonElement;
|
||||
const btnOpen = document.getElementById("btn-open") as HTMLButtonElement;
|
||||
const btnClose = document.getElementById("btn-close") as HTMLButtonElement;
|
||||
const btnSend = document.getElementById("btn-send") as HTMLButtonElement;
|
||||
const btnRemove = document.getElementById("btn-remove") as HTMLButtonElement;
|
||||
|
||||
const newDialog = document.getElementById("new-dialog") as HTMLDialogElement;
|
||||
const sendDialog = document.getElementById("send-dialog") as HTMLDialogElement;
|
||||
|
||||
// --- API helpers ---
|
||||
|
||||
async function api<T = unknown>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api/${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// --- Worktree list ---
|
||||
|
||||
async function refreshWorktrees(): Promise<void> {
|
||||
try {
|
||||
worktrees = await api<WorktreeInfo[]>("worktrees");
|
||||
renderWorktreeList();
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh worktrees:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function statusDotClass(agent: string): string {
|
||||
if (agent === "working") return "working";
|
||||
if (agent === "waiting") return "waiting";
|
||||
if (agent === "error") return "error";
|
||||
return "stopped";
|
||||
}
|
||||
|
||||
function renderWorktreeList(): void {
|
||||
worktreeList.innerHTML = "";
|
||||
for (const wt of worktrees) {
|
||||
const li = document.createElement("li");
|
||||
if (wt.branch === selectedWorktree) li.classList.add("active");
|
||||
|
||||
const isMain = wt.path === "(here)" || wt.branch === "main";
|
||||
|
||||
li.innerHTML = `
|
||||
<span class="wt-branch">${escapeHtml(wt.branch)}</span>
|
||||
<span class="wt-meta">
|
||||
<span><span class="status-dot ${statusDotClass(wt.agent)}"></span>${escapeHtml(wt.agent || "none")}</span>
|
||||
${wt.mux && wt.mux !== "-" ? `<span>mux: ${escapeHtml(wt.mux)}</span>` : ""}
|
||||
${isMain ? "<span>main</span>" : ""}
|
||||
</span>
|
||||
`;
|
||||
|
||||
li.addEventListener("click", () => selectWorktree(wt.branch));
|
||||
worktreeList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// --- Terminal ---
|
||||
|
||||
function selectWorktree(branch: string): void {
|
||||
if (selectedWorktree === branch) return;
|
||||
selectedWorktree = branch;
|
||||
|
||||
// Update sidebar selection
|
||||
renderWorktreeList();
|
||||
|
||||
// Update top bar
|
||||
const wt = worktrees.find(w => w.branch === branch);
|
||||
wtName.textContent = branch;
|
||||
wtActions.classList.remove("hidden");
|
||||
wtStatusBadge.textContent = wt?.status || wt?.agent || "";
|
||||
|
||||
// Only connect terminal for non-main worktrees that have a tmux window
|
||||
const isMain = wt?.path === "(here)" || branch === "main";
|
||||
if (isMain) {
|
||||
disconnectTerminal();
|
||||
placeholder.classList.remove("hidden");
|
||||
placeholder.querySelector("p")!.textContent = "Main worktree — use workmux to manage";
|
||||
terminalContainer.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
|
||||
connectTerminal(branch);
|
||||
}
|
||||
|
||||
function connectTerminal(worktree: string): void {
|
||||
disconnectTerminal();
|
||||
|
||||
// Show terminal container
|
||||
placeholder.classList.add("hidden");
|
||||
terminalContainer.classList.add("visible");
|
||||
|
||||
// Create terminal
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: "#0d1117",
|
||||
foreground: "#e6edf3",
|
||||
cursor: "#58a6ff",
|
||||
selectionBackground: "#264f78",
|
||||
},
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace",
|
||||
fontSize: 13,
|
||||
scrollback: 10000,
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(terminalContainer);
|
||||
|
||||
// Delay fit() so the container has its final dimensions after display:none → block
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon?.fit();
|
||||
});
|
||||
|
||||
// WebSocket connection
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(worktree)}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
case "scrollback":
|
||||
case "output":
|
||||
term?.write(msg.data);
|
||||
break;
|
||||
case "exit":
|
||||
term?.writeln(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m`);
|
||||
break;
|
||||
case "error":
|
||||
term?.writeln(`\r\n\x1b[31m[Error: ${msg.message}]\x1b[0m`);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send actual fitted dimensions once connected
|
||||
if (term && fitAddon) {
|
||||
fitAddon.fit();
|
||||
ws!.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term?.writeln("\r\n\x1b[90m[Disconnected]\x1b[0m");
|
||||
};
|
||||
|
||||
// Terminal input → WebSocket
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon?.fit();
|
||||
if (term && ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(terminalContainer);
|
||||
}
|
||||
|
||||
function disconnectTerminal(): void {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
ws?.close();
|
||||
ws = null;
|
||||
term?.dispose();
|
||||
term = null;
|
||||
fitAddon = null;
|
||||
terminalContainer.innerHTML = "";
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
btnNew.addEventListener("click", () => {
|
||||
(document.getElementById("new-branch") as HTMLInputElement).value = "";
|
||||
(document.getElementById("new-prompt") as HTMLTextAreaElement).value = "";
|
||||
newDialog.showModal();
|
||||
});
|
||||
|
||||
newDialog.querySelector("form")!.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const branch = (document.getElementById("new-branch") as HTMLInputElement).value.trim();
|
||||
const prompt = (document.getElementById("new-prompt") as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!branch) return;
|
||||
|
||||
try {
|
||||
newDialog.close();
|
||||
await api("worktrees", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ branch, prompt: prompt || undefined }),
|
||||
});
|
||||
await refreshWorktrees();
|
||||
selectWorktree(branch);
|
||||
} catch (err) {
|
||||
alert(`Failed to create worktree: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
|
||||
btnOpen.addEventListener("click", async () => {
|
||||
if (!selectedWorktree) return;
|
||||
try {
|
||||
await api(`worktrees/${encodeURIComponent(selectedWorktree)}/open`, { method: "POST" });
|
||||
await refreshWorktrees();
|
||||
// Reconnect terminal since tmux window is now open
|
||||
connectTerminal(selectedWorktree);
|
||||
} catch (err) {
|
||||
alert(`Failed to open: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
|
||||
btnClose.addEventListener("click", async () => {
|
||||
if (!selectedWorktree) return;
|
||||
try {
|
||||
await api(`worktrees/${encodeURIComponent(selectedWorktree)}/close`, { method: "POST" });
|
||||
disconnectTerminal();
|
||||
placeholder.classList.remove("hidden");
|
||||
placeholder.querySelector("p")!.textContent = "Worktree closed";
|
||||
terminalContainer.classList.remove("visible");
|
||||
await refreshWorktrees();
|
||||
} catch (err) {
|
||||
alert(`Failed to close: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
|
||||
btnSend.addEventListener("click", () => {
|
||||
(document.getElementById("send-prompt") as HTMLTextAreaElement).value = "";
|
||||
sendDialog.showModal();
|
||||
});
|
||||
|
||||
sendDialog.querySelector("form")!.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const prompt = (document.getElementById("send-prompt") as HTMLTextAreaElement).value.trim();
|
||||
if (!prompt || !selectedWorktree) return;
|
||||
|
||||
try {
|
||||
sendDialog.close();
|
||||
await api(`worktrees/${encodeURIComponent(selectedWorktree)}/send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
} catch (err) {
|
||||
alert(`Failed to send prompt: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
|
||||
btnRemove.addEventListener("click", async () => {
|
||||
if (!selectedWorktree) return;
|
||||
if (!confirm(`Remove worktree "${selectedWorktree}"? This also deletes the branch.`)) return;
|
||||
|
||||
try {
|
||||
disconnectTerminal();
|
||||
await api(`worktrees/${encodeURIComponent(selectedWorktree)}`, { method: "DELETE" });
|
||||
selectedWorktree = null;
|
||||
wtName.textContent = "Select a worktree";
|
||||
wtActions.classList.add("hidden");
|
||||
placeholder.classList.remove("hidden");
|
||||
placeholder.querySelector("p")!.textContent = "Select a worktree from the sidebar to connect";
|
||||
terminalContainer.classList.remove("visible");
|
||||
await refreshWorktrees();
|
||||
} catch (err) {
|
||||
alert(`Failed to remove: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Init ---
|
||||
|
||||
refreshWorktrees();
|
||||
setInterval(refreshWorktrees, 5000);
|
||||
@@ -0,0 +1,213 @@
|
||||
import { file } from "bun";
|
||||
import * as path from "path";
|
||||
import {
|
||||
listWorktrees,
|
||||
getStatus,
|
||||
addWorktree,
|
||||
removeWorktree,
|
||||
openWorktree,
|
||||
closeWorktree,
|
||||
sendPrompt,
|
||||
} from "./workmux";
|
||||
import {
|
||||
attach,
|
||||
detach,
|
||||
write,
|
||||
resize,
|
||||
getScrollback,
|
||||
setCallbacks,
|
||||
clearCallbacks,
|
||||
} from "./terminal";
|
||||
|
||||
const PORT = parseInt(process.env.DASHBOARD_PORT || "5111");
|
||||
const PUBLIC_DIR = path.join(import.meta.dir, "..", "public");
|
||||
|
||||
function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(message: string, status = 500): Response {
|
||||
return jsonResponse({ error: message }, status);
|
||||
}
|
||||
|
||||
async function serveStatic(pathname: string): Promise<Response> {
|
||||
if (pathname === "/" || pathname === "") pathname = "/index.html";
|
||||
|
||||
const filePath = path.join(PUBLIC_DIR, pathname);
|
||||
|
||||
if (!filePath.startsWith(PUBLIC_DIR)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const f = file(filePath);
|
||||
if (await f.exists()) {
|
||||
return new Response(f);
|
||||
}
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
interface WsData {
|
||||
worktree: string;
|
||||
}
|
||||
|
||||
function makeCallbacks(ws: { send: (data: string) => void; readyState: number }) {
|
||||
return {
|
||||
onData: (data: string) => {
|
||||
if (ws.readyState <= 1) {
|
||||
ws.send(JSON.stringify({ type: "output", data }));
|
||||
}
|
||||
},
|
||||
onExit: (exitCode: number) => {
|
||||
if (ws.readyState <= 1) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Bun.serve<WsData>({
|
||||
port: PORT,
|
||||
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const wsMatch = url.pathname.match(/^\/ws\/(.+)$/);
|
||||
if (wsMatch) {
|
||||
const worktree = decodeURIComponent(wsMatch[1]);
|
||||
const upgraded = server.upgrade(req, { data: { worktree } });
|
||||
if (upgraded) return undefined as unknown as Response;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return handleApi(req, url);
|
||||
}
|
||||
|
||||
return serveStatic(url.pathname);
|
||||
},
|
||||
|
||||
websocket: {
|
||||
async open(ws) {
|
||||
const { worktree } = ws.data;
|
||||
const cols = 120;
|
||||
const rows = 30;
|
||||
|
||||
try {
|
||||
await attach(worktree, cols, rows);
|
||||
|
||||
const { onData, onExit } = makeCallbacks(ws);
|
||||
setCallbacks(worktree, onData, onExit);
|
||||
|
||||
const scrollback = getScrollback(worktree);
|
||||
if (scrollback) {
|
||||
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ws.send(JSON.stringify({ type: "error", message }));
|
||||
ws.close();
|
||||
}
|
||||
},
|
||||
|
||||
message(ws, message) {
|
||||
try {
|
||||
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
||||
const { worktree } = ws.data;
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
write(worktree, msg.data);
|
||||
break;
|
||||
case "resize":
|
||||
resize(worktree, msg.cols, msg.rows);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
},
|
||||
|
||||
async close(ws) {
|
||||
clearCallbacks(ws.data.worktree);
|
||||
await detach(ws.data.worktree);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function handleApi(req: Request, url: URL): Promise<Response> {
|
||||
const method = req.method;
|
||||
const parts = url.pathname.slice(5).split("/").filter(Boolean);
|
||||
|
||||
try {
|
||||
// GET /api/worktrees
|
||||
if (parts[0] === "worktrees" && parts.length === 1 && method === "GET") {
|
||||
const [worktrees, status] = await Promise.all([listWorktrees(), getStatus()]);
|
||||
const merged = worktrees.map(wt => {
|
||||
const st = status.find(s =>
|
||||
s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch)
|
||||
);
|
||||
return { ...wt, status: st?.status ?? "", elapsed: st?.elapsed ?? "", title: st?.title ?? "" };
|
||||
});
|
||||
return jsonResponse(merged);
|
||||
}
|
||||
|
||||
// POST /api/worktrees
|
||||
if (parts[0] === "worktrees" && parts.length === 1 && method === "POST") {
|
||||
const body = await req.json() as { branch?: string; prompt?: string; autoName?: boolean };
|
||||
if (!body.branch && !body.autoName) {
|
||||
return errorResponse("branch is required (or use autoName)", 400);
|
||||
}
|
||||
const result = await addWorktree(body.branch || "", {
|
||||
prompt: body.prompt,
|
||||
autoName: body.autoName,
|
||||
});
|
||||
return jsonResponse({ message: result }, 201);
|
||||
}
|
||||
|
||||
// DELETE /api/worktrees/:name
|
||||
if (parts[0] === "worktrees" && parts.length === 2 && method === "DELETE") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
return jsonResponse({ message: await removeWorktree(name) });
|
||||
}
|
||||
|
||||
// POST /api/worktrees/:name/open
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "open" && method === "POST") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
return jsonResponse({ message: await openWorktree(name) });
|
||||
}
|
||||
|
||||
// POST /api/worktrees/:name/close
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "close" && method === "POST") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
return jsonResponse({ message: await closeWorktree(name) });
|
||||
}
|
||||
|
||||
// POST /api/worktrees/:name/send
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "send" && method === "POST") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
const body = await req.json() as { prompt?: string };
|
||||
if (!body.prompt) {
|
||||
return errorResponse("prompt is required", 400);
|
||||
}
|
||||
return jsonResponse({ message: await sendPrompt(name, body.prompt) });
|
||||
}
|
||||
|
||||
// GET /api/worktrees/:name/status
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "status" && method === "GET") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
const status = await getStatus();
|
||||
const match = status.find(s => s.worktree.includes(name));
|
||||
return jsonResponse(match ?? { status: "unknown" });
|
||||
}
|
||||
|
||||
return errorResponse("Not Found", 404);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return errorResponse(message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Dev Dashboard running at http://localhost:${PORT}`);
|
||||
@@ -0,0 +1,125 @@
|
||||
import { getTmuxSession } from "./workmux";
|
||||
|
||||
interface TerminalSession {
|
||||
proc: ReturnType<typeof Bun.spawn>;
|
||||
groupedSessionName: string;
|
||||
scrollback: string[];
|
||||
onData: ((data: string) => void) | null;
|
||||
onExit: ((exitCode: number) => void) | null;
|
||||
}
|
||||
|
||||
const MAX_SCROLLBACK = 5000;
|
||||
const sessions = new Map<string, TerminalSession>();
|
||||
let sessionCounter = 0;
|
||||
|
||||
function groupedName(): string {
|
||||
return `wm-dash-${++sessionCounter}`;
|
||||
}
|
||||
|
||||
export async function attach(
|
||||
worktreeName: string,
|
||||
cols: number,
|
||||
rows: number
|
||||
): Promise<string> {
|
||||
if (sessions.has(worktreeName)) {
|
||||
await detach(worktreeName);
|
||||
}
|
||||
|
||||
const tmuxSession = await getTmuxSession();
|
||||
const gName = groupedName();
|
||||
const windowTarget = `wm-${worktreeName}`;
|
||||
|
||||
// Create a grouped tmux session (independent sizing) and attach to the worktree's window
|
||||
const cmd = [
|
||||
`tmux new-session -d -s "${gName}" -t "${tmuxSession}"`,
|
||||
`tmux select-window -t "${gName}:${windowTarget}"`,
|
||||
`exec tmux attach-session -t "${gName}"`,
|
||||
].join(" && ");
|
||||
|
||||
const session: TerminalSession = {
|
||||
proc: null as any,
|
||||
groupedSessionName: gName,
|
||||
scrollback: [],
|
||||
onData: null,
|
||||
onExit: null,
|
||||
};
|
||||
|
||||
sessions.set(worktreeName, session);
|
||||
|
||||
const proc = Bun.spawn(["bash", "-c", cmd], {
|
||||
terminal: {
|
||||
cols,
|
||||
rows,
|
||||
name: "xterm-256color",
|
||||
data(_terminal, data) {
|
||||
const str = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
session.scrollback.push(str);
|
||||
if (session.scrollback.length > MAX_SCROLLBACK) {
|
||||
session.scrollback.shift();
|
||||
}
|
||||
session.onData?.(str);
|
||||
},
|
||||
exit() {},
|
||||
},
|
||||
});
|
||||
|
||||
session.proc = proc;
|
||||
|
||||
proc.exited.then((exitCode) => {
|
||||
session.onExit?.(exitCode);
|
||||
sessions.delete(worktreeName);
|
||||
try {
|
||||
Bun.spawnSync(["tmux", "kill-session", "-t", gName]);
|
||||
} catch {
|
||||
// Session may already be gone
|
||||
}
|
||||
});
|
||||
|
||||
return worktreeName;
|
||||
}
|
||||
|
||||
export async function detach(worktreeName: string): Promise<void> {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) return;
|
||||
|
||||
session.proc.kill();
|
||||
sessions.delete(worktreeName);
|
||||
|
||||
try {
|
||||
Bun.spawnSync(["tmux", "kill-session", "-t", session.groupedSessionName]);
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
}
|
||||
|
||||
export function write(worktreeName: string, data: string): void {
|
||||
sessions.get(worktreeName)?.proc.terminal?.write(data);
|
||||
}
|
||||
|
||||
export function resize(worktreeName: string, cols: number, rows: number): void {
|
||||
sessions.get(worktreeName)?.proc.terminal?.resize(cols, rows);
|
||||
}
|
||||
|
||||
export function getScrollback(worktreeName: string): string {
|
||||
return sessions.get(worktreeName)?.scrollback.join("") ?? "";
|
||||
}
|
||||
|
||||
export function setCallbacks(
|
||||
worktreeName: string,
|
||||
onData: (data: string) => void,
|
||||
onExit: (exitCode: number) => void
|
||||
): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (session) {
|
||||
session.onData = onData;
|
||||
session.onExit = onExit;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCallbacks(worktreeName: string): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (session) {
|
||||
session.onData = null;
|
||||
session.onExit = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { $ } from "bun";
|
||||
|
||||
export interface Worktree {
|
||||
branch: string;
|
||||
agent: string;
|
||||
mux: string;
|
||||
unmerged: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface WorktreeStatus {
|
||||
worktree: string;
|
||||
status: string;
|
||||
elapsed: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function parseTable<T>(output: string, mapper: (cols: string[]) => T): T[] {
|
||||
const lines = output.trim().split("\n").filter(Boolean);
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headerLine = lines[0];
|
||||
|
||||
// Find column positions based on header spacing
|
||||
const colStarts: number[] = [];
|
||||
let inSpace = true;
|
||||
for (let i = 0; i < headerLine.length; i++) {
|
||||
if (headerLine[i] !== " " && inSpace) {
|
||||
colStarts.push(i);
|
||||
inSpace = false;
|
||||
} else if (headerLine[i] === " " && !inSpace) {
|
||||
inSpace = true;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.slice(1).map(line => {
|
||||
const cols = colStarts.map((start, idx) => {
|
||||
const end = idx + 1 < colStarts.length ? colStarts[idx + 1] : line.length;
|
||||
return line.slice(start, end).trim();
|
||||
});
|
||||
return mapper(cols);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWorktrees(): Promise<Worktree[]> {
|
||||
const result = await $`workmux list`.text();
|
||||
return parseTable(result, (cols) => ({
|
||||
branch: cols[0] ?? "",
|
||||
agent: cols[1] ?? "",
|
||||
mux: cols[2] ?? "",
|
||||
unmerged: cols[3] ?? "",
|
||||
path: cols[4] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<WorktreeStatus[]> {
|
||||
const result = await $`workmux status`.text();
|
||||
return parseTable(result, (cols) => ({
|
||||
worktree: cols[0] ?? "",
|
||||
status: cols[1] ?? "",
|
||||
elapsed: cols[2] ?? "",
|
||||
title: cols[3] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
async function runChecked(args: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`${args.join(" ")} failed: ${stderr || stdout}`);
|
||||
}
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
export async function addWorktree(
|
||||
branch: string,
|
||||
opts?: { prompt?: string; autoName?: boolean }
|
||||
): Promise<string> {
|
||||
const args: string[] = ["workmux", "add"];
|
||||
if (opts?.autoName) args.push("-A");
|
||||
if (opts?.prompt) args.push("-p", opts.prompt);
|
||||
args.push(branch);
|
||||
return runChecked(args);
|
||||
}
|
||||
|
||||
export async function removeWorktree(name: string): Promise<string> {
|
||||
return runChecked(["workmux", "rm", "--force", name]);
|
||||
}
|
||||
|
||||
export async function openWorktree(name: string): Promise<string> {
|
||||
return runChecked(["workmux", "open", name]);
|
||||
}
|
||||
|
||||
export async function closeWorktree(name: string): Promise<string> {
|
||||
return runChecked(["workmux", "close", name]);
|
||||
}
|
||||
|
||||
export async function sendPrompt(name: string, prompt: string): Promise<string> {
|
||||
return runChecked(["workmux", "send", name, prompt]);
|
||||
}
|
||||
|
||||
export async function getTmuxSession(): Promise<string> {
|
||||
try {
|
||||
const result = await $`tmux list-windows -a -F "#{session_name}:#{window_name}"`.text();
|
||||
for (const line of result.trim().split("\n")) {
|
||||
const [session, window] = line.split(":");
|
||||
if (window?.startsWith("wm-")) {
|
||||
return session!;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No tmux server running
|
||||
}
|
||||
return "0";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/client/**/*.ts"]
|
||||
}
|
||||
@@ -50,6 +50,12 @@ EOF
|
||||
|
||||
echo "Created .env.local with ports: backend=$backend_port, frontend=$frontend_port"
|
||||
|
||||
# --- Allow direnv so the nix devshell activates in pane commands ---
|
||||
if command -v direnv &>/dev/null && [ -f .envrc ]; then
|
||||
direnv allow
|
||||
echo "direnv allowed"
|
||||
fi
|
||||
|
||||
# --- Create matching windmill-ee-private worktree ---
|
||||
# Check parent directory first (sibling to worktree root), then fall back to home
|
||||
parent_dir="$(cd "$(pwd)/.." && pwd)"
|
||||
|
||||
Reference in New Issue
Block a user