The kill button now sends the unforced DELETE immediately instead of asking first. Only the daemon's guard refusal raises a prompt, and it is a real dialog rather than confirm(), so it can offer the override the daemon already implements. The API error envelope is decoded into ApiRequestError, keeping the code and the unsaved-work breakdown so the dialog names what is in the way.
105 lines
3.6 KiB
TypeScript
105 lines
3.6 KiB
TypeScript
/**
|
|
* Forced-kill prompt (#81).
|
|
*
|
|
* The daemon refuses to kill a session whose worktree still holds uncommitted
|
|
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
|
* refusal gets its own dialog: it names what is in the way and offers the
|
|
* override the daemon's `force=true` already implements. A kill the daemon
|
|
* accepts never reaches here — there is nothing to warn about.
|
|
*/
|
|
import { formatInTheWay, type InTheWay } from "./types.js";
|
|
|
|
export interface ForceKillPrompt {
|
|
/** Session name, as the sidebar shows it. */
|
|
name: string;
|
|
/** What the daemon reported in the way, when it reported anything. */
|
|
blockers: InTheWay | null;
|
|
/** Message from the daemon's refusal, shown when there is no breakdown. */
|
|
reason: string;
|
|
/** Run the forced kill. A rejection is rendered in the dialog, which stays
|
|
* open so the override can be retried. */
|
|
onForce: () => Promise<void>;
|
|
}
|
|
|
|
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
|
|
|
|
export function closeForceKillDialog(): void {
|
|
if (!open) return;
|
|
window.removeEventListener("keydown", open.onKeydown, true);
|
|
open.el.remove();
|
|
open = null;
|
|
}
|
|
|
|
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
|
* can never stack two dialogs. */
|
|
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|
closeForceKillDialog();
|
|
|
|
const el = document.createElement("div");
|
|
el.className = "kill-dialog";
|
|
el.setAttribute("role", "dialog");
|
|
el.setAttribute("aria-modal", "true");
|
|
el.setAttribute("aria-label", `Kill ${p.name}`);
|
|
|
|
const panel = document.createElement("div");
|
|
panel.className = "kill-dialog-panel";
|
|
|
|
const title = document.createElement("h2");
|
|
title.className = "kill-dialog-title";
|
|
title.textContent = `Kill "${p.name}"?`;
|
|
|
|
const body = document.createElement("p");
|
|
body.className = "kill-dialog-body";
|
|
body.textContent = p.blockers
|
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
|
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
|
|
|
|
const error = document.createElement("p");
|
|
error.className = "kill-dialog-error";
|
|
error.hidden = true;
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "kill-dialog-actions";
|
|
const cancel = document.createElement("button");
|
|
cancel.type = "button";
|
|
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
|
cancel.textContent = "Cancel";
|
|
cancel.addEventListener("click", closeForceKillDialog);
|
|
const force = document.createElement("button");
|
|
force.type = "button";
|
|
force.className = "btn btn-danger kill-dialog-force";
|
|
force.textContent = "Force kill";
|
|
force.addEventListener("click", () => {
|
|
force.disabled = true;
|
|
void p
|
|
.onForce()
|
|
.then(closeForceKillDialog)
|
|
.catch((e: unknown) => {
|
|
force.disabled = false;
|
|
error.hidden = false;
|
|
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
|
});
|
|
});
|
|
actions.append(cancel, force);
|
|
|
|
panel.append(title, body, error, actions);
|
|
|
|
// A press on the scrim cancels; one inside the panel is left alone.
|
|
el.addEventListener("click", (ev) => {
|
|
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
|
|
});
|
|
el.appendChild(panel);
|
|
document.body.appendChild(el);
|
|
|
|
const onKeydown = (ev: KeyboardEvent): void => {
|
|
if (ev.key !== "Escape") return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
closeForceKillDialog();
|
|
};
|
|
window.addEventListener("keydown", onKeydown, true);
|
|
open = { el, onKeydown };
|
|
cancel.focus();
|
|
return el;
|
|
}
|