A session's rail glows AwaitingInput even after the operator has already looked at it. Clients now report pane focus (POST /:id/focus); the daemon clears the glow to Idle after 5s of continuous focus and keeps it cleared until the session does new work, so a fresh Active cycle is required to glow again.
156 lines
5.7 KiB
TypeScript
156 lines
5.7 KiB
TypeScript
import * as routes from "./routes.js";
|
|
import type {
|
|
ApiError,
|
|
InTheWay,
|
|
Branches,
|
|
CreateSessionRequest,
|
|
DaemonInfo,
|
|
DirListing,
|
|
GitRoot,
|
|
NameBatch,
|
|
Projects,
|
|
RecentDirs,
|
|
Session,
|
|
Uuid,
|
|
} from "./types.js";
|
|
|
|
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
|
|
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
|
|
* render `unsavedWork` rather than re-parsing the message. */
|
|
export class ApiRequestError extends Error {
|
|
readonly status: number;
|
|
readonly code: string;
|
|
readonly detail: string;
|
|
readonly unsavedWork: InTheWay | null;
|
|
|
|
constructor(res: Response, info?: ApiError["error"], text?: string) {
|
|
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
|
|
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
|
this.name = "ApiRequestError";
|
|
this.status = res.status;
|
|
this.code = info?.code ?? "";
|
|
this.detail = info?.message ?? text ?? "";
|
|
this.unsavedWork = info?.unsaved_work ?? null;
|
|
}
|
|
}
|
|
|
|
async function jsonOrError<T>(res: Response): Promise<T> {
|
|
if (res.ok) {
|
|
if (res.status === 204) return undefined as unknown as T;
|
|
return (await res.json()) as T;
|
|
}
|
|
try {
|
|
const body = (await res.json()) as ApiError;
|
|
throw new ApiRequestError(res, body.error);
|
|
} catch (e) {
|
|
if (e instanceof ApiRequestError) throw e;
|
|
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
|
|
}
|
|
}
|
|
|
|
export async function listSessions(): Promise<Session[]> {
|
|
return jsonOrError(await fetch(routes.SESSIONS, { credentials: "same-origin" }));
|
|
}
|
|
|
|
export async function createSession(req: CreateSessionRequest): Promise<Session> {
|
|
return jsonOrError(
|
|
await fetch(routes.SESSIONS, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(req),
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Rename a session (#125). An empty name clears the custom one, so the card
|
|
* falls back to the command. The daemon trims and caps what it stores and
|
|
* broadcasts the update, so callers need not patch the store themselves. */
|
|
export async function renameSession(id: Uuid, name: string): Promise<Session> {
|
|
return jsonOrError(
|
|
await fetch(routes.session(id), {
|
|
method: "PATCH",
|
|
credentials: "same-origin",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Kill a session. With `force` (#81) the daemon overrides its refusal over a
|
|
* worktree holding unsaved work and leaves that worktree on disk. */
|
|
export async function killSession(id: Uuid, force = false): Promise<void> {
|
|
const url = force
|
|
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
|
|
: routes.session(id);
|
|
await jsonOrError(
|
|
await fetch(url, {
|
|
method: "DELETE",
|
|
credentials: "same-origin",
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Report whether this client's pane is the one currently showing `id` — not
|
|
* merely highlighted in a sidebar. The daemon uses this to clear an
|
|
* `AwaitingInput` glow once the pane has been looked at long enough
|
|
* (docs/SIDEBAR.md). Best-effort: a failure here only delays the glow
|
|
* clearing, so callers don't need to surface it. */
|
|
export async function setSessionFocus(id: Uuid, focused: boolean): Promise<void> {
|
|
await jsonOrError(
|
|
await fetch(routes.sessionFocus(id), {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ focused }),
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** List one directory's immediate subdirectories (NAP-329). `path` may be empty,
|
|
* `~`, relative, or absolute — the daemon resolves it against its home dir. */
|
|
export async function listDir(path: string): Promise<DirListing> {
|
|
const url = routes.withPathQuery(routes.DIRS, path);
|
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
|
}
|
|
|
|
/** Identity + spawn presets of the daemon this web app is connected to (#26). */
|
|
export async function getDaemonInfo(): Promise<DaemonInfo> {
|
|
return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" }));
|
|
}
|
|
|
|
/** Recently-used working dirs, newest-first and deduped by dir (#26). */
|
|
export async function recentDirs(): Promise<RecentDirs> {
|
|
return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" }));
|
|
}
|
|
|
|
/** Every discovered git repository / worktree, newest-activity-first (#54).
|
|
* Drives the spawn form's project picker. */
|
|
export async function projects(): Promise<Projects> {
|
|
return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" }));
|
|
}
|
|
|
|
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
|
|
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
|
|
* it against its home dir. */
|
|
export async function gitRoot(path: string): Promise<GitRoot> {
|
|
const url = routes.withPathQuery(routes.GITROOT, path);
|
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
|
}
|
|
|
|
/** A batch of fresh session handles for the spawn form's instant reroll (#53).
|
|
* Rerolls consume the batch locally; only exhaustion goes back to the daemon. */
|
|
export async function fetchNames(count: number): Promise<NameBatch> {
|
|
const url = `${routes.NAMES}?${routes.COUNT_QUERY}=${encodeURIComponent(count)}`;
|
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
|
}
|
|
|
|
/** One repository's local branches, with worktree occupancy (#55). Empty for
|
|
* a path outside any repo. Drives the spawn form's existing-branch picker. */
|
|
export async function fetchBranches(path: string): Promise<Branches> {
|
|
const url = routes.withPathQuery(routes.BRANCHES, path);
|
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
|
}
|
|
|
|
export { eventsWsUrl } from "./routes.js";
|