Merge remote-tracking branch 'origin/main'

# Conflicts:
#	lib/crates/fabro-spa/assets/assets/entry-evtq2sja.js
#	lib/crates/fabro-spa/assets/index.html
This commit is contained in:
Bryan Helmkamp 2026-04-20 09:33:08 -04:00
commit afd6994b30
No known key found for this signature in database
5 changed files with 309 additions and 137 deletions

View file

@ -1,6 +1,13 @@
import { afterEach, describe, expect, test } from "bun:test";
import { action, lifecycleActionVisibility, loader } from "./run-detail";
import {
action,
handleLifecycleToastResult,
lifecycleActionVisibility,
loader,
type LifecycleToastState,
type RunDetailActionResult,
} from "./run-detail";
type StubFetchEntry = {
status: number;
@ -196,3 +203,117 @@ describe("lifecycleActionVisibility", () => {
expect(lifecycleActionVisibility("blocked").showBlockedNotice).toBe(true);
});
});
describe("handleLifecycleToastResult", () => {
type PushedToast = { message: string; action?: { label: string; onClick: () => void } };
function makeToastApi() {
const pushed: PushedToast[] = [];
const dismissed: string[] = [];
return {
pushed,
dismissed,
api: {
push: (toast: PushedToast) => {
pushed.push(toast);
return `toast-${pushed.length}`;
},
dismiss: (id: string) => {
dismissed.push(id);
},
},
};
}
const initialState: LifecycleToastState = {
activeArchiveToastId: null,
lastProcessed: { cancel: null, archive: null, unarchive: null },
};
test("replaying the same cancel success result does not enqueue a duplicate toast", () => {
const { pushed, dismissed, api } = makeToastApi();
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: { id: "run-1", status: "failed", status_reason: "cancelled", created_at: "2026-04-20T12:00:00Z" },
};
const firstState = handleLifecycleToastResult("cancel", result, initialState, api);
expect(pushed).toEqual([{ message: "Run cancelled." }]);
expect(firstState.lastProcessed.cancel).toBe(result);
const replayedState = handleLifecycleToastResult("cancel", result, firstState, api);
expect(pushed).toHaveLength(1);
expect(dismissed).toEqual([]);
expect(replayedState).toBe(firstState);
});
test("cancel for non-terminal state reports cancellation as requested", () => {
const { pushed, api } = makeToastApi();
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: { id: "run-1", status: "running", created_at: "2026-04-20T12:00:00Z" },
};
handleLifecycleToastResult("cancel", result, initialState, api);
expect(pushed).toEqual([{ message: "Cancellation requested." }]);
});
test("replaying the same archive success result does not enqueue a duplicate toast", () => {
const { pushed, dismissed, api } = makeToastApi();
let unarchiveClicks = 0;
const result: RunDetailActionResult = {
intent: "archive",
ok: true,
run: { id: "run-1", status: "archived", created_at: "2026-04-20T12:00:00Z" },
};
const firstState = handleLifecycleToastResult("archive", result, initialState, api, () => {
unarchiveClicks += 1;
});
expect(pushed).toHaveLength(1);
expect(pushed[0]?.message).toBe("Run archived.");
expect(pushed[0]?.action?.label).toBe("Unarchive");
pushed[0]?.action?.onClick();
expect(unarchiveClicks).toBe(1);
expect(firstState.activeArchiveToastId).toBe("toast-1");
const replayedState = handleLifecycleToastResult("archive", result, firstState, api, () => {
unarchiveClicks += 1;
});
expect(pushed).toHaveLength(1);
expect(replayedState).toBe(firstState);
expect(dismissed).toEqual([]);
});
test("successful unarchive dismisses the active archive toast before showing restore feedback", () => {
const { pushed, dismissed, api } = makeToastApi();
const result: RunDetailActionResult = {
intent: "unarchive",
ok: true,
run: { id: "run-1", status: "succeeded", created_at: "2026-04-20T12:00:00Z" },
};
const stateWithActiveToast: LifecycleToastState = {
activeArchiveToastId: "toast-9",
lastProcessed: { cancel: null, archive: null, unarchive: null },
};
const nextState = handleLifecycleToastResult("unarchive", result, stateWithActiveToast, api);
expect(dismissed).toEqual(["toast-9"]);
expect(pushed).toEqual([{ message: "Run restored." }]);
expect(nextState.activeArchiveToastId).toBeNull();
const replayedState = handleLifecycleToastResult("unarchive", result, nextState, api);
expect(dismissed).toEqual(["toast-9"]);
expect(pushed).toEqual([{ message: "Run restored." }]);
expect(replayedState).toBe(nextState);
});
});

View file

