feat: add merge endpoint and button, remove unused close/send endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-22 13:46:59 +00:00
co-authored by Claude Opus 4.6
parent 5eb308ad35
commit 96c2d88d91
7 changed files with 68 additions and 40 deletions
+7 -17
View File
@@ -4,8 +4,7 @@ import {
addWorktree,
removeWorktree,
openWorktree,
closeWorktree,
sendPrompt,
mergeWorktree,
readEnvLocal,
type Profile,
type Agent,
@@ -226,22 +225,13 @@ async function handleApi(req: Request, url: URL): Promise<Response> {
return jsonResponse({ message: await openWorktree(name) });
}
// POST /api/worktrees/:name/close
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "close" && method === "POST") {
// POST /api/worktrees/:name/merge
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "merge" && method === "POST") {
const name = decodeURIComponent(parts[1]);
console.log(`[worktree:close] name=${name}`);
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);
}
console.log(`[worktree:send] name=${name} prompt="${body.prompt.slice(0, 80)}"`);
return jsonResponse({ message: await sendPrompt(name, body.prompt) });
console.log(`[worktree:merge] name=${name}`);
const result = await mergeWorktree(name);
console.log(`[worktree:merge] done name=${name}: ${result}`);
return jsonResponse({ message: result });
}
// GET /api/worktrees/:name/status
+6 -6
View File
@@ -239,12 +239,12 @@ 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 mergeWorktree(name: string): Promise<string> {
console.log(`[workmux:merge] running: workmux merge ${name}`);
stopForwarding(name);
const result = await runChecked(["workmux", "merge", name]);
console.log(`[workmux:merge] result: ${result}`);
return result;
}
export async function getTmuxSession(): Promise<string> {
+43 -2
View File
@@ -13,6 +13,9 @@
let worktrees = $state<WorktreeInfo[]>([]);
let selectedBranch = $state<string | null>(null);
let removeBranch = $state<string | null>(null);
let mergeBranch = $state<string | null>(null);
let merging = $state(false);
let mergeError = $state("");
let removingBranches = $state<Set<string>>(new Set());
const SSH_STORAGE_KEY = "wt-ssh-host";
let showCreateDialog = $state(false);
@@ -84,6 +87,31 @@
}
}
async function handleMerge() {
const branch = mergeBranch;
if (!branch) return;
merging = true;
mergeError = "";
try {
await api.mergeWorktree(branch);
mergeBranch = null;
if (selectedBranch === branch) {
const idx = visibleWorktrees.findIndex((w) => w.branch === branch);
const neighbor = visibleWorktrees[idx - 1] ?? visibleWorktrees[idx + 1];
const isNeighborMain = neighbor && (neighbor.path === "(here)" || neighbor.branch === "main");
selectedBranch = neighbor && !isNeighborMain ? neighbor.branch : null;
}
await refresh();
} catch (err) {
mergeError = err instanceof Error ? err.message : String(err);
} finally {
merging = false;
}
}
function selectNeighborWorktree(direction: -1 | 1) {
const selectable = visibleWorktrees.filter(
(w) => w.path !== "(here)" && w.branch !== "main" && !removingBranches.has(w.branch)
@@ -102,7 +130,7 @@
function handleKeydown(e: KeyboardEvent) {
// Ignore shortcuts when a dialog is open (let dialog handle its own keys)
if (showCreateDialog || removeBranch) return;
if (showCreateDialog || removeBranch || mergeBranch) return;
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
@@ -157,13 +185,14 @@
name={selectedBranch}
worktree={selectedWorktree}
{sshHost}
onmerge={() => { if (selectedBranch) mergeBranch = selectedBranch; }}
onremove={() => { if (selectedBranch) removeBranch = selectedBranch; }}
onsettings={() => (showSettingsDialog = true)}
/>
{#if canConnect}
{#key selectedBranch}
<Terminal worktree={selectedBranch} />
<Terminal worktree={selectedBranch!} />
{/key}
{:else}
<div class="flex-1 flex items-center justify-center text-muted text-sm">
@@ -195,6 +224,18 @@
/>
{/if}
{#if mergeBranch}
<ConfirmDialog
message={`Merge worktree "${mergeBranch}" into main? The worktree will be removed after merging.`}
confirmLabel="Merge"
variant="accent"
loading={merging}
error={mergeError}
onconfirm={handleMerge}
oncancel={() => { mergeBranch = null; mergeError = ""; }}
/>
{/if}
{#if showSettingsDialog}
<SettingsDialog
onsave={(host) => { sshHost = host; showSettingsDialog = false; }}
@@ -1,7 +1,10 @@
<script lang="ts">
let { message, loading = false, onconfirm, oncancel }: {
let { message, loading = false, error = "", confirmLabel = "Remove", variant = "danger", onconfirm, oncancel }: {
message: string;
loading?: boolean;
error?: string;
confirmLabel?: string;
variant?: "danger" | "accent";
onconfirm: () => void;
oncancel: () => void;
} = $props();
@@ -19,14 +22,14 @@
<form method="dialog" onsubmit={(e) => { e.preventDefault(); onconfirm(); }}>
<h2 class="text-base mb-4">Confirm</h2>
<p class="text-[13px] text-muted mb-6">{message}</p>
{#if error}<p class="text-[12px] text-danger mb-4 -mt-2 whitespace-pre-wrap">{error}</p>{/if}
<div class="flex justify-end gap-2">
<button type="button" class={btn} onclick={oncancel} disabled={loading}>Cancel</button>
<button
autofocus
type="submit"
class="{btn} !bg-danger !text-white !border-danger hover:!opacity-90 disabled:!opacity-50 disabled:!cursor-not-allowed flex items-center gap-1.5"
class="{btn} !text-white hover:!opacity-90 disabled:!opacity-50 disabled:!cursor-not-allowed flex items-center gap-1.5 {variant === 'accent' ? '!bg-accent !border-accent' : '!bg-danger !border-danger'}"
disabled={loading}
>{#if loading}<span class="spinner"></span>{/if} Remove</button>
>{#if loading}<span class="spinner"></span>{/if} {confirmLabel}</button>
</div>
</form>
</dialog>
@@ -39,7 +39,6 @@
class="w-full px-2.5 py-1.5 rounded-md border border-edge bg-surface text-primary text-[13px] placeholder:text-muted/50 outline-none focus:border-accent"
placeholder="e.g. devbox or 10.0.0.5"
bind:value={sshHost}
autofocus
/>
<p class="text-[11px] text-muted mt-1.5">
Must match an entry in your local <code class="text-accent/80">~/.ssh/config</code>. Leave empty for local mode.
+3 -1
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import type { WorktreeInfo } from "./types";
let { name, worktree, sshHost, onremove, onsettings }: {
let { name, worktree, sshHost, onmerge, onremove, onsettings }: {
name: string | null;
worktree: WorktreeInfo | undefined;
sshHost: string;
onmerge: () => void;
onremove: () => void;
onsettings: () => void;
} = $props();
@@ -58,6 +59,7 @@
{#if name}
<div class="flex gap-2 items-center">
<span class="text-xs px-2 py-0.5 rounded-xl bg-hover">{worktree?.status || worktree?.agent || ""}</span>
<button class="{btn} !text-accent !border-accent hover:!bg-accent/10" onclick={onmerge} title="Merge worktree">Merge</button>
<button class="{btn} !text-danger !border-danger hover:!bg-danger/10" onclick={onremove} title="Remove worktree">Remove</button>
</div>
{/if}
+2 -9
View File
@@ -36,13 +36,6 @@ export function openWorktree(name: string): Promise<unknown> {
return api(`worktrees/${encodeURIComponent(name)}/open`, { method: "POST" });
}
export function closeWorktree(name: string): Promise<unknown> {
return api(`worktrees/${encodeURIComponent(name)}/close`, { method: "POST" });
}
export function sendPrompt(name: string, prompt: string): Promise<unknown> {
return api(`worktrees/${encodeURIComponent(name)}/send`, {
method: "POST",
body: JSON.stringify({ prompt }),
});
export function mergeWorktree(name: string): Promise<unknown> {
return api(`worktrees/${encodeURIComponent(name)}/merge`, { method: "POST" });
}