mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
5e09aa8176
28 changed files with 2673 additions and 2230 deletions
|
|
@ -40,7 +40,7 @@
|
|||
--color-amber: #F0A45B;
|
||||
--color-coral: #E86B6B;
|
||||
|
||||
/* ── Semantic tokens (dark defaults) ── */
|
||||
/* ── Semantic tokens ── */
|
||||
--color-page: #0F1729;
|
||||
--color-panel: #252C3D;
|
||||
--color-panel-alt: #1a2133;
|
||||
|
|
@ -53,39 +53,10 @@
|
|||
--color-line: rgba(255, 255, 255, 0.06);
|
||||
--color-line-strong: rgba(255, 255, 255, 0.12);
|
||||
--color-focus: rgba(103, 178, 215, 0.4);
|
||||
/* Text color that sits on --color-teal-500 primary fills.
|
||||
Dark mode: teal-500 is light (#67B2D7) so dark text reads best.
|
||||
Light mode: teal-500 is dark (#0e7490) so white reads best. */
|
||||
/* Text color that sits on --color-teal-500 primary fills. */
|
||||
--color-on-primary: #0F1729;
|
||||
}
|
||||
|
||||
/* ── Light theme overrides ── */
|
||||
|
||||
.light {
|
||||
/* Semantic tokens — page is softly tinted so panels/cards (white)
|
||||
read as the lighter surface, mirroring the dark-theme hierarchy. */
|
||||
--color-page: #eef2f7;
|
||||
--color-panel: #ffffff;
|
||||
--color-panel-alt: #e4eaf1;
|
||||
--color-overlay: rgba(15, 23, 41, 0.05);
|
||||
--color-overlay-strong: rgba(15, 23, 41, 0.10);
|
||||
--color-fg: #0f172a;
|
||||
--color-fg-2: #1e293b;
|
||||
--color-fg-3: #475569;
|
||||
--color-fg-muted: #64748b;
|
||||
--color-line: rgba(15, 23, 41, 0.10);
|
||||
--color-line-strong: rgba(15, 23, 41, 0.18);
|
||||
--color-focus: rgba(14, 116, 144, 0.5);
|
||||
--color-on-primary: #ffffff;
|
||||
|
||||
/* Accent — darkened for WCAG AA on white */
|
||||
--color-mint: #0d9488;
|
||||
--color-amber: #d97706;
|
||||
--color-coral: #dc2626;
|
||||
--color-teal-300: #0891b2;
|
||||
--color-teal-500: #0e7490;
|
||||
}
|
||||
|
||||
/* ── Chart variables ── */
|
||||
|
||||
:root {
|
||||
|
|
@ -93,11 +64,6 @@
|
|||
--chart-axis-label: rgba(75, 87, 104, 1);
|
||||
}
|
||||
|
||||
.light {
|
||||
--chart-gridline: rgba(0, 0, 0, 0.06);
|
||||
--chart-axis-label: rgba(71, 85, 105, 1);
|
||||
}
|
||||
|
||||
/* ── Background atmosphere ── */
|
||||
|
||||
.bg-atmosphere {
|
||||
|
|
@ -108,13 +74,6 @@
|
|||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.light .bg-atmosphere,
|
||||
.light.bg-atmosphere {
|
||||
background-image:
|
||||
radial-gradient(ellipse 120% 60% at 15% 5%, rgba(14, 116, 144, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 80% 50% at 85% 90%, rgba(13, 148, 136, 0.04) 0%, transparent 45%);
|
||||
}
|
||||
|
||||
.bg-atmosphere::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
|
|
@ -127,11 +86,6 @@
|
|||
background-size: 200px 200px;
|
||||
}
|
||||
|
||||
.light .bg-atmosphere::before,
|
||||
.light.bg-atmosphere::before {
|
||||
opacity: 0.03;
|
||||
}
|
||||
|
||||
/* ── @pierre/diffs brand overrides ── */
|
||||
|
||||
:root {
|
||||
|
|
@ -159,28 +113,16 @@
|
|||
}
|
||||
|
||||
/* ── Server-rendered SVG graph overrides ── */
|
||||
/* The server injects @media (prefers-color-scheme: dark) but the app uses
|
||||
.dark/.light classes, so we apply dark-mode colors via the class. */
|
||||
/* The server injects @media (prefers-color-scheme: dark) which won't reliably
|
||||
fire when the OS is in light mode, so we apply dark graph colors always. */
|
||||
|
||||
.dark .graph-svg text { fill: #c6d4e0; }
|
||||
.dark .graph-svg [stroke="#357f9e"] { stroke: #67B2D7; }
|
||||
.dark .graph-svg [stroke="#666666"] { stroke: #5a7a94; }
|
||||
.dark .graph-svg polygon[fill="#357f9e"] { fill: #67B2D7; }
|
||||
.dark .graph-svg polygon[fill="#666666"] { fill: #5a7a94; }
|
||||
.dark .graph-svg ellipse[fill="#ffffff"],
|
||||
.dark .graph-svg polygon[fill="#ffffff"] { fill: #1a2b3c; }
|
||||
.dark .graph-svg ellipse[stroke="#357f9e"],
|
||||
.dark .graph-svg polygon[stroke="#357f9e"] { stroke: #67B2D7; }
|
||||
.dark .graph-svg [fill="#1a1a1a"] { fill: #c6d4e0; }
|
||||
|
||||
.light {
|
||||
--diffs-bg-buffer-override: #ffffff;
|
||||
--diffs-bg-context-override: #f8fafc;
|
||||
--diffs-bg-hover-override: #f1f5f9;
|
||||
--diffs-bg-separator-override: #e2e8f0;
|
||||
--diffs-fg-number-override: #94a3b8;
|
||||
--diffs-addition-color-override: #0d9488;
|
||||
--diffs-fg-number-addition-override: rgba(13, 148, 136, 0.5);
|
||||
--diffs-deletion-color-override: #dc2626;
|
||||
--diffs-fg-number-deletion-override: rgba(220, 38, 38, 0.5);
|
||||
}
|
||||
.graph-svg text { fill: #c6d4e0; }
|
||||
.graph-svg [stroke="#357f9e"] { stroke: #67B2D7; }
|
||||
.graph-svg [stroke="#666666"] { stroke: #5a7a94; }
|
||||
.graph-svg polygon[fill="#357f9e"] { fill: #67B2D7; }
|
||||
.graph-svg polygon[fill="#666666"] { fill: #5a7a94; }
|
||||
.graph-svg ellipse[fill="#ffffff"],
|
||||
.graph-svg polygon[fill="#ffffff"] { fill: #1a2b3c; }
|
||||
.graph-svg ellipse[stroke="#357f9e"],
|
||||
.graph-svg polygon[stroke="#357f9e"] { stroke: #67B2D7; }
|
||||
.graph-svg [fill="#1a1a1a"] { fill: #c6d4e0; }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useState } from "react";
|
|||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import type { FileContents } from "@pierre/diffs";
|
||||
import { File } from "@pierre/diffs/react";
|
||||
import { useTheme } from "../lib/theme";
|
||||
|
||||
export function CollapsibleFile({
|
||||
file,
|
||||
|
|
@ -12,7 +11,6 @@ export function CollapsibleFile({
|
|||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const lines = file.contents.split("\n");
|
||||
const lineCount = lines.length;
|
||||
|
|
@ -38,7 +36,7 @@ export function CollapsibleFile({
|
|||
<div className="border-t border-line" />
|
||||
<File
|
||||
file={file}
|
||||
options={{ theme: theme === "dark" ? "pierre-dark" : "pierre-light", disableFileHeader: true }}
|
||||
options={{ theme: "pierre-dark", disableFileHeader: true }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { dotLanguage } from "./dot-grammar";
|
|||
|
||||
export async function registerDotLanguage(): Promise<void> {
|
||||
const highlighter = await getSharedHighlighter({
|
||||
themes: ["pierre-dark", "pierre-light"],
|
||||
themes: ["pierre-dark"],
|
||||
langs: [],
|
||||
});
|
||||
attachResolvedLanguages(
|
||||
|
|
|
|||
|
|
@ -12,17 +12,14 @@ import {
|
|||
BeakerIcon,
|
||||
ChartBarIcon,
|
||||
Cog6ToothIcon,
|
||||
MoonIcon,
|
||||
PlayIcon,
|
||||
RectangleStackIcon,
|
||||
SunIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link, Outlet, useLocation, useMatches, useRevalidator } from "react-router";
|
||||
import { getAuthMe } from "../api";
|
||||
import { ToastProvider } from "../components/toast";
|
||||
import { DemoModeProvider } from "../lib/demo-mode";
|
||||
import { useTheme } from "../lib/theme";
|
||||
|
||||
export async function loader() {
|
||||
return getAuthMe();
|
||||
|
|
@ -48,7 +45,6 @@ export default function AppShell({ loaderData }: any) {
|
|||
const { pathname } = useLocation();
|
||||
const matches = useMatches();
|
||||
const revalidator = useRevalidator();
|
||||
const { theme, toggle } = useTheme();
|
||||
const navigation = getVisibleNavigation(demoMode);
|
||||
const currentNav = navigation.find((item) => pathname.startsWith(item.href));
|
||||
const title = currentNav?.name ?? "";
|
||||
|
|
@ -59,8 +55,6 @@ export default function AppShell({ loaderData }: any) {
|
|||
const wide = matches.some((m) => (m.handle as { wide?: boolean } | undefined)?.wide);
|
||||
const maxWidth = wide ? "" : "max-w-5xl";
|
||||
|
||||
const ThemeIcon = theme === "dark" ? SunIcon : MoonIcon;
|
||||
|
||||
async function toggleDemoMode() {
|
||||
await fetch("/api/v1/demo/toggle", {
|
||||
method: "POST",
|
||||
|
|
@ -81,7 +75,7 @@ export default function AppShell({ loaderData }: any) {
|
|||
<div className="flex items-center">
|
||||
<div className="shrink-0">
|
||||
<Link to={demoMode ? "/start" : "/runs"}>
|
||||
<img alt="Fabro" src={theme === "dark" ? "/logotype.svg" : "/logotype-light.svg"} className="h-8 w-auto" />
|
||||
<img alt="Fabro" src="/logotype.svg" className="h-8 w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
|
|
@ -122,15 +116,6 @@ export default function AppShell({ loaderData }: any) {
|
|||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-overlay hover:text-fg"
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
<ThemeIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="relative flex max-w-xs items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
|
||||
<span className="absolute -inset-1.5" />
|
||||
|
|
@ -230,15 +215,6 @@ export default function AppShell({ loaderData }: any) {
|
|||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-overlay hover:text-fg"
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
<ThemeIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{provider !== "tailscale" && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import type { Theme } from "./theme";
|
||||
|
||||
interface GraphTheme {
|
||||
export interface GraphTheme {
|
||||
fontcolor: string;
|
||||
edgeColor: string;
|
||||
nodeFill: string;
|
||||
|
|
@ -24,7 +22,7 @@ interface GraphTheme {
|
|||
failedText: string;
|
||||
}
|
||||
|
||||
const dark: GraphTheme = {
|
||||
export const graphTheme: GraphTheme = {
|
||||
fontcolor: "#5a7a94",
|
||||
edgeColor: "#2a3f52",
|
||||
nodeFill: "#1a2b3c",
|
||||
|
|
@ -47,32 +45,3 @@ const dark: GraphTheme = {
|
|||
failedBorder: "#f87171",
|
||||
failedText: "#fca5a5",
|
||||
};
|
||||
|
||||
const light: GraphTheme = {
|
||||
fontcolor: "#475569",
|
||||
edgeColor: "#cbd5e1",
|
||||
nodeFill: "#f1f5f9",
|
||||
nodeText: "#1e293b",
|
||||
startFill: "#ccfbf1",
|
||||
startBorder: "#0d9488",
|
||||
startText: "#0d9488",
|
||||
gateFill: "#fef3c7",
|
||||
gateBorder: "#d97706",
|
||||
gateText: "#d97706",
|
||||
completedFill: "#d1fae5",
|
||||
completedBorder: "#059669",
|
||||
completedText: "#059669",
|
||||
runningFill: "#ccfbf1",
|
||||
runningBorder: "#0d9488",
|
||||
runningText: "#0d9488",
|
||||
runningPulseFill: "#99f6e4",
|
||||
runningPulseStroke: "#0d9488",
|
||||
failedFill: "#fef2f2",
|
||||
failedBorder: "#dc2626",
|
||||
failedText: "#dc2626",
|
||||
};
|
||||
|
||||
export function getGraphTheme(theme: Theme): GraphTheme {
|
||||
return theme === "dark" ? dark : light;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { buildThemeBootScript, resolveTheme } from "./theme-selection";
|
||||
|
||||
describe("resolveTheme", () => {
|
||||
test("defaults to dark when no saved theme exists", () => {
|
||||
expect(resolveTheme(null)).toBe("dark");
|
||||
expect(resolveTheme("system")).toBe("dark");
|
||||
});
|
||||
|
||||
test("uses the saved theme when it is valid", () => {
|
||||
expect(resolveTheme("light")).toBe("light");
|
||||
expect(resolveTheme("dark")).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildThemeBootScript", () => {
|
||||
test("defaults the boot script to dark without using system preference", () => {
|
||||
const script = buildThemeBootScript();
|
||||
|
||||
expect(script).toContain('localStorage.getItem("fabro-theme")');
|
||||
expect(script).toContain('t="dark"');
|
||||
expect(script).not.toContain("matchMedia");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
type Theme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "fabro-theme";
|
||||
|
||||
function resolveTheme(storedTheme: string | null): Theme {
|
||||
return storedTheme === "light" || storedTheme === "dark"
|
||||
? storedTheme
|
||||
: "dark";
|
||||
}
|
||||
|
||||
function buildThemeBootScript(storageKey = STORAGE_KEY) {
|
||||
return `(function(){try{var t=localStorage.getItem("${storageKey}");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()`;
|
||||
}
|
||||
|
||||
export { STORAGE_KEY, buildThemeBootScript, resolveTheme };
|
||||
export type { Theme };
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { STORAGE_KEY, resolveTheme } from "./theme-selection";
|
||||
import type { Theme } from "./theme-selection";
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return resolveTheme(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
function applyThemeClass(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(theme);
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
}>({ theme: "dark", toggle: () => {} });
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeClass(theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === "dark" ? "light" : "dark";
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext value={{ theme, toggle }}>
|
||||
{children}
|
||||
</ThemeContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export type { Theme };
|
||||
|
|
@ -1,21 +1,8 @@
|
|||
import { isRouteErrorResponse, Outlet } from "react-router";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import { buildThemeBootScript } from "./lib/theme-selection";
|
||||
import "./app.css";
|
||||
|
||||
const themeScript = buildThemeBootScript();
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ThemeBoot />
|
||||
<Outlet />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeBoot() {
|
||||
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: any) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
} from "react-router";
|
||||
import * as PierreDiffs from "@pierre/diffs/react";
|
||||
import { useToast } from "../components/toast";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import type {
|
||||
FileDiff as ApiFileDiff,
|
||||
PaginatedRunFileList,
|
||||
|
|
@ -318,7 +317,6 @@ function resolveRunStatus(matches: ReturnType<typeof useMatches>): string | unde
|
|||
}
|
||||
|
||||
export default function RunFiles({ loaderData }: any) {
|
||||
const theme = useTheme();
|
||||
const params = useParams();
|
||||
const navigation = useNavigation();
|
||||
const revalidator = useRevalidator();
|
||||
|
|
@ -382,9 +380,6 @@ export default function RunFiles({ loaderData }: any) {
|
|||
[diffStyleForced],
|
||||
);
|
||||
|
||||
const pierreTheme =
|
||||
theme.theme === "dark" ? "pierre-dark" : "pierre-light";
|
||||
|
||||
const refreshButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastDeepLinkToastRef = useRef<string | null>(null);
|
||||
|
|
@ -477,14 +472,14 @@ export default function RunFiles({ loaderData }: any) {
|
|||
newFile={file.new_file}
|
||||
options={{
|
||||
diffStyle,
|
||||
theme: pierreTheme,
|
||||
theme: "pierre-dark",
|
||||
expandUnchanged: isDeepLinkTarget ? true : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
[diffStyle, pierreTheme, hashFile],
|
||||
[diffStyle, hashFile],
|
||||
);
|
||||
|
||||
if (isInitialLoading) {
|
||||
|
|
@ -553,7 +548,7 @@ export default function RunFiles({ loaderData }: any) {
|
|||
patch={meta.patch}
|
||||
options={{
|
||||
diffStyle,
|
||||
theme: pierreTheme,
|
||||
theme: "pierre-dark",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
import { apiFetch, apiJsonOrNull } from "../api";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
|
|
@ -34,7 +33,7 @@ export async function loader({ request, params }: any) {
|
|||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
function buildDot(direction: Direction, gt: ReturnType<typeof getGraphTheme>) {
|
||||
function buildDot(direction: Direction) {
|
||||
return `digraph sync {
|
||||
graph [label="Sync"]
|
||||
rankdir=${direction}
|
||||
|
|
@ -44,27 +43,27 @@ function buildDot(direction: Direction, gt: ReturnType<typeof getGraphTheme>) {
|
|||
node [
|
||||
fontname="ui-monospace, monospace"
|
||||
fontsize=11
|
||||
fontcolor="${gt.nodeText}"
|
||||
color="${gt.edgeColor}"
|
||||
fillcolor="${gt.nodeFill}"
|
||||
fontcolor="${graphTheme.nodeText}"
|
||||
color="${graphTheme.edgeColor}"
|
||||
fillcolor="${graphTheme.nodeFill}"
|
||||
style=filled
|
||||
penwidth=1.2
|
||||
]
|
||||
edge [
|
||||
fontname="ui-monospace, monospace"
|
||||
fontsize=9
|
||||
fontcolor="${gt.fontcolor}"
|
||||
color="${gt.edgeColor}"
|
||||
fontcolor="${graphTheme.fontcolor}"
|
||||
color="${graphTheme.edgeColor}"
|
||||
arrowsize=0.7
|
||||
penwidth=1.2
|
||||
]
|
||||
|
||||
start [shape=Mdiamond, label="Start", fillcolor="${gt.startFill}", color="${gt.startBorder}", fontcolor="${gt.startText}"]
|
||||
exit [shape=Msquare, label="Exit", fillcolor="${gt.startFill}", color="${gt.startBorder}", fontcolor="${gt.startText}"]
|
||||
start [shape=Mdiamond, label="Start", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
|
||||
exit [shape=Msquare, label="Exit", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
|
||||
|
||||
detect [label="Detect\\nDrift"]
|
||||
propose [label="Propose\\nChanges"]
|
||||
review [shape=hexagon, label="Review\\nChanges", fillcolor="${gt.gateFill}", color="${gt.gateBorder}", fontcolor="${gt.gateText}"]
|
||||
review [shape=hexagon, label="Review\\nChanges", fillcolor="${graphTheme.gateFill}", color="${graphTheme.gateBorder}", fontcolor="${graphTheme.gateText}"]
|
||||
apply [label="Apply\\nChanges"]
|
||||
|
||||
start -> detect
|
||||
|
|
@ -89,90 +88,6 @@ function stripGraphTitle(svg: SVGSVGElement) {
|
|||
title.remove();
|
||||
}
|
||||
|
||||
function annotateRunningNodes(svg: SVGSVGElement, gt: ReturnType<typeof getGraphTheme>, stageList: Stage[]) {
|
||||
const runningDotIds = new Set(
|
||||
stageList.filter((s) => s.status === "running").map((s) => s.dotId),
|
||||
);
|
||||
const completedDotIds = new Set(
|
||||
stageList.filter((s) => s.status === "completed").map((s) => s.dotId),
|
||||
);
|
||||
|
||||
const nodeGroups = svg.querySelectorAll(".node");
|
||||
for (const group of nodeGroups) {
|
||||
const titleEl = group.querySelector("title");
|
||||
if (!titleEl) continue;
|
||||
const nodeId = titleEl.textContent?.trim();
|
||||
if (!nodeId) continue;
|
||||
|
||||
if (runningDotIds.has(nodeId)) {
|
||||
// Animate with native SVG <animate> elements (cross-browser reliable)
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const shapes = group.querySelectorAll("ellipse, polygon, path");
|
||||
for (const shape of shapes) {
|
||||
shape.setAttribute("fill", gt.runningFill);
|
||||
shape.setAttribute("stroke", gt.runningBorder);
|
||||
shape.setAttribute("stroke-width", "2");
|
||||
|
||||
const animFill = document.createElementNS(ns, "animate");
|
||||
animFill.setAttribute("attributeName", "fill");
|
||||
animFill.setAttribute("values", `${gt.runningFill};${gt.runningPulseFill};${gt.runningFill}`);
|
||||
animFill.setAttribute("dur", "1.5s");
|
||||
animFill.setAttribute("repeatCount", "indefinite");
|
||||
shape.appendChild(animFill);
|
||||
|
||||
const animStroke = document.createElementNS(ns, "animate");
|
||||
animStroke.setAttribute("attributeName", "stroke");
|
||||
animStroke.setAttribute("values", `${gt.runningBorder};${gt.runningPulseStroke};${gt.runningBorder}`);
|
||||
animStroke.setAttribute("dur", "1.5s");
|
||||
animStroke.setAttribute("repeatCount", "indefinite");
|
||||
shape.appendChild(animStroke);
|
||||
|
||||
const animWidth = document.createElementNS(ns, "animate");
|
||||
animWidth.setAttribute("attributeName", "stroke-width");
|
||||
animWidth.setAttribute("values", "2;3.5;2");
|
||||
animWidth.setAttribute("dur", "1.5s");
|
||||
animWidth.setAttribute("repeatCount", "indefinite");
|
||||
shape.appendChild(animWidth);
|
||||
}
|
||||
const texts = group.querySelectorAll("text");
|
||||
for (const text of texts) {
|
||||
text.setAttribute("fill", gt.runningText);
|
||||
}
|
||||
} else if (completedDotIds.has(nodeId)) {
|
||||
// Tint completed nodes green
|
||||
const shapes = group.querySelectorAll("ellipse, polygon, path");
|
||||
for (const shape of shapes) {
|
||||
shape.setAttribute("fill", gt.completedFill);
|
||||
shape.setAttribute("stroke", gt.completedBorder);
|
||||
}
|
||||
const texts = group.querySelectorAll("text");
|
||||
for (const text of texts) {
|
||||
text.setAttribute("fill", gt.completedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also color edges leading into completed nodes
|
||||
const edgeGroups = svg.querySelectorAll(".edge");
|
||||
for (const group of edgeGroups) {
|
||||
const titleEl = group.querySelector("title");
|
||||
if (!titleEl) continue;
|
||||
const edgeLabel = titleEl.textContent?.trim() ?? "";
|
||||
const [, target] = edgeLabel.split("->");
|
||||
if (!target) continue;
|
||||
const targetId = target.trim();
|
||||
|
||||
if (completedDotIds.has(targetId)) {
|
||||
const paths = group.querySelectorAll("path, polygon");
|
||||
for (const p of paths) {
|
||||
p.setAttribute("stroke", gt.completedBorder);
|
||||
if (p.tagName === "polygon") p.setAttribute("fill", gt.completedBorder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default function RunGraph({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, graphSvg } = loaderData;
|
||||
|
|
@ -185,8 +100,6 @@ export default function RunGraph({ loaderData }: any) {
|
|||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
|
||||
const { theme } = useTheme();
|
||||
const graphTheme = getGraphTheme(theme);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -214,7 +127,7 @@ export default function RunGraph({ loaderData }: any) {
|
|||
const { instance } = await import("@viz-js/viz");
|
||||
const viz = await instance();
|
||||
if (cancelled) return;
|
||||
svg = viz.renderSVGElement(buildDot(direction, graphTheme));
|
||||
svg = viz.renderSVGElement(buildDot(direction));
|
||||
}
|
||||
|
||||
stripGraphTitle(svg);
|
||||
|
|
@ -231,7 +144,7 @@ export default function RunGraph({ loaderData }: any) {
|
|||
setPan({ x: 0, y: 0 });
|
||||
render();
|
||||
return () => { cancelled = true; };
|
||||
}, [direction, graphTheme, graphSvg, id]);
|
||||
}, [direction, graphSvg, id]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { useNavigate, useParams } from "react-router";
|
|||
import { apiFetch, apiJsonOrNull } from "../api";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import {
|
||||
|
|
@ -47,7 +46,6 @@ export default function RunOverview({ loaderData }: any) {
|
|||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
|
|
@ -69,7 +67,7 @@ export default function RunOverview({ loaderData }: any) {
|
|||
if (!svg) return;
|
||||
svgRef.current = svg;
|
||||
|
||||
const gt = getGraphTheme(theme);
|
||||
const gt = graphTheme;
|
||||
const runningDotIds = new Set<string>(
|
||||
stages.filter((s: Stage) => s.status === "running").map((s: Stage) => s.dotId ?? s.id),
|
||||
);
|
||||
|
|
@ -157,7 +155,7 @@ export default function RunOverview({ loaderData }: any) {
|
|||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [stages, graphSvg, theme, direction, id]);
|
||||
}, [stages, graphSvg, direction, id]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
function buildDot(direction: Direction, theme: ReturnType<typeof getGraphTheme>) {
|
||||
function buildDot(direction: Direction) {
|
||||
return `digraph fix_build {
|
||||
graph [
|
||||
label="Fix Build"
|
||||
|
|
@ -17,29 +16,29 @@ function buildDot(direction: Direction, theme: ReturnType<typeof getGraphTheme>)
|
|||
node [
|
||||
fontname="ui-monospace, monospace"
|
||||
fontsize=11
|
||||
fontcolor="${theme.nodeText}"
|
||||
color="${theme.edgeColor}"
|
||||
fillcolor="${theme.nodeFill}"
|
||||
fontcolor="${graphTheme.nodeText}"
|
||||
color="${graphTheme.edgeColor}"
|
||||
fillcolor="${graphTheme.nodeFill}"
|
||||
style=filled
|
||||
penwidth=1.2
|
||||
]
|
||||
edge [
|
||||
fontname="ui-monospace, monospace"
|
||||
fontsize=9
|
||||
fontcolor="${theme.fontcolor}"
|
||||
color="${theme.edgeColor}"
|
||||
fontcolor="${graphTheme.fontcolor}"
|
||||
color="${graphTheme.edgeColor}"
|
||||
arrowsize=0.7
|
||||
penwidth=1.2
|
||||
]
|
||||
|
||||
start [shape=Mdiamond, label="Start", fillcolor="${theme.startFill}", color="${theme.startBorder}", fontcolor="${theme.startText}"]
|
||||
exit [shape=Msquare, label="Exit", fillcolor="${theme.startFill}", color="${theme.startBorder}", fontcolor="${theme.startText}"]
|
||||
start [shape=Mdiamond, label="Start", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
|
||||
exit [shape=Msquare, label="Exit", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
|
||||
|
||||
analyze [label="Analyze\\nBuild Errors"]
|
||||
diagnose [label="Diagnose\\nRoot Cause"]
|
||||
fix [label="Apply\\nFix"]
|
||||
validate [label="Validate\\nBuild"]
|
||||
approve [shape=hexagon, label="Review\\nChanges", fillcolor="${theme.gateFill}", color="${theme.gateBorder}", fontcolor="${theme.gateText}"]
|
||||
approve [shape=hexagon, label="Review\\nChanges", fillcolor="${graphTheme.gateFill}", color="${graphTheme.gateBorder}", fontcolor="${graphTheme.gateText}"]
|
||||
|
||||
start -> analyze
|
||||
analyze -> diagnose
|
||||
|
|
@ -47,7 +46,7 @@ function buildDot(direction: Direction, theme: ReturnType<typeof getGraphTheme>)
|
|||
fix -> validate
|
||||
validate -> exit [label="Pass"]
|
||||
validate -> diagnose [label="Fail", style=dashed, color="#f87171"]
|
||||
validate -> approve [label="Needs review", color="${theme.gateBorder}"]
|
||||
validate -> approve [label="Needs review", color="${graphTheme.gateBorder}"]
|
||||
approve -> exit [label="Accept"]
|
||||
approve -> fix [label="Revise", style=dashed]
|
||||
}`;
|
||||
|
|
@ -78,8 +77,6 @@ export default function WorkflowDiagram() {
|
|||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const zoom = ZOOM_STEPS[zoomIndex];
|
||||
const { theme } = useTheme();
|
||||
const graphTheme = getGraphTheme(theme);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -90,7 +87,7 @@ export default function WorkflowDiagram() {
|
|||
if (cancelled) return;
|
||||
|
||||
try {
|
||||
const svg = viz.renderSVGElement(buildDot(direction, graphTheme));
|
||||
const svg = viz.renderSVGElement(buildDot(direction));
|
||||
stripGraphTitle(svg);
|
||||
|
||||
svgRef.current = svg;
|
||||
|
|
@ -105,7 +102,7 @@ export default function WorkflowDiagram() {
|
|||
setPan({ x: 0, y: 0 });
|
||||
render();
|
||||
return () => { cancelled = true; };
|
||||
}, [direction, graphTheme]);
|
||||
}, [direction]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-atmosphere">
|
||||
<html lang="en" class="h-full bg-atmosphere scheme-only-dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
|
@ -13,9 +13,6 @@
|
|||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
/>
|
||||
<script>
|
||||
(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()
|
||||
</script>
|
||||
{{styles}}
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased">
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 7.8 KiB |
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
1
lib/crates/fabro-spa/assets/assets/chunk-7fpy4pmb.js
generated
Normal file
1
lib/crates/fabro-spa/assets/assets/chunk-7fpy4pmb.js
generated
Normal file
File diff suppressed because one or more lines are too long
1
lib/crates/fabro-spa/assets/assets/chunk-nghb2mxb.js
generated
Normal file
1
lib/crates/fabro-spa/assets/assets/chunk-nghb2mxb.js
generated
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2402
lib/crates/fabro-spa/assets/assets/entry-b79sap7r.js
generated
Normal file
2402
lib/crates/fabro-spa/assets/assets/entry-b79sap7r.js
generated
Normal file
File diff suppressed because one or more lines are too long
1805
lib/crates/fabro-spa/assets/assets/entry-qp59a2b0.js
generated
1805
lib/crates/fabro-spa/assets/assets/entry-qp59a2b0.js
generated
File diff suppressed because one or more lines are too long
13
lib/crates/fabro-spa/assets/index.html
generated
13
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-atmosphere">
|
||||
<html lang="en" class="h-full bg-atmosphere scheme-only-dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
|
@ -13,11 +13,8 @@
|
|||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
/>
|
||||
<script>
|
||||
(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()
|
||||
</script>
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
<link rel="stylesheet" href="/assets/entry-vh02mb6v.css" />
|
||||
<link rel="stylesheet" href="/assets/entry-4r6r51av.css" />
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
|
|
@ -61,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-ab7g8fgj.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>
|
||||
|
|
@ -75,8 +72,8 @@
|
|||
<script type="module" src="/assets/chunk-6ma2q84r.js"></script>
|
||||
<script type="module" src="/assets/chunk-0tq24xt3.js"></script>
|
||||
<script type="module" src="/assets/chunk-z512569f.js"></script>
|
||||
<script type="module" src="/assets/chunk-q2qv5qr4.js"></script>
|
||||
<script type="module" src="/assets/chunk-tqzz87j8.js"></script>
|
||||
<script type="module" src="/assets/chunk-7fpy4pmb.js"></script>
|
||||
<script type="module" src="/assets/chunk-nghb2mxb.js"></script>
|
||||
<script type="module" src="/assets/chunk-w4txx8sc.js"></script>
|
||||
<script type="module" src="/assets/chunk-eaexpy25.js"></script>
|
||||
<script type="module" src="/assets/chunk-zb6gezq1.js"></script>
|
||||
|
|
|
|||
4
lib/crates/fabro-spa/assets/logotype-light.svg
generated
4
lib/crates/fabro-spa/assets/logotype-light.svg
generated
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 7.8 KiB |
Loading…
Add table
Reference in a new issue