@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { ArrowPathIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useFetcher, useLocation } from "react-router";
import type {
@ -95,6 +95,18 @@ type LifecycleActionResult =
export type RunDetailActionResult = PreviewActionResult | LifecycleActionResult;
export interface LifecycleToastState {
activeArchiveToastId: string | null;
lastProcessed: Record<LifecycleAction, RunDetailActionResult | null>;
}
type ToastApi = Pick<ReturnType<typeof useToast>, "push" | "dismiss">;
const INITIAL_LIFECYCLE_TOAST_STATE: LifecycleToastState = {
activeArchiveToastId: null,
lastProcessed: { cancel: null, archive: null, unarchive: null },
};
export function lifecycleActionVisibility(status: string | null | undefined) {
return {
showPrimaryCancel: canCancel(status),
@ -175,9 +187,10 @@ export default function RunDetail({ loaderData, params }: { loaderData: RunDetai
const cancelFetcher = useFetcher<RunDetailActionResult>();
const archiveFetcher = useFetcher<RunDetailActionResult>();
const unarchiveFetcher = useFetcher<RunDetailActionResult>();
const { push } = useToast();
const { push, dismiss } = useToast();
const demoMode = useDemoMode();
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
const lifecycleToastStateRef = useRef<LifecycleToastState>(INITIAL_LIFECYCLE_TOAST_STATE);
useRunEventSource(run?.id ?? undefined, {
allowlist: RUN_DETAIL_EVENTS,
@ -191,44 +204,32 @@ export default function RunDetail({ loaderData, params }: { loaderData: RunDetai
}, [previewFetcher.data]);
useEffect(() => {
const result = cancelFetcher.data;
if (!result || result.intent !== "cancel") return;
if (isLifecycleActionFailure(result)) {
push({ message: mapError(result.error, "cancel"), tone: "error" });
return;
}
push({
message: isTerminalCancelledRun(result.run)
? "Run cancelled."
: "Cancellation requested.",
});
}, [cancelFetcher.data, push]);
lifecycleToastStateRef.current = handleLifecycleToastResult(
"cancel",
cancelFetcher.data,
lifecycleToastStateRef.current,
{ push, dismiss },
);
}, [cancelFetcher.data, dismiss, push]);
useEffect(() => {
const result = archiveFetcher.data;
if (!result || result.intent !== "archive") return;
if (isLifecycleActionFailure(result)) {
push({ message: mapError(result.error, "archive"), tone: "error" });
return;
}
push({
message: "Run archived.",
action: {
label: "Unarchive",
onClick: () => submitIntent(unarchiveFetcher, "unarchive"),
},
});
}, [archiveFetcher, archiveFetcher.data, push, unarchiveFetcher]);
lifecycleToastStateRef.current = handleLifecycleToastResult(
"archive",
archiveFetcher.data,
lifecycleToastStateRef.current,
{ push, dismiss },
() => submitIntent(unarchiveFetcher, "unarchive"),
);
}, [archiveFetcher.data, dismiss, push, unarchiveFetcher]);
useEffect(() => {
const result = unarchiveFetcher.data;
if (!result || result.intent !== "unarchive") return;
if (isLifecycleActionFailure(result)) {
push({ message: mapError(result.error, "unarchive"), tone: "error" });
return;
}
push({ message: "Run restored." });
}, [push, unarchiveFetcher.data]);
lifecycleToastStateRef.current = handleLifecycleToastResult(
"unarchive",
unarchiveFetcher.data,
lifecycleToastStateRef.current,
{ push, dismiss },
);
}, [dismiss, push, unarchiveFetcher.data]);
if (!run) {
return (
@ -458,6 +459,51 @@ function isLifecycleActionFailure(
return value.ok === false;
}
export function handleLifecycleToastResult(
intent: LifecycleAction,
result: RunDetailActionResult | undefined,
state: LifecycleToastState,
toastApi: ToastApi,
onUnarchive?: () => void,
): LifecycleToastState {
if (!result || result.intent !== intent) return state;
if (state.lastProcessed[intent] === result) return state;
const nextState: LifecycleToastState = {
...state,
lastProcessed: { ...state.lastProcessed, [intent]: result },
};
if (isLifecycleActionFailure(result)) {
toastApi.push({ message: mapError(result.error, intent), tone: "error" });
return nextState;
}
if (intent === "cancel") {
toastApi.push({
message: isTerminalCancelledRun(result.run) ? "Run cancelled." : "Cancellation requested.",
});
return nextState;
}
if (state.activeArchiveToastId) {
toastApi.dismiss(state.activeArchiveToastId);
}
if (intent === "archive") {
return {
...nextState,
activeArchiveToastId: toastApi.push({
message: "Run archived.",
action: onUnarchive ? { label: "Unarchive", onClick: onUnarchive } : undefined,
}),
};
}
toastApi.push({ message: "Run restored." });
return { ...nextState, activeArchiveToastId: null };
}
function submitIntent(
fetcher: { submit: (target: FormData, options: { method: "post" }) => void },
intent: LifecycleAction,

View file

@ -146,12 +146,17 @@ pub fn sigusr2(pid: u32) {
}
#[cfg(test)]
#[expect(
clippy::disallowed_types,
reason = "Tests use sync std::io::BufReader to read a short-lived helper's stdout synchronously."
)]
mod tests {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::time::Duration;
use super::{process_exists, process_group_alive, process_running};
use crate::pre_exec::pre_exec_setpgid;
#[test]
fn process_running_returns_true_for_current_process() {
@ -199,7 +204,7 @@ mod tests {
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::pre_exec::pre_exec_setpgid(&mut child);
pre_exec_setpgid(&mut child);
let mut child = child.spawn().expect("group leader should spawn");
let pgid = child.id();

View file

@ -58,7 +58,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-znhdzrrr.js"></script>
<script type="module" src="/assets/entry-b79sap7r.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>