mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
Introduce approved effect hooks and migrate direct useEffect calls (#425)
## Summary
Implements the React Effects Policy by creating the approved hook
surface in `hooks/effects.ts` and migrating a broad set of direct
`useEffect` calls across the codebase to either purpose-named hooks or
non-effect patterns.
### Plan Summary
- Add `hooks/effects.ts` exporting `useMountEffect`, `useInterval`,
`useTimeout`, `useDebouncedValue`, `useWindowEvent`, `useDocumentEvent`,
`useDocumentTitle`, `useMediaQuery`, `useLocationHash`, and
`useResizeObserver`
- Extract large imperative effects into purpose-named hooks:
`useTerminalSession`, `useFloatingTooltipMeasurements`,
`useAnnotatedRunGraphSvg`, `useInstallEffects`, and others
- Move install session fetch from a component effect into a SWR query
(`install-query.ts`)
- Replace `useEffect` + `useState` state-derivation patterns with
render-time computation or ref callbacks
- Replace `AskFabroLayoutProvider`/`useAskFabroLayout` context with a
prop callback
## What changed and why
**`hooks/effects.ts`** — the new approved primitive surface. All
internal `useEffect` calls here are intentional; the hooks expose the
*external system* they manage rather than leaking `useEffect` to
component code. `useMediaQuery` and `useLocationHash` use
`useSyncExternalStore` instead of effect + state.
**`useTerminalSession`** — the largest extraction. The 130-line
xterm/WebSocket/ResizeObserver setup block moves from
`terminal-view.tsx` into its own hook, which now owns the `terminalRef`,
`fitRef`, and `socketRef` that previously cluttered the component.
`TerminalConnectionError` and `ConnectionStatus` types are exported from
the hook.
**`useFloatingTooltipMeasurements`** — extracts the `useLayoutEffect` +
ResizeObserver + window resize listener out of `FloatingTooltip`. The
`FloatingTooltipSize` type moves with it so consumers don't need to
import from the component.
**`useInstallSessionQuery` + `useInstallEffects`** — the install session
fetch moves from a component effect to SWR (`install-query.ts`). The
three remaining install effects (token URL scrubbing, GitHub error URL
scrubbing, health-poll restart) move into
`hooks/use-install-effects.ts`. The root-redirect effect is replaced
with a render-time `<Navigate>` gate. The `SessionState` discriminant
now carries `token` so stale query results can be discarded without an
effect chain.
**`SelectionCheckbox`** — `useEffect` setting `input.indeterminate` is
replaced with a ref callback, which runs synchronously after the node is
attached and avoids a stale-frame flash.
**`event-debug.tsx`** — the manual `window.addEventListener("keydown",
...)` pattern is replaced with `useWindowEvent`, removing the
`react-doctor-disable` suppression comments.
**`run-waterfall.tsx`** — the local `useTickingNow` is deleted;
`RunWaterfall` now calls the shared `useTickingNow` from `lib/time` with
the new `active` parameter signature.
**`toast.test.tsx`** — `useEffect(() => onReady?.(api), ...)` in the
test helper is replaced with a direct call during render, which is valid
because `onReady` has no side effects that React cares about.
**`AskFabroSidebar`** — `setIsResizing` from the layout context is
replaced with an `onResizeActiveChange` prop, removing the
`useAskFabroLayout` call and the hidden context coupling from the
sidebar.
### Fabro Details
<details>
<summary>Ran 3 stages in 114m 5s for $95.71</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| work | 103m 3s | $80.42 | 0 |
| audit | 10m 19s | $15.29 | 0 |
| **Total** | **114m 5s** | **$95.71** | **0** |
</details>
<details>
<summary>Ran <code>Goal.fabro</code> (4 nodes and 5 edges)</summary>
```dot
digraph Goal {
graph [
goal="Complete the user-provided goal",
rankdir=LR,
max_node_visits=30
]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
work [
label="Work",
thread_id="goal",
fidelity="full",
max_visits=12,
model="gpt-55",
reasoning_effort="xhigh",
prompt="@prompts/continue.md"
]
audit [
label="Completion Audit",
thread_id="goal",
fidelity="full",
goal_gate=true,
retry_target="work",
output_schema="routing",
output_retries=2,
max_visits=12,
model="gpt-55",
reasoning_effort="xhigh",
prompt="@prompts/audit.md"
]
start -> work -> audit
audit -> exit [label="Done", condition="outcome=succeeded"]
audit -> work [label="Continue", condition="outcome=failed || preferred_label=Continue"]
audit -> work [label="No clear verdict"]
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
parent
c20c6b5361
commit
b196a97ac4
63 changed files with 2023 additions and 1395 deletions
|
|
@ -8,7 +8,6 @@ import { XMarkIcon } from "@heroicons/react/24/outline";
|
|||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { createAskFabroAdapter } from "../../lib/ask-fabro-runtime";
|
||||
import { useAskFabroLayout } from "../../lib/ask-fabro-layout";
|
||||
import SidebarComposer from "./sidebar-composer";
|
||||
import SidebarWelcome from "./sidebar-welcome";
|
||||
import ToolCallSummary from "./tool-call-summary";
|
||||
|
|
@ -44,6 +43,7 @@ export default function AskFabroSidebar({
|
|||
defaultModel,
|
||||
width,
|
||||
onWidthChange,
|
||||
onResizeActiveChange,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -51,6 +51,7 @@ export default function AskFabroSidebar({
|
|||
defaultModel?: string | null;
|
||||
width: number;
|
||||
onWidthChange: (width: number) => void;
|
||||
onResizeActiveChange: (active: boolean) => void;
|
||||
}) {
|
||||
const adapter = useMemo(
|
||||
() => createAskFabroAdapter({ runId, defaultModel }),
|
||||
|
|
@ -58,7 +59,6 @@ export default function AskFabroSidebar({
|
|||
);
|
||||
const runtime = useLocalRuntime(adapter);
|
||||
|
||||
const { setIsResizing } = useAskFabroLayout();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// Pointer X and width captured at drag start, so each move resolves to an
|
||||
// absolute width rather than accumulating rounding error.
|
||||
|
|
@ -69,7 +69,7 @@ export default function AskFabroSidebar({
|
|||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragOrigin.current = { x: event.clientX, width };
|
||||
setIsDragging(true);
|
||||
setIsResizing(true);
|
||||
onResizeActiveChange(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
|
|
@ -88,7 +88,7 @@ export default function AskFabroSidebar({
|
|||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
dragOrigin.current = null;
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
onResizeActiveChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
type DebugCategory,
|
||||
} from "./event-debug-helpers";
|
||||
import { FloatingTooltip } from "./floating-tooltip";
|
||||
import { useWindowEvent } from "../hooks/effects";
|
||||
|
||||
export function DebugEventRow({
|
||||
event,
|
||||
|
|
@ -77,16 +78,14 @@ export function DetailsPanel({
|
|||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// react-doctor-disable-next-line react-doctor/prefer-use-effect-event -- React's useEffectEvent is not in the installed React type surface yet.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
useWindowEvent(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
// react-doctor-disable-next-line react-doctor/prefer-use-effect-event -- React's useEffectEvent is not in the installed React type surface yet.
|
||||
}, [isOpen, onClose]);
|
||||
},
|
||||
undefined,
|
||||
isOpen,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
useFloatingTooltipMeasurements,
|
||||
type FloatingTooltipSize,
|
||||
} from "../hooks/use-floating-tooltip-measurements";
|
||||
|
||||
type FloatingTooltipPlacement = "top" | "bottom";
|
||||
type FloatingTooltipSize = { height: number; width: number };
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const OFFSET = 8;
|
||||
const DEFAULT_CLASS_NAME =
|
||||
|
|
@ -79,10 +78,6 @@ function floatingStyle(
|
|||
};
|
||||
}
|
||||
|
||||
function viewportSize(): FloatingTooltipSize {
|
||||
return { height: window.innerHeight, width: window.innerWidth };
|
||||
}
|
||||
|
||||
export function FloatingTooltip({
|
||||
rect,
|
||||
placement,
|
||||
|
|
@ -94,44 +89,7 @@ export function FloatingTooltip({
|
|||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ height: 0, width: 0 });
|
||||
const [viewport, setViewport] = useState<FloatingTooltipSize>(() =>
|
||||
typeof window === "undefined" ? { height: 0, width: 0 } : viewportSize(),
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateSize = () => {
|
||||
const next = node.getBoundingClientRect();
|
||||
setSize((prev) =>
|
||||
prev.height === next.height && prev.width === next.width
|
||||
? prev
|
||||
: { height: next.height, width: next.width },
|
||||
);
|
||||
};
|
||||
const updateViewport = () => {
|
||||
const next = viewportSize();
|
||||
setViewport((prev) =>
|
||||
prev.height === next.height && prev.width === next.width ? prev : next,
|
||||
);
|
||||
};
|
||||
|
||||
updateSize();
|
||||
updateViewport();
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(updateSize);
|
||||
resizeObserver?.observe(node);
|
||||
window.addEventListener("resize", updateViewport);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", updateViewport);
|
||||
};
|
||||
}, []);
|
||||
const { ref, size, viewport } = useFloatingTooltipMeasurements();
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { StageState, type RunStage } from "@qltysh/fabro-api-client";
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
stageStatusTone,
|
||||
} from "../lib/stage-sidebar";
|
||||
import { deriveRunPhases, type RunPhase } from "../lib/run-phases";
|
||||
import { useTickingNow } from "../lib/time";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
interface WaterfallProps {
|
||||
|
|
@ -35,15 +36,6 @@ interface Row {
|
|||
|
||||
const MIN_BAR_WIDTH_PCT = 0.4;
|
||||
|
||||
function useTickingNow(intervalMs: number): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
function stageBarClass(status: StageState): string {
|
||||
switch (status) {
|
||||
case StageState.RUNNING:
|
||||
|
|
@ -194,7 +186,7 @@ export function RunWaterfall({
|
|||
createdAtIso,
|
||||
completedAtIso,
|
||||
}: WaterfallProps) {
|
||||
const nowMs = useTickingNow(1000);
|
||||
const nowMs = useTickingNow(true, 1000);
|
||||
const rows = useMemo(
|
||||
() => buildRows({ runId, events, stages, createdAtIso, nowMs }),
|
||||
[runId, events, stages, createdAtIso, nowMs],
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ export function loadStoredRunsWorkspaceSearchParams(
|
|||
// `/runs`), fall back to stored preferences so the first render already
|
||||
// reflects the user's view/archived/etc. choice instead of route defaults.
|
||||
// Without this, users whose only runs are archived briefly see the empty
|
||||
// Quick Start landing before a post-commit effect restores `archived=1`.
|
||||
// Quick Start landing before a post-commit URL repair restores `archived=1`.
|
||||
export function resolveRunsWorkspaceSearchParams(
|
||||
urlSearchParams: URLSearchParams,
|
||||
): URLSearchParams {
|
||||
|
|
@ -355,7 +355,7 @@ export function persistRunsWorkspacePreferences(
|
|||
}
|
||||
|
||||
const CHILD_RUNS_LIST_PREFERENCES_VERSION = 1;
|
||||
const CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY = "fabro:run-children-preferences:v1";
|
||||
export const CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY = "fabro:run-children-preferences:v1";
|
||||
const CHILD_RUNS_LIST_PARAM_KEYS = [
|
||||
"search",
|
||||
"created",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function SelectionCheckbox({
|
||||
checked,
|
||||
indeterminate = false,
|
||||
|
|
@ -13,13 +11,11 @@ export function SelectionCheckbox({
|
|||
onChange: () => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
ref={(input) => {
|
||||
if (input) input.indeterminate = indeterminate;
|
||||
}}
|
||||
type="checkbox"
|
||||
aria-label={ariaLabel}
|
||||
checked={checked}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { Terminal as XtermTerminal } from "@xterm/xterm";
|
||||
import type { FitAddon as XtermFitAddon } from "@xterm/addon-fit";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
|
|
@ -20,52 +17,19 @@ import { apiData, humanInTheLoopApi } from "../lib/api-client";
|
|||
import { useRunState } from "../lib/queries";
|
||||
import {
|
||||
buildFullScreenTerminalUrl,
|
||||
buildTerminalWebSocketUrl,
|
||||
parseTerminalServerMessage,
|
||||
sandboxStatusDetail,
|
||||
terminalAccessCommandLabel,
|
||||
} from "./terminal-view-helpers";
|
||||
import {
|
||||
TERMINAL_BACKGROUND,
|
||||
useTerminalSession,
|
||||
type ConnectionStatus,
|
||||
type TerminalConnectionError,
|
||||
} from "../hooks/use-terminal-session";
|
||||
|
||||
const ICON_BUTTON_CLASS =
|
||||
"inline-flex size-9 items-center justify-center rounded-lg text-fg-2 outline-1 -outline-offset-1 outline-white/10 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
|
||||
|
||||
type ConnectionStatus = "connecting" | "ready" | "closed" | "error";
|
||||
|
||||
const TERMINAL_BACKGROUND = "#05080F";
|
||||
|
||||
// Pin the cell to a whole-pixel height so xterm's fit math stays exact.
|
||||
// fontSize × lineHeight = 13 × (19/13) = 19px → no sub-pixel rounding,
|
||||
// no bottom-row clipping.
|
||||
const TERMINAL_FONT_SIZE = 13;
|
||||
const TERMINAL_CELL_HEIGHT_PX = 19;
|
||||
const TERMINAL_LINE_HEIGHT = TERMINAL_CELL_HEIGHT_PX / TERMINAL_FONT_SIZE;
|
||||
|
||||
const TERMINAL_THEME = {
|
||||
background: TERMINAL_BACKGROUND,
|
||||
foreground: "#E6EDF3",
|
||||
cursor: "#7AC4E5",
|
||||
cursorAccent: "#05080F",
|
||||
selectionBackground: "#1F4F73",
|
||||
|
||||
black: "#05080F",
|
||||
red: "#FF6B6B",
|
||||
green: "#5EE6A8",
|
||||
yellow: "#FFC857",
|
||||
blue: "#82AAFF",
|
||||
magenta: "#C792EA",
|
||||
cyan: "#7AC4E5",
|
||||
white: "#D5DCE3",
|
||||
|
||||
brightBlack: "#4B5563",
|
||||
brightRed: "#FF8B8B",
|
||||
brightGreen: "#85F5C2",
|
||||
brightYellow: "#FFD98A",
|
||||
brightBlue: "#A4C4FF",
|
||||
brightMagenta: "#E0B6FF",
|
||||
brightCyan: "#A8DFF5",
|
||||
brightWhite: "#FFFFFF",
|
||||
};
|
||||
|
||||
function terminalAccessCommandCopiedMessage(provider: string | null): string {
|
||||
return provider === "docker" ? "Docker exec command copied." : "SSH command copied.";
|
||||
}
|
||||
|
|
@ -76,15 +40,6 @@ function terminalAccessCommandErrorMessage(provider: string | null): string {
|
|||
: "Could not copy SSH command.";
|
||||
}
|
||||
|
||||
function sendResize(socket: WebSocket | null, terminal: XtermTerminal | null) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !terminal) return;
|
||||
socket.send(JSON.stringify({
|
||||
type: "resize",
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
}));
|
||||
}
|
||||
|
||||
function statusDotClasses(status: ConnectionStatus): string {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
|
|
@ -157,12 +112,16 @@ export default function TerminalView({
|
|||
const accessCommandLabel = terminalAccessCommandLabel(provider);
|
||||
const [connectionKey, reconnectTerminal] = useReducer((key: number) => key + 1, 0);
|
||||
const [status, setStatus] = useState<ConnectionStatus>("connecting");
|
||||
const [error, setError] = useState<{ message: string; recoverable: boolean } | null>(null);
|
||||
const [error, setError] = useState<TerminalConnectionError | null>(null);
|
||||
const terminalEl = useRef<HTMLDivElement | null>(null);
|
||||
const terminalRef = useRef<XtermTerminal | null>(null);
|
||||
const fitRef = useRef<XtermFitAddon | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const headingId = `run-terminal-${runId}`;
|
||||
useTerminalSession({
|
||||
connectionKey,
|
||||
runId,
|
||||
setError,
|
||||
setStatus,
|
||||
terminalEl,
|
||||
});
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
setError(null);
|
||||
|
|
@ -188,132 +147,6 @@ export default function TerminalView({
|
|||
}
|
||||
}, [accessCommandLabel, runId, provider, push]);
|
||||
|
||||
// react-doctor-disable-next-line react-doctor/effect-needs-cleanup -- listeners, socket, xterm, and ResizeObserver are disposed in the returned cleanup.
|
||||
useEffect(() => {
|
||||
if (!terminalEl.current) return undefined;
|
||||
|
||||
let disposed = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
const textEncoder = new TextEncoder();
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
async function connect() {
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
]);
|
||||
if (disposed || !terminalEl.current) return;
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontFamily: "\"JetBrains Mono\", ui-monospace, monospace",
|
||||
fontSize: TERMINAL_FONT_SIZE,
|
||||
lineHeight: TERMINAL_LINE_HEIGHT,
|
||||
scrollback: 5000,
|
||||
theme: TERMINAL_THEME,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(terminalEl.current);
|
||||
fitAddon.fit();
|
||||
terminal.focus();
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fitAddon;
|
||||
|
||||
const socket = new WebSocket(buildTerminalWebSocketUrl(window.location, runId));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
disposables.push(terminal.onData((data) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(textEncoder.encode(data));
|
||||
}
|
||||
}));
|
||||
|
||||
const handleOpen = () => {
|
||||
sendResize(socket, terminal);
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (typeof event.data === "string") {
|
||||
const message = parseTerminalServerMessage(event.data);
|
||||
if (!message) return;
|
||||
if (message.type === "ready") {
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
if (message.type === "closed") {
|
||||
setStatus("closed");
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setError({
|
||||
message: message.message ?? "Terminal session failed.",
|
||||
recoverable: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bytes = event.data instanceof ArrayBuffer
|
||||
? new Uint8Array(event.data)
|
||||
: event.data;
|
||||
terminal.write(bytes);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setStatus((current) => current === "error" ? current : "closed");
|
||||
};
|
||||
const handleError = () => {
|
||||
setStatus("error");
|
||||
setError({
|
||||
message: "Terminal WebSocket connection failed.",
|
||||
recoverable: true,
|
||||
});
|
||||
};
|
||||
socket.addEventListener("open", handleOpen);
|
||||
socket.addEventListener("message", handleMessage);
|
||||
socket.addEventListener("close", handleClose);
|
||||
socket.addEventListener("error", handleError);
|
||||
disposables.push({
|
||||
dispose: () => {
|
||||
socket.removeEventListener("open", handleOpen);
|
||||
socket.removeEventListener("message", handleMessage);
|
||||
socket.removeEventListener("close", handleClose);
|
||||
socket.removeEventListener("error", handleError);
|
||||
},
|
||||
});
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
sendResize(socket, terminal);
|
||||
});
|
||||
resizeObserver.observe(terminalEl.current);
|
||||
|
||||
if (typeof document !== "undefined" && document.fonts?.ready) {
|
||||
void document.fonts.ready.then(() => {
|
||||
if (disposed) return;
|
||||
fitAddon.fit();
|
||||
sendResize(socket, terminal);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver?.disconnect();
|
||||
for (const disposable of disposables) disposable.dispose();
|
||||
socketRef.current?.send(JSON.stringify({ type: "close" }));
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [connectionKey, runId]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { useEffect } from "react";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
import { toast as sonnerToast, useSonner } from "sonner";
|
||||
|
||||
|
|
@ -18,10 +17,7 @@ function CaptureToastApi({
|
|||
onReady?: (api: ReturnType<typeof useToast>) => void;
|
||||
}) {
|
||||
const api = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.(api);
|
||||
}, [api, onReady]);
|
||||
onReady?.(api);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
206
apps/fabro-web/app/hooks/effects.ts
Normal file
206
apps/fabro-web/app/hooks/effects.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type EffectCallback,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Synchronizes React with a resource that is created for the mounted lifetime
|
||||
* only. The returned cleanup is run on unmount, including Strict Mode remounts.
|
||||
*/
|
||||
export function useMountEffect(setup: EffectCallback): void {
|
||||
useEffect(setup, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React with the browser timer queue. The interval is started
|
||||
* while `active` is true and is always cleared before the hook resubscribes or
|
||||
* unmounts.
|
||||
*/
|
||||
export function useInterval(
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
active = true,
|
||||
): void {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
const id = setInterval(() => callbackRef.current(), delayMs);
|
||||
return () => clearInterval(id);
|
||||
}, [active, delayMs]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React with the browser timer queue. The timeout is scheduled
|
||||
* while `active` is true and is always cleared before it can fire after
|
||||
* unmount.
|
||||
*/
|
||||
export function useTimeout(
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
active = true,
|
||||
): void {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
const id = setTimeout(() => callbackRef.current(), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [active, delayMs]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes a value with the browser timer queue. Pending debounce timers are
|
||||
* cleared when the value or delay changes and on unmount.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React with a browser `window` event listener. The listener is
|
||||
* removed before resubscribe and on unmount; the handler sees the latest render.
|
||||
*/
|
||||
export function useWindowEvent<K extends keyof WindowEventMap>(
|
||||
type: K,
|
||||
handler: (event: WindowEventMap[K]) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
active = true,
|
||||
): void {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || typeof window === "undefined") return undefined;
|
||||
const listener = (event: WindowEventMap[K]) => handlerRef.current(event);
|
||||
window.addEventListener(type, listener as EventListener, options);
|
||||
return () => {
|
||||
window.removeEventListener(type, listener as EventListener, options);
|
||||
};
|
||||
}, [active, options, type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React with a browser `document` event listener. The listener is
|
||||
* removed before resubscribe and on unmount; the handler sees the latest render.
|
||||
*/
|
||||
export function useDocumentEvent<K extends keyof DocumentEventMap>(
|
||||
type: K,
|
||||
handler: (event: DocumentEventMap[K]) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
active = true,
|
||||
): void {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || typeof document === "undefined") return undefined;
|
||||
const listener = (event: DocumentEventMap[K]) => handlerRef.current(event);
|
||||
document.addEventListener(type, listener as EventListener, options);
|
||||
return () => {
|
||||
document.removeEventListener(type, listener as EventListener, options);
|
||||
};
|
||||
}, [active, options, type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React with `document.title`. The previous title is restored when
|
||||
* the title changes or the component unmounts.
|
||||
*/
|
||||
export function useDocumentTitle(title: string): void {
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const previous = document.title;
|
||||
document.title = title;
|
||||
return () => {
|
||||
document.title = previous;
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React rendering with a browser media query using
|
||||
* `useSyncExternalStore`. The media query listener is removed on unsubscribe.
|
||||
*/
|
||||
export function useMediaQuery(query: string, serverSnapshot = false): boolean {
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
mediaQuery.addEventListener("change", onStoreChange);
|
||||
return () => mediaQuery.removeEventListener("change", onStoreChange);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
const getSnapshot = useCallback(
|
||||
() => typeof window !== "undefined" && window.matchMedia(query).matches,
|
||||
[query],
|
||||
);
|
||||
const getServerSnapshot = useCallback(
|
||||
() => serverSnapshot,
|
||||
[serverSnapshot],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React rendering with `window.location.hash` using
|
||||
* `useSyncExternalStore`. The `hashchange` listener is removed on unsubscribe.
|
||||
*/
|
||||
export function useLocationHash(serverSnapshot = ""): string {
|
||||
const subscribe = useCallback((onStoreChange: () => void) => {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
window.addEventListener("hashchange", onStoreChange);
|
||||
return () => window.removeEventListener("hashchange", onStoreChange);
|
||||
}, []);
|
||||
const getSnapshot = useCallback(
|
||||
() => typeof window === "undefined" ? serverSnapshot : window.location.hash,
|
||||
[serverSnapshot],
|
||||
);
|
||||
const getServerSnapshot = useCallback(
|
||||
() => serverSnapshot,
|
||||
[serverSnapshot],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React with a browser `ResizeObserver`. The observer is
|
||||
* disconnected before resubscribe and on unmount; the callback sees the latest
|
||||
* render.
|
||||
*/
|
||||
export function useResizeObserver<T extends Element>(
|
||||
ref: RefObject<T | null>,
|
||||
callback: ResizeObserverCallback,
|
||||
active = true,
|
||||
): void {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || typeof ResizeObserver === "undefined") return undefined;
|
||||
const node = ref.current;
|
||||
if (!node) return undefined;
|
||||
const observer = new ResizeObserver((entries, resizeObserver) => {
|
||||
callbackRef.current(entries, resizeObserver);
|
||||
});
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [active, ref]);
|
||||
}
|
||||
183
apps/fabro-web/app/hooks/use-annotated-run-graph-svg.ts
Normal file
183
apps/fabro-web/app/hooks/use-annotated-run-graph-svg.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
import {
|
||||
ACTIVE_STAGE_STATES,
|
||||
SUCCEEDED_STAGE_STATES,
|
||||
aggregateGraphNodeStatus,
|
||||
type Stage,
|
||||
} from "../lib/stage-sidebar";
|
||||
|
||||
const HOVER_OPEN_DELAY_MS = 200;
|
||||
|
||||
export interface RunGraphNodeHover {
|
||||
stage: Stage;
|
||||
rect: DOMRect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes Graphviz SVG markup with imperative DOM annotations, animation
|
||||
* nodes, and pointer listeners. Timers and DOM listeners are cleaned up before
|
||||
* resubscribe and on unmount.
|
||||
*/
|
||||
export function useAnnotatedRunGraphSvg({
|
||||
graphSvg,
|
||||
innerRef,
|
||||
onHoverChange,
|
||||
onStageClick,
|
||||
stages,
|
||||
svgRef,
|
||||
terminalOutcome,
|
||||
}: {
|
||||
graphSvg: string | null | undefined;
|
||||
innerRef: { current: HTMLDivElement | null };
|
||||
onHoverChange: (hover: RunGraphNodeHover | null) => void;
|
||||
onStageClick: (stageId: string) => void;
|
||||
stages: Stage[];
|
||||
svgRef: { current: SVGSVGElement | null };
|
||||
terminalOutcome: "succeeded" | "failed" | "dead" | null;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const inner = innerRef.current;
|
||||
if (!inner || !graphSvg) return;
|
||||
|
||||
inner.innerHTML = graphSvg;
|
||||
const svg = inner.querySelector("svg");
|
||||
if (!svg) return;
|
||||
svgRef.current = svg;
|
||||
|
||||
const stageById = new Map<string, Stage>();
|
||||
for (const stage of stages) stageById.set(stage.id, stage);
|
||||
|
||||
const gt = graphTheme;
|
||||
const aggregated = aggregateGraphNodeStatus(stages);
|
||||
const runningDotIds = new Set<string>();
|
||||
const failedDotIds = new Set<string>();
|
||||
const completedDotIds = new Set<string>();
|
||||
const dotIdToStageId = new Map<string, string>();
|
||||
for (const [nodeId, { displayStatus, latestStageId }] of aggregated) {
|
||||
dotIdToStageId.set(nodeId, latestStageId);
|
||||
if (ACTIVE_STAGE_STATES.has(displayStatus)) {
|
||||
runningDotIds.add(nodeId);
|
||||
} else if (displayStatus === "failed") {
|
||||
failedDotIds.add(nodeId);
|
||||
} else if (SUCCEEDED_STAGE_STATES.has(displayStatus)) {
|
||||
completedDotIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearOpenTimer = () => {
|
||||
if (openTimer !== null) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = null;
|
||||
}
|
||||
};
|
||||
const listeners: Array<{ target: Element; type: string; listener: EventListener }> = [];
|
||||
const addListener = (target: Element, type: string, listener: EventListener) => {
|
||||
target.addEventListener(type, listener);
|
||||
listeners.push({ target, type, listener });
|
||||
};
|
||||
|
||||
for (const group of svg.querySelectorAll(".node")) {
|
||||
const nodeId = group.querySelector("title")?.textContent?.trim();
|
||||
if (!nodeId) continue;
|
||||
|
||||
const stageId = dotIdToStageId.get(nodeId);
|
||||
const stage = stageId ? stageById.get(stageId) : undefined;
|
||||
if (stageId) {
|
||||
(group as SVGElement).style.cursor = "pointer";
|
||||
addListener(group, "click", () => onStageClick(stageId));
|
||||
}
|
||||
if (stage) {
|
||||
addListener(group, "mouseenter", () => {
|
||||
clearOpenTimer();
|
||||
const target = group as SVGGElement;
|
||||
openTimer = setTimeout(() => {
|
||||
openTimer = null;
|
||||
onHoverChange({ stage, rect: target.getBoundingClientRect() });
|
||||
}, HOVER_OPEN_DELAY_MS);
|
||||
});
|
||||
addListener(group, "mouseleave", () => {
|
||||
clearOpenTimer();
|
||||
onHoverChange(null);
|
||||
});
|
||||
}
|
||||
|
||||
if (nodeId === "exit" && terminalOutcome) {
|
||||
const isSuccess = terminalOutcome === "succeeded";
|
||||
const fill = isSuccess ? gt.completedFill : gt.failedFill;
|
||||
const border = isSuccess ? gt.completedBorder : gt.failedBorder;
|
||||
const text = isSuccess ? gt.completedText : gt.failedText;
|
||||
for (const shape of group.querySelectorAll("ellipse, polygon, path")) {
|
||||
shape.setAttribute("fill", fill);
|
||||
shape.setAttribute("stroke", border);
|
||||
}
|
||||
for (const t of group.querySelectorAll("text")) {
|
||||
t.setAttribute("fill", text);
|
||||
}
|
||||
} else if (runningDotIds.has(nodeId)) {
|
||||
for (const shape of group.querySelectorAll("ellipse, polygon, path")) {
|
||||
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);
|
||||
}
|
||||
for (const text of group.querySelectorAll("text")) {
|
||||
text.setAttribute("fill", gt.runningText);
|
||||
}
|
||||
} else if (failedDotIds.has(nodeId)) {
|
||||
for (const shape of group.querySelectorAll("ellipse, polygon, path")) {
|
||||
shape.setAttribute("fill", gt.failedFill);
|
||||
shape.setAttribute("stroke", gt.failedBorder);
|
||||
}
|
||||
for (const text of group.querySelectorAll("text")) {
|
||||
text.setAttribute("fill", gt.failedText);
|
||||
}
|
||||
} else if (completedDotIds.has(nodeId)) {
|
||||
for (const shape of group.querySelectorAll("ellipse, polygon, path")) {
|
||||
shape.setAttribute("fill", gt.completedFill);
|
||||
shape.setAttribute("stroke", gt.completedBorder);
|
||||
}
|
||||
for (const text of group.querySelectorAll("text")) {
|
||||
text.setAttribute("fill", gt.completedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearOpenTimer();
|
||||
for (const { target, type, listener } of listeners) {
|
||||
target.removeEventListener(type, listener);
|
||||
}
|
||||
onHoverChange(null);
|
||||
};
|
||||
}, [graphSvg, innerRef, onHoverChange, onStageClick, stages, svgRef, terminalOutcome]);
|
||||
}
|
||||
72
apps/fabro-web/app/hooks/use-changed-files-tree-sync.ts
Normal file
72
apps/fabro-web/app/hooks/use-changed-files-tree-sync.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type {
|
||||
FileTree as FileTreeModel,
|
||||
GitStatusEntry,
|
||||
} from "@pierre/trees";
|
||||
|
||||
/**
|
||||
* Synchronizes Pierre's imperative changed-files tree model with React-owned
|
||||
* file paths, git status, and selected-path state. Model mutations run after
|
||||
* commit; no external subscription is created.
|
||||
*/
|
||||
export function useChangedFilesTreeSync({
|
||||
changedPaths,
|
||||
changedPathsRef,
|
||||
gitStatus,
|
||||
model,
|
||||
paths,
|
||||
pendingSelectedPathRef,
|
||||
selectedPath,
|
||||
selectedPathRef,
|
||||
selection,
|
||||
syncSelection,
|
||||
}: {
|
||||
changedPaths: ReadonlySet<string>;
|
||||
changedPathsRef: { current: ReadonlySet<string> };
|
||||
gitStatus: GitStatusEntry[];
|
||||
model: FileTreeModel;
|
||||
paths: string[];
|
||||
pendingSelectedPathRef: { current: string | null };
|
||||
selectedPath: string | null;
|
||||
selectedPathRef: { current: string | null };
|
||||
selection: readonly string[];
|
||||
syncSelection: (
|
||||
model: FileTreeModel,
|
||||
selection: readonly string[],
|
||||
selectedPath: string | null,
|
||||
) => void;
|
||||
}) {
|
||||
const didSyncModelRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didSyncModelRef.current) {
|
||||
didSyncModelRef.current = true;
|
||||
return;
|
||||
}
|
||||
model.resetPaths(paths);
|
||||
model.setGitStatus(gitStatus);
|
||||
pendingSelectedPathRef.current = null;
|
||||
const currentSelectedPath = selectedPathRef.current;
|
||||
syncSelection(
|
||||
model,
|
||||
model.getSelectedPaths(),
|
||||
currentSelectedPath && changedPathsRef.current.has(currentSelectedPath)
|
||||
? currentSelectedPath
|
||||
: null,
|
||||
);
|
||||
}, [changedPathsRef, gitStatus, model, paths, pendingSelectedPathRef, selectedPathRef, syncSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingSelectedPath = pendingSelectedPathRef.current;
|
||||
if (pendingSelectedPath === selectedPath) {
|
||||
pendingSelectedPathRef.current = null;
|
||||
}
|
||||
const nextSelectedPath = pendingSelectedPath ?? selectedPath;
|
||||
syncSelection(
|
||||
model,
|
||||
selection,
|
||||
nextSelectedPath && changedPaths.has(nextSelectedPath) ? nextSelectedPath : null,
|
||||
);
|
||||
}, [changedPaths, model, pendingSelectedPathRef, selectedPath, selection, syncSelection]);
|
||||
}
|
||||
10
apps/fabro-web/app/hooks/use-data-updated-at.ts
Normal file
10
apps/fabro-web/app/hooks/use-data-updated-at.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Captures a stable wall-clock timestamp for the current async data identity.
|
||||
* The value is derived during render and stays stable until that identity
|
||||
* changes.
|
||||
*/
|
||||
export function useDataUpdatedAt<T>(data: T | null | undefined): number | null {
|
||||
return useMemo(() => data != null ? Date.now() : null, [data]);
|
||||
}
|
||||
31
apps/fabro-web/app/hooks/use-dot-language-ready.ts
Normal file
31
apps/fabro-web/app/hooks/use-dot-language-ready.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { registerDotLanguage } from "../data/register-dot-language";
|
||||
|
||||
let dotLanguageRegistration: Promise<void> | null = null;
|
||||
|
||||
function ensureDotLanguageRegistered(): Promise<void> {
|
||||
dotLanguageRegistration ??= registerDotLanguage();
|
||||
return dotLanguageRegistration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React with the shared Pierre syntax highlighter's Graphviz DOT
|
||||
* language registration. Registration is shared across mounts; cleanup only
|
||||
* suppresses stale state updates because the highlighter registration is global.
|
||||
*/
|
||||
export function useDotLanguageReady(): boolean {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void ensureDotLanguageRegistered().then(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ready;
|
||||
}
|
||||
16
apps/fabro-web/app/hooks/use-file-tree-model.ts
Normal file
16
apps/fabro-web/app/hooks/use-file-tree-model.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
import type { FileTree as FileTreeModel } from "@pierre/trees";
|
||||
|
||||
/**
|
||||
* Synchronizes Pierre's imperative file-tree model with the latest path list.
|
||||
* The model owns no subscription here, so no cleanup is required.
|
||||
*/
|
||||
export function useResetFileTreePaths(
|
||||
model: FileTreeModel,
|
||||
paths: readonly string[],
|
||||
) {
|
||||
useEffect(() => {
|
||||
model.resetPaths(paths);
|
||||
}, [model, paths]);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
export type FloatingTooltipSize = { height: number; width: number };
|
||||
|
||||
function viewportSize(): FloatingTooltipSize {
|
||||
return { height: window.innerHeight, width: window.innerWidth };
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes a floating tooltip with DOM layout measurements, ResizeObserver,
|
||||
* and window resize events. Observers and listeners are disconnected on
|
||||
* unmount.
|
||||
*/
|
||||
export function useFloatingTooltipMeasurements() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ height: 0, width: 0 });
|
||||
const [viewport, setViewport] = useState<FloatingTooltipSize>(() =>
|
||||
typeof window === "undefined" ? { height: 0, width: 0 } : viewportSize(),
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateSize = () => {
|
||||
const next = node.getBoundingClientRect();
|
||||
setSize((prev) =>
|
||||
prev.height === next.height && prev.width === next.width
|
||||
? prev
|
||||
: { height: next.height, width: next.width },
|
||||
);
|
||||
};
|
||||
const updateViewport = () => {
|
||||
const next = viewportSize();
|
||||
setViewport((prev) =>
|
||||
prev.height === next.height && prev.width === next.width ? prev : next,
|
||||
);
|
||||
};
|
||||
|
||||
updateSize();
|
||||
updateViewport();
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(updateSize);
|
||||
resizeObserver?.observe(node);
|
||||
window.addEventListener("resize", updateViewport);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", updateViewport);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ref, size, viewport };
|
||||
}
|
||||
20
apps/fabro-web/app/hooks/use-focus-after-refresh.ts
Normal file
20
apps/fabro-web/app/hooks/use-focus-after-refresh.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
/**
|
||||
* Synchronizes refresh completion with browser focus so keyboard users return to
|
||||
* the refresh control. No cleanup is required because focus is a one-shot DOM
|
||||
* operation and duplicate Strict Mode calls do not change persisted state.
|
||||
*/
|
||||
export function useFocusAfterRefreshCompletes(
|
||||
refreshing: boolean,
|
||||
targetRef: RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const refreshingPrev = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshingPrev.current && !refreshing) {
|
||||
targetRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
refreshingPrev.current = refreshing;
|
||||
}, [refreshing, targetRef]);
|
||||
}
|
||||
116
apps/fabro-web/app/hooks/use-insights-query-runner.ts
Normal file
116
apps/fabro-web/app/hooks/use-insights-query-runner.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface QueryResult {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string | number>>;
|
||||
elapsed: number;
|
||||
rowsRead: number;
|
||||
bytesRead: number;
|
||||
rowsReturned: number;
|
||||
}
|
||||
|
||||
function generateMockResult(sql: string): QueryResult {
|
||||
const lowerSql = sql.toLowerCase();
|
||||
|
||||
if (lowerSql.includes("workflow_name") && lowerSql.includes("avg")) {
|
||||
return {
|
||||
columns: ["workflow_name", "avg_duration", "run_count"],
|
||||
rows: [
|
||||
{ workflow_name: "Expand Product", avg_duration: 342.5, run_count: 48 },
|
||||
{ workflow_name: "Implement Feature", avg_duration: 287.3, run_count: 156 },
|
||||
{ workflow_name: "Security Scan", avg_duration: 198.1, run_count: 312 },
|
||||
{ workflow_name: "Fix Build", avg_duration: 145.7, run_count: 482 },
|
||||
{ workflow_name: "Sync Drift", avg_duration: 89.2, run_count: 94 },
|
||||
{ workflow_name: "Dependency Audit", avg_duration: 67.4, run_count: 201 },
|
||||
],
|
||||
elapsed: 0.531,
|
||||
rowsRead: 5182366,
|
||||
bytesRead: 357780000,
|
||||
rowsReturned: 6,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerSql.includes("failure_rate") || lowerSql.includes("failed")) {
|
||||
return {
|
||||
columns: ["day", "failures", "total", "failure_rate"],
|
||||
rows: Array.from({ length: 14 }, (_, i) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const total = 80 + Math.floor(Math.random() * 60);
|
||||
const failures = Math.floor(Math.random() * 15);
|
||||
return {
|
||||
day: d.toISOString().slice(0, 10),
|
||||
failures,
|
||||
total,
|
||||
failure_rate: Math.round((1000 * failures) / total) / 10,
|
||||
};
|
||||
}),
|
||||
elapsed: 0.287,
|
||||
rowsRead: 2841092,
|
||||
bytesRead: 198400000,
|
||||
rowsReturned: 14,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
columns: ["repo", "runs", "total_additions", "total_deletions"],
|
||||
rows: [
|
||||
{ repo: "fabro-engine", runs: 482, total_additions: 28450, total_deletions: 12300 },
|
||||
{ repo: "fabro-web", runs: 356, total_additions: 19200, total_deletions: 8900 },
|
||||
{ repo: "fabro-cli", runs: 198, total_additions: 8700, total_deletions: 4200 },
|
||||
{ repo: "fabro-docs", runs: 145, total_additions: 12100, total_deletions: 3400 },
|
||||
{ repo: "fabro-sdk", runs: 89, total_additions: 5600, total_deletions: 2100 },
|
||||
{ repo: "fabro-infra", runs: 67, total_additions: 3200, total_deletions: 1800 },
|
||||
{ repo: "fabro-actions", runs: 42, total_additions: 2100, total_deletions: 980 },
|
||||
{ repo: "fabro-proto", runs: 28, total_additions: 1400, total_deletions: 650 },
|
||||
],
|
||||
elapsed: 0.148,
|
||||
rowsRead: 1204588,
|
||||
bytesRead: 89200000,
|
||||
rowsReturned: 8,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes the mock Insights query runner with the browser timer queue.
|
||||
* Starting a new run clears any pending timer, and the active timer is cleared
|
||||
* on unmount so stale completions cannot update React state.
|
||||
*/
|
||||
export function useInsightsQueryRunner(initialSql: string) {
|
||||
const [result, setResult] = useState<QueryResult | null>(() =>
|
||||
generateMockResult(initialSql),
|
||||
);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const runRequestIdRef = useRef(0);
|
||||
const runTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearPendingRun = useCallback(() => {
|
||||
if (runTimeoutRef.current !== null) {
|
||||
clearTimeout(runTimeoutRef.current);
|
||||
runTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runQuery = useCallback((sql: string) => {
|
||||
const requestId = runRequestIdRef.current + 1;
|
||||
runRequestIdRef.current = requestId;
|
||||
clearPendingRun();
|
||||
setIsRunning(true);
|
||||
const delay = 200 + Math.random() * 400;
|
||||
runTimeoutRef.current = setTimeout(() => {
|
||||
if (runRequestIdRef.current !== requestId) return;
|
||||
runTimeoutRef.current = null;
|
||||
setResult(generateMockResult(sql));
|
||||
setIsRunning(false);
|
||||
}, delay);
|
||||
}, [clearPendingRun]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
runRequestIdRef.current += 1;
|
||||
clearPendingRun();
|
||||
};
|
||||
}, [clearPendingRun]);
|
||||
|
||||
return { result, isRunning, runQuery };
|
||||
}
|
||||
128
apps/fabro-web/app/hooks/use-install-effects.ts
Normal file
128
apps/fabro-web/app/hooks/use-install-effects.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
import {
|
||||
type InstallFinishResponse,
|
||||
persistInstallToken,
|
||||
} from "../install-api";
|
||||
import { shouldRedirectAfterHealthPoll } from "../install-flow";
|
||||
import {
|
||||
consumeInstallGithubErrorFromUrl,
|
||||
consumeInstallTokenFromUrl,
|
||||
shouldConsumeInstallGithubErrorForPath,
|
||||
} from "../mode";
|
||||
|
||||
type InstallGithubCallbackAction =
|
||||
| { type: "saveErrorChanged"; message: string | null };
|
||||
|
||||
type InstallRestartPollingAction =
|
||||
| { type: "timedOutChanged"; timedOut: boolean };
|
||||
|
||||
/**
|
||||
* Synchronizes install mode with the browser URL and sessionStorage. A token in
|
||||
* the URL is persisted, promoted into React state, and scrubbed from history on
|
||||
* mount; there is no resource to clean up.
|
||||
*/
|
||||
export function useInstallTokenFromUrl({
|
||||
setInstallToken,
|
||||
}: {
|
||||
setInstallToken: Dispatch<SetStateAction<string | null>>;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href);
|
||||
if (!token) return;
|
||||
|
||||
persistInstallToken(token);
|
||||
setInstallToken(token);
|
||||
window.history.replaceState(window.history.state, "", sanitizedUrl);
|
||||
}, [setInstallToken]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes GitHub App callback errors from the browser URL into the install
|
||||
* state machine. The error query parameter is scrubbed from history after it is
|
||||
* consumed; there is no resource to clean up.
|
||||
*/
|
||||
export function useInstallGithubCallbackError({
|
||||
dispatchInstall,
|
||||
pathname,
|
||||
}: {
|
||||
dispatchInstall: (action: InstallGithubCallbackAction) => void;
|
||||
pathname: string;
|
||||
}) {
|
||||
const consumedErrorPathRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldConsumeInstallGithubErrorForPath(pathname)) {
|
||||
const { error, sanitizedUrl } = consumeInstallGithubErrorFromUrl(window.location.href);
|
||||
if (error) {
|
||||
consumedErrorPathRef.current = pathname;
|
||||
dispatchInstall({ type: "saveErrorChanged", message: error });
|
||||
window.history.replaceState(window.history.state, "", sanitizedUrl);
|
||||
return;
|
||||
}
|
||||
if (consumedErrorPathRef.current === pathname) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
consumedErrorPathRef.current = null;
|
||||
dispatchInstall({ type: "saveErrorChanged", message: null });
|
||||
}, [dispatchInstall, pathname]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes install finishing with browser timers, fetch health polling, and
|
||||
* `window.location`. The deadline timer, polling interval, and in-flight fetch
|
||||
* are cancelled when finishing stops or the component unmounts.
|
||||
*/
|
||||
export function useInstallRestartHealthPolling({
|
||||
dispatchInstall,
|
||||
finishState,
|
||||
}: {
|
||||
dispatchInstall: (action: InstallRestartPollingAction) => void;
|
||||
finishState: InstallFinishResponse | null;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!finishState) return;
|
||||
|
||||
dispatchInstall({ type: "timedOutChanged", timedOut: false });
|
||||
const deadline = window.setTimeout(() => {
|
||||
dispatchInstall({ type: "timedOutChanged", timedOut: true });
|
||||
}, 30_000);
|
||||
|
||||
const controller = new AbortController();
|
||||
let inFlight = false;
|
||||
const poll = async () => {
|
||||
if (inFlight || controller.signal.aborted) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const response = await fetch("/health", { signal: controller.signal });
|
||||
const body = response.ok
|
||||
? ((await response.json()) as { mode?: string })
|
||||
: undefined;
|
||||
if (
|
||||
shouldRedirectAfterHealthPoll({
|
||||
kind: "response",
|
||||
ok: response.ok,
|
||||
mode: body?.mode,
|
||||
})
|
||||
) {
|
||||
window.location.href = finishState.restart_url;
|
||||
}
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
if (shouldRedirectAfterHealthPoll({ kind: "error" })) {
|
||||
window.location.href = finishState.restart_url;
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
const interval = window.setInterval(poll, 2_000);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(deadline);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [dispatchInstall, finishState]);
|
||||
}
|
||||
30
apps/fabro-web/app/hooks/use-minimum-refresh-spinner.ts
Normal file
30
apps/fabro-web/app/hooks/use-minimum-refresh-spinner.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Synchronizes a user-triggered refresh affordance with the browser timer queue.
|
||||
* Any pending minimum-duration timer is cleared before restart and on unmount.
|
||||
*/
|
||||
export function useMinimumRefreshSpinner(durationMs: number) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(() => {
|
||||
clear();
|
||||
setActive(true);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setActive(false);
|
||||
timerRef.current = null;
|
||||
}, durationMs);
|
||||
}, [clear, durationMs]);
|
||||
|
||||
useEffect(() => clear, [clear]);
|
||||
|
||||
return { active, start };
|
||||
}
|
||||
30
apps/fabro-web/app/hooks/use-pending-chat-autoresponse.ts
Normal file
30
apps/fabro-web/app/hooks/use-pending-chat-autoresponse.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Synchronizes a pending scripted chat response with assistant-ui's imperative
|
||||
* runtime. There is no resource to clean up; duplicate Strict Mode calls are
|
||||
* harmless because the local ref dedupes a mount cycle and the chat store flag
|
||||
* dedupes remounts.
|
||||
*/
|
||||
export function usePendingChatAutoresponse({
|
||||
chatId,
|
||||
pendingResponse,
|
||||
consumePendingResponse,
|
||||
startRun,
|
||||
}: {
|
||||
chatId: string;
|
||||
pendingResponse: boolean;
|
||||
consumePendingResponse: (chatId: string) => void;
|
||||
startRun: () => void;
|
||||
}) {
|
||||
const startRunRef = useRef(startRun);
|
||||
startRunRef.current = startRun;
|
||||
const didStartRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingResponse || didStartRef.current) return;
|
||||
didStartRef.current = true;
|
||||
consumePendingResponse(chatId);
|
||||
startRunRef.current();
|
||||
}, [chatId, consumePendingResponse, pendingResponse]);
|
||||
}
|
||||
54
apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts
Normal file
54
apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Synchronizes a DOT source with the imperative @viz-js SVG renderer and a DOM
|
||||
* container. Async renders are ignored after identity changes or unmount.
|
||||
*/
|
||||
export function useRenderedVizDiagram<TIdentity>({
|
||||
buildDot,
|
||||
innerRef,
|
||||
identity,
|
||||
onRenderStart,
|
||||
prepareSvg,
|
||||
svgRef,
|
||||
}: {
|
||||
buildDot: (identity: TIdentity) => string;
|
||||
innerRef: { current: HTMLDivElement | null };
|
||||
identity: TIdentity;
|
||||
onRenderStart?: () => void;
|
||||
prepareSvg?: (svg: SVGSVGElement) => void;
|
||||
svgRef: { current: SVGSVGElement | null };
|
||||
}): string | null {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function render() {
|
||||
setError(null);
|
||||
onRenderStart?.();
|
||||
const { instance } = await import("@viz-js/viz");
|
||||
const viz = await instance();
|
||||
if (cancelled) return;
|
||||
|
||||
try {
|
||||
const svg = viz.renderSVGElement(buildDot(identity));
|
||||
prepareSvg?.(svg);
|
||||
|
||||
svgRef.current = svg;
|
||||
if (innerRef.current) {
|
||||
innerRef.current.replaceChildren(svg);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to render diagram");
|
||||
}
|
||||
}
|
||||
|
||||
void render();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [buildDot, identity, innerRef, onRenderStart, prepareSvg, svgRef]);
|
||||
|
||||
return error;
|
||||
}
|
||||
47
apps/fabro-web/app/hooks/use-run-file-deep-link.ts
Normal file
47
apps/fabro-web/app/hooks/use-run-file-deep-link.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { PaginatedRunFileList } from "@qltysh/fabro-api-client";
|
||||
import type { ToastInput } from "../components/toast";
|
||||
|
||||
/**
|
||||
* Synchronizes the run-files URL hash with rendered file-row DOM focus and the
|
||||
* toast system. Missing-file toasts are deduped by key, and no persistent
|
||||
* browser resource is created.
|
||||
*/
|
||||
export function useRunFileDeepLinkFocus({
|
||||
data,
|
||||
hashFile,
|
||||
rowId,
|
||||
resolveToast,
|
||||
push,
|
||||
}: {
|
||||
data: PaginatedRunFileList | null;
|
||||
hashFile: string | null;
|
||||
rowId: (path: string) => string;
|
||||
resolveToast: (
|
||||
hashFile: string | null,
|
||||
data: PaginatedRunFileList | null,
|
||||
) => { key: string; message: string } | null;
|
||||
push: (toast: ToastInput) => string;
|
||||
}) {
|
||||
const lastToastRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const toast = resolveToast(hashFile, data);
|
||||
if (toast) {
|
||||
if (lastToastRef.current !== toast.key) {
|
||||
push({ message: toast.message, autoDismissMs: 5000 });
|
||||
lastToastRef.current = toast.key;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastToastRef.current = null;
|
||||
if (!hashFile || !data) return;
|
||||
const el = document.getElementById(rowId(hashFile));
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
el.focus({ preventScroll: true });
|
||||
}
|
||||
}, [data, hashFile, push, resolveToast, rowId]);
|
||||
}
|
||||
|
|
@ -7,6 +7,10 @@ import type { MutateFn } from "../lib/sse";
|
|||
const NOOP_MUTATE = (() => undefined) as MutateFn;
|
||||
const DEDUPE_WINDOW = 256;
|
||||
|
||||
/**
|
||||
* Synchronizes toast notifications with a run-scoped SSE stream. Changing
|
||||
* `runId` resubscribes, and the active subscription is closed on unmount.
|
||||
*/
|
||||
export function useRunToasts(runId: string | undefined) {
|
||||
const { push } = useToast();
|
||||
const seenEventIdsRef = useRef(new Set<string>());
|
||||
|
|
|
|||
207
apps/fabro-web/app/hooks/use-terminal-session.ts
Normal file
207
apps/fabro-web/app/hooks/use-terminal-session.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { useEffect, useRef, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
import type { Terminal as XtermTerminal } from "@xterm/xterm";
|
||||
import type { FitAddon as XtermFitAddon } from "@xterm/addon-fit";
|
||||
|
||||
import {
|
||||
buildTerminalWebSocketUrl,
|
||||
parseTerminalServerMessage,
|
||||
} from "../components/terminal-view-helpers";
|
||||
|
||||
export type ConnectionStatus = "connecting" | "ready" | "closed" | "error";
|
||||
|
||||
export type TerminalConnectionError = {
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
};
|
||||
|
||||
export const TERMINAL_BACKGROUND = "#05080F";
|
||||
|
||||
// Pin the cell to a whole-pixel height so xterm's fit math stays exact.
|
||||
// fontSize × lineHeight = 13 × (19/13) = 19px → no sub-pixel rounding,
|
||||
// no bottom-row clipping.
|
||||
const TERMINAL_FONT_SIZE = 13;
|
||||
const TERMINAL_CELL_HEIGHT_PX = 19;
|
||||
const TERMINAL_LINE_HEIGHT = TERMINAL_CELL_HEIGHT_PX / TERMINAL_FONT_SIZE;
|
||||
|
||||
const TERMINAL_THEME = {
|
||||
background: TERMINAL_BACKGROUND,
|
||||
foreground: "#E6EDF3",
|
||||
cursor: "#7AC4E5",
|
||||
cursorAccent: TERMINAL_BACKGROUND,
|
||||
selectionBackground: "#1F4F73",
|
||||
|
||||
black: TERMINAL_BACKGROUND,
|
||||
red: "#FF6B6B",
|
||||
green: "#5EE6A8",
|
||||
yellow: "#FFC857",
|
||||
blue: "#82AAFF",
|
||||
magenta: "#C792EA",
|
||||
cyan: "#7AC4E5",
|
||||
white: "#D5DCE3",
|
||||
|
||||
brightBlack: "#4B5563",
|
||||
brightRed: "#FF8B8B",
|
||||
brightGreen: "#85F5C2",
|
||||
brightYellow: "#FFD98A",
|
||||
brightBlue: "#A4C4FF",
|
||||
brightMagenta: "#E0B6FF",
|
||||
brightCyan: "#A8DFF5",
|
||||
brightWhite: "#FFFFFF",
|
||||
};
|
||||
|
||||
function sendResize(socket: WebSocket | null, terminal: XtermTerminal | null) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !terminal) return;
|
||||
socket.send(JSON.stringify({
|
||||
type: "resize",
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes a mounted DOM node with xterm, its FitAddon, ResizeObserver, and
|
||||
* the run terminal WebSocket. All listeners, observers, sockets, and xterm
|
||||
* disposables are cleaned up before reconnect and on unmount.
|
||||
*/
|
||||
export function useTerminalSession({
|
||||
connectionKey,
|
||||
runId,
|
||||
setError,
|
||||
setStatus,
|
||||
terminalEl,
|
||||
}: {
|
||||
connectionKey: number;
|
||||
runId: string;
|
||||
setError: Dispatch<SetStateAction<TerminalConnectionError | null>>;
|
||||
setStatus: Dispatch<SetStateAction<ConnectionStatus>>;
|
||||
terminalEl: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const terminalRef = useRef<XtermTerminal | null>(null);
|
||||
const fitRef = useRef<XtermFitAddon | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalEl.current) return undefined;
|
||||
|
||||
let disposed = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
const textEncoder = new TextEncoder();
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
async function connect() {
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
]);
|
||||
if (disposed || !terminalEl.current) return;
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontFamily: "\"JetBrains Mono\", ui-monospace, monospace",
|
||||
fontSize: TERMINAL_FONT_SIZE,
|
||||
lineHeight: TERMINAL_LINE_HEIGHT,
|
||||
scrollback: 5000,
|
||||
theme: TERMINAL_THEME,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(terminalEl.current);
|
||||
fitAddon.fit();
|
||||
terminal.focus();
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fitAddon;
|
||||
|
||||
const socket = new WebSocket(buildTerminalWebSocketUrl(window.location, runId));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
disposables.push(terminal.onData((data) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(textEncoder.encode(data));
|
||||
}
|
||||
}));
|
||||
|
||||
const handleOpen = () => {
|
||||
sendResize(socket, terminal);
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (typeof event.data === "string") {
|
||||
const message = parseTerminalServerMessage(event.data);
|
||||
if (!message) return;
|
||||
if (message.type === "ready") {
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
if (message.type === "closed") {
|
||||
setStatus("closed");
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setError({
|
||||
message: message.message ?? "Terminal session failed.",
|
||||
recoverable: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bytes = event.data instanceof ArrayBuffer
|
||||
? new Uint8Array(event.data)
|
||||
: event.data;
|
||||
terminal.write(bytes);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setStatus((current) => current === "error" ? current : "closed");
|
||||
};
|
||||
const handleError = () => {
|
||||
setStatus("error");
|
||||
setError({
|
||||
message: "Terminal WebSocket connection failed.",
|
||||
recoverable: true,
|
||||
});
|
||||
};
|
||||
socket.addEventListener("open", handleOpen);
|
||||
socket.addEventListener("message", handleMessage);
|
||||
socket.addEventListener("close", handleClose);
|
||||
socket.addEventListener("error", handleError);
|
||||
disposables.push({
|
||||
dispose: () => {
|
||||
socket.removeEventListener("open", handleOpen);
|
||||
socket.removeEventListener("message", handleMessage);
|
||||
socket.removeEventListener("close", handleClose);
|
||||
socket.removeEventListener("error", handleError);
|
||||
},
|
||||
});
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
sendResize(socket, terminal);
|
||||
});
|
||||
resizeObserver.observe(terminalEl.current);
|
||||
|
||||
if (typeof document !== "undefined" && document.fonts?.ready) {
|
||||
void document.fonts.ready.then(() => {
|
||||
if (disposed) return;
|
||||
fitAddon.fit();
|
||||
sendResize(socket, terminal);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver?.disconnect();
|
||||
for (const disposable of disposables) disposable.dispose();
|
||||
socketRef.current?.send(JSON.stringify({ type: "close" }));
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [connectionKey, runId, setError, setStatus, terminalEl]);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||
import type { AxiosAdapter } from "axios";
|
||||
import { StrictMode } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
|
|
@ -209,11 +210,13 @@ describe("InstallApp", () => {
|
|||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/github?error=github-app-manifest-conversion-failed"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
<StrictMode>
|
||||
<MemoryRouter initialEntries={["/install/github?error=github-app-manifest-conversion-failed"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { startTransition, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { FormEvent, ReactNode, Ref } from "react";
|
||||
import {
|
||||
Link,
|
||||
Navigate,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
type NavigateFunction,
|
||||
} from "react-router";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
|
|
@ -43,12 +42,7 @@ import {
|
|||
testInstallSandbox,
|
||||
} from "./install-api";
|
||||
import { INSTALL_PROVIDERS } from "./install-config";
|
||||
import { shouldRedirectAfterHealthPoll } from "./install-flow";
|
||||
import {
|
||||
consumeInstallGithubErrorFromUrl,
|
||||
consumeInstallTokenFromUrl,
|
||||
shouldConsumeInstallGithubErrorForPath,
|
||||
} from "./mode";
|
||||
import { useInstallSessionQuery } from "./install-query";
|
||||
import {
|
||||
CopyButton,
|
||||
ErrorMessage,
|
||||
|
|
@ -57,6 +51,12 @@ import {
|
|||
SECONDARY_BUTTON_CLASS,
|
||||
} from "./components/ui";
|
||||
import { LoadingState } from "./components/state";
|
||||
import {
|
||||
useInstallGithubCallbackError,
|
||||
useInstallRestartHealthPolling,
|
||||
useInstallTokenFromUrl,
|
||||
} from "./hooks/use-install-effects";
|
||||
import { consumeInstallTokenFromUrl } from "./mode";
|
||||
|
||||
const INSTALL_STEPS = [
|
||||
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
|
||||
|
|
@ -77,9 +77,9 @@ type GithubOwnerKind = "personal" | "org";
|
|||
|
||||
type SessionState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; data: InstallSessionResponse };
|
||||
| { status: "loading"; token: string }
|
||||
| { status: "error"; token: string | null; message: string }
|
||||
| { status: "ready"; token: string; data: InstallSessionResponse };
|
||||
|
||||
type TokenForm = { token: string; username: string };
|
||||
|
||||
|
|
@ -134,9 +134,8 @@ type InstallState = {
|
|||
type InstallAction =
|
||||
| { type: "manualTokenChanged"; value: string }
|
||||
| { type: "sessionCleared" }
|
||||
| { type: "sessionRequested" }
|
||||
| { type: "sessionReady"; session: InstallSessionResponse }
|
||||
| { type: "sessionFailed"; message: string }
|
||||
| { type: "sessionReady"; token: string; session: InstallSessionResponse }
|
||||
| { type: "sessionFailed"; token: string | null; message: string }
|
||||
| { type: "saveErrorChanged"; message: string | null }
|
||||
| { type: "submittingChanged"; submitting: boolean }
|
||||
| { type: "timedOutChanged"; timedOut: boolean }
|
||||
|
|
@ -177,6 +176,7 @@ function initialInstallState(): InstallState {
|
|||
|
||||
function hydrateInstallState(
|
||||
state: InstallState,
|
||||
token: string,
|
||||
session: InstallSessionResponse,
|
||||
): InstallState {
|
||||
let githubStrategy = state.githubStrategy;
|
||||
|
|
@ -200,7 +200,7 @@ function hydrateInstallState(
|
|||
|
||||
return {
|
||||
...state,
|
||||
sessionState: { status: "ready", data: session },
|
||||
sessionState: { status: "ready", token, data: session },
|
||||
canonicalUrl:
|
||||
state.canonicalUrl ||
|
||||
session.server?.canonical_url ||
|
||||
|
|
@ -220,12 +220,10 @@ function installReducer(state: InstallState, action: InstallAction): InstallStat
|
|||
return { ...state, manualToken: action.value };
|
||||
case "sessionCleared":
|
||||
return { ...state, sessionState: { status: "idle" } };
|
||||
case "sessionRequested":
|
||||
return { ...state, sessionState: { status: "loading" } };
|
||||
case "sessionReady":
|
||||
return hydrateInstallState(state, action.session);
|
||||
return hydrateInstallState(state, action.token, action.session);
|
||||
case "sessionFailed":
|
||||
return { ...state, sessionState: { status: "error", message: action.message } };
|
||||
return { ...state, sessionState: { status: "error", token: action.token, message: action.message } };
|
||||
case "saveErrorChanged":
|
||||
return { ...state, saveError: action.message };
|
||||
case "submittingChanged":
|
||||
|
|
@ -286,10 +284,55 @@ function installReducer(state: InstallState, action: InstallAction): InstallStat
|
|||
}
|
||||
}
|
||||
|
||||
function installSessionErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Install session failed";
|
||||
}
|
||||
|
||||
function sessionStateForInstallToken(
|
||||
installToken: string | null,
|
||||
sessionState: SessionState,
|
||||
queryError: unknown,
|
||||
): SessionState {
|
||||
if (!installToken) {
|
||||
return sessionState.status === "error" && sessionState.token === null
|
||||
? sessionState
|
||||
: { status: "idle" };
|
||||
}
|
||||
|
||||
if (
|
||||
(sessionState.status === "ready" || sessionState.status === "error") &&
|
||||
sessionState.token === installToken
|
||||
) {
|
||||
return sessionState;
|
||||
}
|
||||
|
||||
if (queryError) {
|
||||
return {
|
||||
status: "error",
|
||||
token: installToken,
|
||||
message: installSessionErrorMessage(queryError),
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "loading", token: installToken };
|
||||
}
|
||||
|
||||
function readInitialInstallToken(): string | null {
|
||||
const stored = readStoredInstallToken();
|
||||
if (stored) return stored;
|
||||
if (typeof window === "undefined") return null;
|
||||
return consumeInstallTokenFromUrl(window.location.href).token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates install-mode browser integrations: token/error URL scrubbing,
|
||||
* install-session query state, and restart health polling. Timers, intervals,
|
||||
* and in-flight requests are cancelled when their install identity changes.
|
||||
*/
|
||||
function useInstallController() {
|
||||
const { pathname } = useLocation();
|
||||
const [installToken, setInstallToken] = useState<string | null>(() =>
|
||||
readStoredInstallToken(),
|
||||
readInitialInstallToken(),
|
||||
);
|
||||
const [installState, dispatchInstall] = useReducer(
|
||||
installReducer,
|
||||
|
|
@ -297,128 +340,54 @@ function useInstallController() {
|
|||
initialInstallState,
|
||||
);
|
||||
const { finishState } = installState;
|
||||
const installSessionQuery = useInstallSessionQuery(installToken, {
|
||||
onSuccess: (session) => {
|
||||
if (!installToken) return;
|
||||
dispatchInstall({ type: "sessionReady", token: installToken, session });
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchInstall({
|
||||
type: "sessionFailed",
|
||||
token: installToken,
|
||||
message: installSessionErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href);
|
||||
if (!token) return;
|
||||
|
||||
persistInstallToken(token);
|
||||
// react-doctor-disable-next-line react-doctor/no-initialize-state -- The token is persisted and scrubbed from the URL after the client mounts.
|
||||
setInstallToken(token);
|
||||
window.history.replaceState(window.history.state, "", sanitizedUrl);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldConsumeInstallGithubErrorForPath(pathname)) {
|
||||
const { error, sanitizedUrl } = consumeInstallGithubErrorFromUrl(window.location.href);
|
||||
if (error) {
|
||||
dispatchInstall({ type: "saveErrorChanged", message: error });
|
||||
window.history.replaceState(window.history.state, "", sanitizedUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatchInstall({ type: "saveErrorChanged", message: null });
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
useInstallTokenFromUrl({ setInstallToken });
|
||||
useInstallGithubCallbackError({ dispatchInstall, pathname });
|
||||
useInstallRestartHealthPolling({ dispatchInstall, finishState });
|
||||
const sessionState = sessionStateForInstallToken(
|
||||
installToken,
|
||||
installState.sessionState,
|
||||
installSessionQuery.error,
|
||||
);
|
||||
const controllerState =
|
||||
sessionState === installState.sessionState
|
||||
? installState
|
||||
: { ...installState, sessionState };
|
||||
const refreshInstallSession = async () => {
|
||||
if (!installToken) {
|
||||
dispatchInstall({ type: "sessionCleared" });
|
||||
return;
|
||||
throw new Error("Install token is required to refresh the session.");
|
||||
}
|
||||
const nextSession = await getInstallSession(installToken);
|
||||
dispatchInstall({
|
||||
type: "sessionReady",
|
||||
token: installToken,
|
||||
session: nextSession,
|
||||
});
|
||||
await installSessionQuery.mutate(nextSession, { revalidate: false });
|
||||
return nextSession;
|
||||
};
|
||||
|
||||
let cancelled = false;
|
||||
dispatchInstall({ type: "sessionRequested" });
|
||||
getInstallSession(installToken)
|
||||
.then((nextSession) => {
|
||||
if (cancelled) return;
|
||||
dispatchInstall({ type: "sessionReady", session: nextSession });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
dispatchInstall({
|
||||
type: "sessionFailed",
|
||||
message: error instanceof Error ? error.message : "Install session failed",
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [installToken]);
|
||||
|
||||
// react-doctor-disable-next-line react-doctor/no-fetch-in-effect -- This is install-mode restart polling, not cacheable app data.
|
||||
useEffect(() => {
|
||||
if (!finishState) return;
|
||||
|
||||
dispatchInstall({ type: "timedOutChanged", timedOut: false });
|
||||
const deadline = window.setTimeout(() => {
|
||||
dispatchInstall({ type: "timedOutChanged", timedOut: true });
|
||||
}, 30_000);
|
||||
|
||||
const controller = new AbortController();
|
||||
let inFlight = false;
|
||||
const poll = async () => {
|
||||
if (inFlight || controller.signal.aborted) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
// react-doctor-disable-next-line react-doctor/no-fetch-in-effect -- This health probe is tied to install restart polling, not cacheable app data.
|
||||
const response = await fetch("/health", { signal: controller.signal });
|
||||
const body = response.ok
|
||||
? ((await response.json()) as { mode?: string })
|
||||
: undefined;
|
||||
if (
|
||||
shouldRedirectAfterHealthPoll({
|
||||
kind: "response",
|
||||
ok: response.ok,
|
||||
mode: body?.mode,
|
||||
})
|
||||
) {
|
||||
window.location.href = finishState.restart_url;
|
||||
}
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
if (shouldRedirectAfterHealthPoll({ kind: "error" })) {
|
||||
window.location.href = finishState.restart_url;
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
const interval = window.setInterval(poll, 2_000);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(deadline);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [finishState]);
|
||||
|
||||
return { pathname, installToken, setInstallToken, installState, dispatchInstall };
|
||||
}
|
||||
|
||||
function useInstallRootRedirect({
|
||||
installToken,
|
||||
session,
|
||||
finishState,
|
||||
pathname,
|
||||
navigate,
|
||||
}: {
|
||||
installToken: string | null;
|
||||
session: InstallSessionResponse | null;
|
||||
finishState: FinishState;
|
||||
pathname: string;
|
||||
navigate: NavigateFunction;
|
||||
}) {
|
||||
// react-doctor-disable-next-line react-doctor/no-effect-chain -- Navigation waits for the async install session before leaving the token/root entry route.
|
||||
useEffect(() => {
|
||||
if (!installToken || !session) return;
|
||||
// react-doctor-disable-next-line react-doctor/no-event-handler -- This redirects from root/install exactly once after the async session becomes available.
|
||||
if ((pathname === "/" || pathname === "/install") && !finishState) {
|
||||
startTransition(() => {
|
||||
navigate("/install/welcome", { replace: true });
|
||||
});
|
||||
}
|
||||
}, [finishState, installToken, pathname, navigate, session]);
|
||||
return {
|
||||
pathname,
|
||||
installToken,
|
||||
setInstallToken,
|
||||
installState: controllerState,
|
||||
dispatchInstall,
|
||||
refreshInstallSession,
|
||||
};
|
||||
}
|
||||
|
||||
export default function InstallApp() {
|
||||
|
|
@ -429,6 +398,7 @@ export default function InstallApp() {
|
|||
setInstallToken,
|
||||
installState,
|
||||
dispatchInstall,
|
||||
refreshInstallSession,
|
||||
} = useInstallController();
|
||||
const {
|
||||
sessionState,
|
||||
|
|
@ -447,8 +417,6 @@ export default function InstallApp() {
|
|||
} = installState;
|
||||
const session = sessionState.status === "ready" ? sessionState.data : null;
|
||||
|
||||
useInstallRootRedirect({ installToken, session, finishState, pathname, navigate });
|
||||
|
||||
const currentStep = useMemo<StepId>(
|
||||
() =>
|
||||
STEPPER_STEPS.find((step) => pathname.startsWith(step.href))?.id ??
|
||||
|
|
@ -474,6 +442,7 @@ export default function InstallApp() {
|
|||
if (!nextToken) {
|
||||
dispatchInstall({
|
||||
type: "sessionFailed",
|
||||
token: null,
|
||||
message: "Paste the install token from the server logs.",
|
||||
});
|
||||
return;
|
||||
|
|
@ -497,8 +466,7 @@ export default function InstallApp() {
|
|||
try {
|
||||
await args.action();
|
||||
if (args.next) {
|
||||
const nextSession = await getInstallSession(installToken);
|
||||
dispatchInstall({ type: "sessionReady", session: nextSession });
|
||||
await refreshInstallSession();
|
||||
navigate(args.next);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -528,8 +496,8 @@ export default function InstallApp() {
|
|||
);
|
||||
}
|
||||
|
||||
// Covers both sessionState "loading" AND the brief "idle" window between
|
||||
// the initial render and the session-fetch useEffect. Without this guard,
|
||||
// Covers both sessionState "loading" AND the brief "idle" window before the
|
||||
// install session query reports data. Without this guard,
|
||||
// screens like GithubAppDoneScreen see `session == null` and navigate away
|
||||
// before the first fetch finishes — trapping the user in a redirect loop.
|
||||
if (!session) {
|
||||
|
|
@ -540,6 +508,10 @@ export default function InstallApp() {
|
|||
);
|
||||
}
|
||||
|
||||
if ((pathname === "/" || pathname === "/install") && !finishState) {
|
||||
return <Navigate to="/install/welcome" replace />;
|
||||
}
|
||||
|
||||
if (finishState && pathname !== "/install/finishing") {
|
||||
return <Navigate to="/install/finishing" replace />;
|
||||
}
|
||||
|
|
|
|||
31
apps/fabro-web/app/install-query.ts
Normal file
31
apps/fabro-web/app/install-query.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import useSWR, { type SWRConfiguration } from "swr";
|
||||
|
||||
import { getInstallSession, type InstallSessionResponse } from "./install-api";
|
||||
|
||||
type InstallSessionKey = readonly ["install", "session", string];
|
||||
|
||||
function installSessionKey(token: string | null): InstallSessionKey | null {
|
||||
return token ? ["install", "session", token] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the install session through SWR so server state is owned by the query
|
||||
* layer instead of a component effect. Revalidation is explicit because install
|
||||
* setup writes refresh the session from their submit path.
|
||||
*/
|
||||
export function useInstallSessionQuery(
|
||||
token: string | null,
|
||||
options: SWRConfiguration<InstallSessionResponse, Error> = {},
|
||||
) {
|
||||
return useSWR<InstallSessionResponse, Error, InstallSessionKey | null>(
|
||||
installSessionKey(token),
|
||||
([, , currentToken]) => getInstallSession(currentToken),
|
||||
{
|
||||
dedupingInterval: 0,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
shouldRetryOnError: false,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ import { Link, Outlet, useLocation, useMatches } from "react-router";
|
|||
import { Toaster } from "sonner";
|
||||
import { ErrorState } from "../components/state";
|
||||
import { TooltipProvider } from "../components/ui";
|
||||
import { AskFabroLayoutProvider, useAskFabroLayout } from "../lib/ask-fabro-layout";
|
||||
import { DemoModeProvider } from "../lib/demo-mode";
|
||||
import { useAuthMe } from "../lib/queries";
|
||||
import { allNavigation, getVisibleNavigation } from "./navigation";
|
||||
|
|
@ -65,7 +64,6 @@ export default function AppShell() {
|
|||
return (
|
||||
<DemoModeProvider value={demoMode}>
|
||||
<TooltipProvider>
|
||||
<AskFabroLayoutProvider>
|
||||
<div
|
||||
className={classNames(
|
||||
"isolate",
|
||||
|
|
@ -251,7 +249,6 @@ export default function AppShell() {
|
|||
{typeof document !== "undefined" && (
|
||||
<Toaster richColors position="bottom-right" />
|
||||
)}
|
||||
</AskFabroLayoutProvider>
|
||||
</TooltipProvider>
|
||||
</DemoModeProvider>
|
||||
);
|
||||
|
|
@ -269,15 +266,16 @@ function ShellMain({
|
|||
fullHeight: boolean;
|
||||
maxWidth: string;
|
||||
}) {
|
||||
const { sidebarWidth, isResizing } = useAskFabroLayout();
|
||||
return (
|
||||
<main
|
||||
className={classNames(
|
||||
!isResizing &&
|
||||
"transition-[padding] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
|
||||
fullHeight && "min-h-0 flex-1",
|
||||
)}
|
||||
style={{ paddingRight: sidebarWidth }}
|
||||
style={{
|
||||
paddingRight: "var(--fabro-ask-sidebar-width, 0px)",
|
||||
transition:
|
||||
"var(--fabro-ask-sidebar-transition, padding 300ms cubic-bezier(0.16, 1, 0.3, 1))",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
|
|
|
|||
|
|
@ -137,10 +137,10 @@ describe("fetchAllPages", () => {
|
|||
});
|
||||
|
||||
describe("stageArtifactDownloadUrl", () => {
|
||||
test("builds the download href through generated client metadata", async () => {
|
||||
await expect(
|
||||
test("builds the escaped download href", () => {
|
||||
expect(
|
||||
stageArtifactDownloadUrl("run 1", "stage@1", "logs/output.txt", 2),
|
||||
).resolves.toBe(
|
||||
).toBe(
|
||||
"/api/v1/runs/run%201/stages/stage%401/artifacts/download?filename=logs%2Foutput.txt&retry=2",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
InstallApi,
|
||||
ModelsApi,
|
||||
RunInternalsApi,
|
||||
RunInternalsApiAxiosParamCreator,
|
||||
RunOutputsApi,
|
||||
RunsApi,
|
||||
SecretsApi,
|
||||
|
|
@ -385,14 +384,17 @@ export function requestSignalOptions(request?: Request): RawAxiosRequestConfig {
|
|||
return request?.signal ? { signal: request.signal } : {};
|
||||
}
|
||||
|
||||
export async function stageArtifactDownloadUrl(
|
||||
export function stageArtifactDownloadUrl(
|
||||
id: string,
|
||||
stageId: string,
|
||||
filename: string,
|
||||
retry: number,
|
||||
): Promise<string> {
|
||||
const requestArgs = await RunInternalsApiAxiosParamCreator(
|
||||
generatedApiConfiguration,
|
||||
).getStageArtifact(id, stageId, filename, retry);
|
||||
return `${generatedApiConfiguration.basePath ?? ""}${requestArgs.url}`;
|
||||
): string {
|
||||
const searchParams = new URLSearchParams({
|
||||
filename,
|
||||
retry: String(retry),
|
||||
});
|
||||
return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${
|
||||
encodeURIComponent(id)
|
||||
}/stages/${encodeURIComponent(stageId)}/artifacts/download?${searchParams}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import { createContext, use, useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* Layout coordination for the docked "Ask Fabro" sidebar. The run detail page
|
||||
* owns the open/closed state and publishes the sidebar's current width here;
|
||||
* the app shell reads it and insets `<main>` by that amount so the page
|
||||
* content shifts left instead of being covered by the fixed sidebar.
|
||||
*/
|
||||
interface AskFabroLayout {
|
||||
/** Width in px the docked sidebar currently occupies; 0 when closed. */
|
||||
sidebarWidth: number;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
/**
|
||||
* True while the user is dragging the sidebar's resize handle. Consumers
|
||||
* that animate off `sidebarWidth` drop their transition while this is set so
|
||||
* the layout tracks the cursor instead of trailing it by the ease duration.
|
||||
*/
|
||||
isResizing: boolean;
|
||||
setIsResizing: (resizing: boolean) => void;
|
||||
}
|
||||
|
||||
const NOOP_LAYOUT: AskFabroLayout = {
|
||||
sidebarWidth: 0,
|
||||
setSidebarWidth: () => {},
|
||||
isResizing: false,
|
||||
setIsResizing: () => {},
|
||||
};
|
||||
|
||||
const AskFabroLayoutContext = createContext<AskFabroLayout>(NOOP_LAYOUT);
|
||||
|
||||
export function AskFabroLayoutProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(0);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const value = useMemo(
|
||||
() => ({ sidebarWidth, setSidebarWidth, isResizing, setIsResizing }),
|
||||
[sidebarWidth, isResizing],
|
||||
);
|
||||
return (
|
||||
<AskFabroLayoutContext.Provider value={value}>
|
||||
{children}
|
||||
</AskFabroLayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAskFabroLayout(): AskFabroLayout {
|
||||
return use(AskFabroLayoutContext);
|
||||
}
|
||||
|
|
@ -93,6 +93,10 @@ function boardRunKeys() {
|
|||
return runListCacheMatchers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React/SWR with the shared board SSE stream. The subscription is
|
||||
* closed before resubscribe and on unmount.
|
||||
*/
|
||||
export function useBoardEvents() {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import type { Key } from "swr";
|
||||
|
||||
import {
|
||||
|
|
@ -63,3 +64,18 @@ export function subscribeToLiveEvents(
|
|||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React with the shared live-events SSE stream. The subscription is
|
||||
* closed before resubscribe and on unmount; `onEvent` sees the latest render.
|
||||
*/
|
||||
export function useLiveEventsSubscription(
|
||||
onEvent: (payload: LiveEventPayload) => void,
|
||||
) {
|
||||
const onEventRef = useRef(onEvent);
|
||||
onEventRef.current = onEvent;
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToLiveEvents((payload) => onEventRef.current(payload));
|
||||
}, []);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,24 +20,8 @@ mock.module("swr/mutation", () => ({
|
|||
default: useSWRMutationMock,
|
||||
}));
|
||||
|
||||
mock.module("./api-client", () => ({
|
||||
apiData: mock(),
|
||||
authApi: {},
|
||||
humanInTheLoopApi: {},
|
||||
runsApi: {},
|
||||
}));
|
||||
|
||||
mock.module("./run-actions", () => ({
|
||||
approveRun: mock(),
|
||||
archiveRun: mock(),
|
||||
cancelRun: mock(),
|
||||
denyRun: mock(),
|
||||
isLifecycleActionError: () => false,
|
||||
retryRun: mock(),
|
||||
unarchiveRun: mock(),
|
||||
}));
|
||||
|
||||
const { useArchiveRun } = await import("./mutations");
|
||||
mock.restore();
|
||||
|
||||
beforeEach(() => {
|
||||
mutateMock.mockClear();
|
||||
|
|
|
|||
|
|
@ -256,6 +256,10 @@ function stageIdFromPayload(payload: RunEventPayload): string | undefined {
|
|||
return typeof nodeId === "string" ? nodeId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes React/SWR with a run-scoped SSE stream. Changing `runId`
|
||||
* resubscribes, and the active subscription is closed on unmount.
|
||||
*/
|
||||
export function useRunEvents(runId: string | undefined) {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useEffect, useReducer } from "react";
|
||||
import { useReducer } from "react";
|
||||
|
||||
import { useInterval } from "../hooks/effects";
|
||||
|
||||
/**
|
||||
* Re-renders the calling component every `intervalMs` milliseconds while
|
||||
|
|
@ -7,11 +9,7 @@ import { useEffect, useReducer } from "react";
|
|||
*/
|
||||
export function useTickingNow(active: boolean, intervalMs = 1000): number {
|
||||
const [now, tick] = useReducer(() => Date.now(), undefined, Date.now);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const interval = setInterval(tick, intervalMs);
|
||||
return () => clearInterval(interval);
|
||||
}, [active, intervalMs]);
|
||||
useInterval(tick, intervalMs, active);
|
||||
return now;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export default function AskFabro() {
|
|||
runId="demo"
|
||||
width={width}
|
||||
onWidthChange={setWidth}
|
||||
onResizeActiveChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,14 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import type { BundledLanguage } from "@pierre/diffs";
|
||||
import { registerDotLanguage } from "../data/register-dot-language";
|
||||
import { workflowData, type WorkflowEntry } from "./automation-detail";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import { useDotLanguageReady } from "../hooks/use-dot-language-ready";
|
||||
|
||||
export default function AutomationDefinition() {
|
||||
const { name } = useParams();
|
||||
const context = useOutletContext<{ workflow?: WorkflowEntry } | null>();
|
||||
const workflow = context?.workflow ?? workflowData[name ?? ""];
|
||||
const [dotReady, setDotReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
registerDotLanguage().then(() => {
|
||||
if (!cancelled) setDotReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const dotReady = useDotLanguageReady();
|
||||
|
||||
if (workflow == null) {
|
||||
return <p className="text-sm text-fg-muted">No settings found.</p>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
import { useRenderedVizDiagram } from "../hooks/use-rendered-viz-diagram";
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
|
|
@ -71,38 +72,20 @@ export default function AutomationDiagram() {
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
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];
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function render() {
|
||||
const { instance } = await import("@viz-js/viz");
|
||||
const viz = await instance();
|
||||
if (cancelled) return;
|
||||
|
||||
try {
|
||||
const svg = viz.renderSVGElement(buildDot(direction));
|
||||
stripGraphTitle(svg);
|
||||
|
||||
svgRef.current = svg;
|
||||
if (innerRef.current) {
|
||||
innerRef.current.replaceChildren(svg);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to render diagram");
|
||||
}
|
||||
}
|
||||
|
||||
setPan({ x: 0, y: 0 });
|
||||
render();
|
||||
return () => { cancelled = true; };
|
||||
}, [direction]);
|
||||
const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []);
|
||||
const error = useRenderedVizDiagram({
|
||||
buildDot,
|
||||
identity: direction,
|
||||
innerRef,
|
||||
onRenderStart: resetPan,
|
||||
prepareSvg: stripGraphTitle,
|
||||
svgRef,
|
||||
});
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
|
|
@ -15,6 +15,7 @@ import CustomComposer from "../components/chats/custom-composer";
|
|||
import ToolFallback from "../components/chats/tool-fallback";
|
||||
import { EmptyState } from "../components/state";
|
||||
import type { Chat, ChatMessage } from "../lib/chats-types";
|
||||
import { usePendingChatAutoresponse } from "../hooks/use-pending-chat-autoresponse";
|
||||
|
||||
// AppShell handle lives on the parent chats-layout route; do not redeclare it
|
||||
// here.
|
||||
|
|
@ -54,9 +55,7 @@ function ChatRuntime({ chatId, chat }: { chatId: string; chat: Chat }) {
|
|||
// Keep latest `chat` accessible to the stable adapter closure below without
|
||||
// recreating the adapter (and the assistant-ui runtime) on every store dispatch.
|
||||
const chatRef = useRef(chat);
|
||||
useEffect(() => {
|
||||
chatRef.current = chat;
|
||||
});
|
||||
chatRef.current = chat;
|
||||
|
||||
const initialMessages = useMemo(
|
||||
() => toThreadMessages(chat.seedMessages),
|
||||
|
|
@ -74,19 +73,12 @@ function ChatRuntime({ chatId, chat }: { chatId: string; chat: Chat }) {
|
|||
|
||||
const runtime = useLocalRuntime(adapter, { initialMessages });
|
||||
|
||||
// Autorespond: chats arriving here from /chats/new carry the user's first
|
||||
// message in seedMessages with pendingResponse=true. Trigger one startRun
|
||||
// once per mount; the ref dedupes within a StrictMode mount cycle (state
|
||||
// updates from consumePendingResponse aren't visible to the re-fired effect
|
||||
// closure), and the store flag dedupes across mounts (e.g. navigating away
|
||||
// and back to the same chat).
|
||||
const didStartRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!chat.pendingResponse || didStartRef.current) return;
|
||||
didStartRef.current = true;
|
||||
consumePendingResponse(chatId);
|
||||
runtime.thread.startRun({ parentId: null });
|
||||
}, [chat.pendingResponse, chatId, consumePendingResponse, runtime]);
|
||||
usePendingChatAutoresponse({
|
||||
chatId,
|
||||
pendingResponse: chat.pendingResponse,
|
||||
consumePendingResponse,
|
||||
startRun: () => runtime.thread.startRun({ parentId: null }),
|
||||
});
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -16,84 +16,14 @@ import {
|
|||
PencilIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { formatBytes } from "../lib/format";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface QueryResult {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string | number>>;
|
||||
elapsed: number;
|
||||
rowsRead: number;
|
||||
bytesRead: number;
|
||||
rowsReturned: number;
|
||||
}
|
||||
import { useResizeObserver } from "../hooks/effects";
|
||||
import {
|
||||
type QueryResult,
|
||||
useInsightsQueryRunner,
|
||||
} from "../hooks/use-insights-query-runner";
|
||||
|
||||
type ResultView = "chart" | "table";
|
||||
|
||||
// ── Mock data ──
|
||||
|
||||
function generateMockResult(sql: string): QueryResult {
|
||||
const lowerSql = sql.toLowerCase();
|
||||
|
||||
if (lowerSql.includes("workflow_name") && lowerSql.includes("avg")) {
|
||||
return {
|
||||
columns: ["workflow_name", "avg_duration", "run_count"],
|
||||
rows: [
|
||||
{ workflow_name: "Expand Product", avg_duration: 342.5, run_count: 48 },
|
||||
{ workflow_name: "Implement Feature", avg_duration: 287.3, run_count: 156 },
|
||||
{ workflow_name: "Security Scan", avg_duration: 198.1, run_count: 312 },
|
||||
{ workflow_name: "Fix Build", avg_duration: 145.7, run_count: 482 },
|
||||
{ workflow_name: "Sync Drift", avg_duration: 89.2, run_count: 94 },
|
||||
{ workflow_name: "Dependency Audit", avg_duration: 67.4, run_count: 201 },
|
||||
],
|
||||
elapsed: 0.531,
|
||||
rowsRead: 5182366,
|
||||
bytesRead: 357780000,
|
||||
rowsReturned: 6,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerSql.includes("failure_rate") || lowerSql.includes("failed")) {
|
||||
return {
|
||||
columns: ["day", "failures", "total", "failure_rate"],
|
||||
rows: Array.from({ length: 14 }, (_, i) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const total = 80 + Math.floor(Math.random() * 60);
|
||||
const failures = Math.floor(Math.random() * 15);
|
||||
return {
|
||||
day: d.toISOString().slice(0, 10),
|
||||
failures,
|
||||
total,
|
||||
failure_rate: Math.round((1000 * failures) / total) / 10,
|
||||
};
|
||||
}),
|
||||
elapsed: 0.287,
|
||||
rowsRead: 2841092,
|
||||
bytesRead: 198400000,
|
||||
rowsReturned: 14,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
columns: ["repo", "runs", "total_additions", "total_deletions"],
|
||||
rows: [
|
||||
{ repo: "fabro-engine", runs: 482, total_additions: 28450, total_deletions: 12300 },
|
||||
{ repo: "fabro-web", runs: 356, total_additions: 19200, total_deletions: 8900 },
|
||||
{ repo: "fabro-cli", runs: 198, total_additions: 8700, total_deletions: 4200 },
|
||||
{ repo: "fabro-docs", runs: 145, total_additions: 12100, total_deletions: 3400 },
|
||||
{ repo: "fabro-sdk", runs: 89, total_additions: 5600, total_deletions: 2100 },
|
||||
{ repo: "fabro-infra", runs: 67, total_additions: 3200, total_deletions: 1800 },
|
||||
{ repo: "fabro-actions", runs: 42, total_additions: 2100, total_deletions: 980 },
|
||||
{ repo: "fabro-proto", runs: 28, total_additions: 1400, total_deletions: 650 },
|
||||
],
|
||||
elapsed: 0.148,
|
||||
rowsRead: 1204588,
|
||||
bytesRead: 89200000,
|
||||
rowsReturned: 8,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Formatting helpers ──
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
|
|
@ -113,20 +43,12 @@ function BarChart({ result }: { result: QueryResult }) {
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
// react-doctor-disable-next-line react-doctor/no-initialize-state -- ResizeObserver is the first reliable source for this rendered container's width.
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useResizeObserver(containerRef, (entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
const labelCol = result.columns[0];
|
||||
const valueCols = result.columns.slice(1).filter((col) => {
|
||||
|
|
@ -382,46 +304,13 @@ export default function InsightsEditor() {
|
|||
const initialQueryName = navState?.name ?? "Run duration by workflow";
|
||||
|
||||
const [sql, setSql] = useState(() => initialSql);
|
||||
const [result, setResult] = useState<QueryResult | null>(() =>
|
||||
generateMockResult(initialSql),
|
||||
);
|
||||
const { result, isRunning, runQuery } = useInsightsQueryRunner(initialSql);
|
||||
const [resultView, setResultView] = useState<ResultView>("chart");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [queryName, setQueryName] = useState(() => initialQueryName);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showAiDialog, setShowAiDialog] = useState(false);
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
const runRequestIdRef = useRef(0);
|
||||
const runTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const runQuery = useCallback(() => {
|
||||
const requestId = runRequestIdRef.current + 1;
|
||||
runRequestIdRef.current = requestId;
|
||||
if (runTimeoutRef.current !== null) {
|
||||
clearTimeout(runTimeoutRef.current);
|
||||
}
|
||||
setIsRunning(true);
|
||||
const delay = 200 + Math.random() * 400;
|
||||
runTimeoutRef.current = setTimeout(() => {
|
||||
if (runRequestIdRef.current !== requestId) return;
|
||||
runTimeoutRef.current = null;
|
||||
setResult(generateMockResult(sql));
|
||||
setIsRunning(false);
|
||||
}, delay);
|
||||
}, [sql]);
|
||||
|
||||
useEffect(() => {
|
||||
const runRequestIds = runRequestIdRef;
|
||||
const runTimeouts = runTimeoutRef;
|
||||
return () => {
|
||||
runRequestIds.current += 1;
|
||||
if (runTimeouts.current !== null) {
|
||||
clearTimeout(runTimeouts.current);
|
||||
runTimeouts.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -490,7 +379,7 @@ export default function InsightsEditor() {
|
|||
{/* Run */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runQuery}
|
||||
onClick={() => runQuery(sql)}
|
||||
disabled={isRunning || sql.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-mint/20 bg-mint/5 px-3.5 py-1.5 text-sm font-medium text-mint transition-all hover:border-mint/50 hover:bg-mint/10 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-mint/20 disabled:hover:bg-mint/5 disabled:hover:text-mint"
|
||||
>
|
||||
|
|
@ -506,7 +395,7 @@ export default function InsightsEditor() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<SqlEditor value={sql} onChange={setSql} onRun={runQuery} />
|
||||
<SqlEditor value={sql} onChange={setSql} onRun={() => runQuery(sql)} />
|
||||
</div>
|
||||
|
||||
{/* ── Results bar + content ── */}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Navigate } from "react-router";
|
||||
import { ApiError } from "../lib/api-client";
|
||||
import { useAuthMe } from "../lib/queries";
|
||||
|
||||
export default function RedirectHome() {
|
||||
const navigate = useNavigate();
|
||||
const { data, error } = useAuthMe();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
navigate("/runs", { replace: true });
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
return <Navigate to="/runs" replace />;
|
||||
}
|
||||
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [data, error, navigate]);
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline";
|
||||
import type { RunArtifactEntry } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/state";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { stageArtifactDownloadUrl } from "../lib/api-client";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { useRunArtifacts, useRunStages } from "../lib/queries";
|
||||
import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
|
||||
|
||||
|
|
@ -178,22 +178,12 @@ function StageGroupCard({ runId, group }: { runId: string; group: StageGroup })
|
|||
}
|
||||
|
||||
function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) {
|
||||
const [href, setHref] = useState<string>("#");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void stageArtifactDownloadUrl(
|
||||
runId,
|
||||
entry.stage_id,
|
||||
entry.relative_path,
|
||||
entry.retry,
|
||||
).then((url) => {
|
||||
if (active) setHref(url);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [entry.relative_path, entry.retry, entry.stage_id, runId]);
|
||||
const href = stageArtifactDownloadUrl(
|
||||
runId,
|
||||
entry.stage_id,
|
||||
entry.relative_path,
|
||||
entry.retry,
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-4 px-4 py-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Navigate, useParams, useSearchParams } from "react-router";
|
||||
import { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import type { ListRunsSortEnum } from "@qltysh/fabro-api-client";
|
||||
|
||||
|
|
@ -24,6 +24,8 @@ import { SECONDARY_BUTTON_CLASS } from "../components/ui";
|
|||
import { ApiError } from "../lib/api-client";
|
||||
import { formatRelativeTime } from "../lib/format";
|
||||
import { useRun, useRunsPage } from "../lib/queries";
|
||||
import { useTickingNow } from "../lib/time";
|
||||
import { useDataUpdatedAt } from "../hooks/use-data-updated-at";
|
||||
|
||||
export const handle = { wide: true, hideSteerBar: true };
|
||||
|
||||
|
|
@ -36,6 +38,8 @@ export default function RunChildren() {
|
|||
() => resolveChildRunsListSearchParams(urlSearchParams),
|
||||
[urlSearchParams],
|
||||
);
|
||||
const hydratedSearch =
|
||||
searchParams === urlSearchParams ? null : `?${searchParams.toString()}`;
|
||||
|
||||
const query = searchParams.get("search") ?? "";
|
||||
const sort = parseSort(searchParams.get("sort"));
|
||||
|
|
@ -86,14 +90,6 @@ export default function RunChildren() {
|
|||
[updatePreferences],
|
||||
);
|
||||
|
||||
const hydratedFromStorage = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hydratedFromStorage.current) return;
|
||||
hydratedFromStorage.current = true;
|
||||
if (searchParams === urlSearchParams) return;
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}, [searchParams, urlSearchParams, setSearchParams]);
|
||||
|
||||
const childRunsQuery = useRunsPage(
|
||||
{
|
||||
parentId: id,
|
||||
|
|
@ -106,121 +102,122 @@ export default function RunChildren() {
|
|||
id != null,
|
||||
);
|
||||
|
||||
const lastFetchedAtRef = useRef<number | null>(null);
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (childRunsQuery.data) {
|
||||
lastFetchedAtRef.current = Date.now();
|
||||
setNow(Date.now());
|
||||
}
|
||||
}, [childRunsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => setNow(Date.now()), 15_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
const now = useTickingNow(true, 15_000);
|
||||
const updatedAt = useDataUpdatedAt(childRunsQuery.data);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void childRunsQuery.mutate();
|
||||
void runQuery.mutate();
|
||||
}, [childRunsQuery, runQuery]);
|
||||
const searchHydration = hydratedSearch
|
||||
? <Navigate to={{ search: hydratedSearch }} replace />
|
||||
: null;
|
||||
|
||||
if (childRunsQuery.isLoading && !childRunsQuery.data) {
|
||||
return <LoadingState label="Loading child runs…" />;
|
||||
return (
|
||||
<>
|
||||
{searchHydration}
|
||||
<LoadingState label="Loading child runs…" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const apiError =
|
||||
childRunsQuery.error instanceof ApiError ? childRunsQuery.error : null;
|
||||
if (apiError && !childRunsQuery.data) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="Couldn't load child runs"
|
||||
description={`Server returned ${apiError.status}.`}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
<>
|
||||
{searchHydration}
|
||||
<ErrorState
|
||||
title="Couldn't load child runs"
|
||||
description={`Server returned ${apiError.status}.`}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const updatedAt = lastFetchedAtRef.current;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-64">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="Search child runs"
|
||||
placeholder="Search child runs…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{updatedAt != null ? (
|
||||
<span className="font-mono text-xs text-fg-muted">
|
||||
Updated{" "}
|
||||
{formatRelativeTime(new Date(updatedAt).toISOString(), now)}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={childRunsQuery.isValidating}
|
||||
aria-label={
|
||||
childRunsQuery.isValidating
|
||||
? "Refreshing child runs"
|
||||
: "Refresh child runs"
|
||||
}
|
||||
title="Refresh"
|
||||
className="inline-flex size-9 items-center justify-center rounded-md border border-line bg-panel/80 text-fg-3 transition-colors hover:bg-panel hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel/80 disabled:hover:text-fg-3"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={`size-4 ${childRunsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
|
||||
aria-hidden="true"
|
||||
<>
|
||||
{searchHydration}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-64">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="Search child runs"
|
||||
placeholder="Search child runs…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
/>
|
||||
</button>
|
||||
<ColumnPickerButton hidden={hiddenColumns} onChange={setHiddenColumns} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RunsListView
|
||||
data={childRunsQuery.data ?? undefined}
|
||||
isLoading={childRunsQuery.data == null && childRunsQuery.isLoading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No child runs"
|
||||
description="When you launch another run with this run as its parent, it will appear here."
|
||||
action={
|
||||
<a
|
||||
href="https://docs.fabro.sh/execution/child-runs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Learn about child runs
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
}
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onSortClick={handleSortClick}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
query={lowerQuery}
|
||||
repoFilter="all"
|
||||
workflowFilter="all"
|
||||
createdCutoffMs={null}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{updatedAt != null ? (
|
||||
<span className="font-mono text-xs text-fg-muted">
|
||||
Updated{" "}
|
||||
{formatRelativeTime(new Date(updatedAt).toISOString(), now)}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={childRunsQuery.isValidating}
|
||||
aria-label={
|
||||
childRunsQuery.isValidating
|
||||
? "Refreshing child runs"
|
||||
: "Refresh child runs"
|
||||
}
|
||||
title="Refresh"
|
||||
className="inline-flex size-9 items-center justify-center rounded-md border border-line bg-panel/80 text-fg-3 transition-colors hover:bg-panel hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel/80 disabled:hover:text-fg-3"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={`size-4 ${childRunsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<ColumnPickerButton hidden={hiddenColumns} onChange={setHiddenColumns} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RunsListView
|
||||
data={childRunsQuery.data ?? undefined}
|
||||
isLoading={childRunsQuery.data == null && childRunsQuery.isLoading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No child runs"
|
||||
description="When you launch another run with this run as its parent, it will appear here."
|
||||
action={
|
||||
<a
|
||||
href="https://docs.fabro.sh/execution/child-runs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Learn about child runs
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
}
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onSortClick={handleSortClick}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
query={lowerQuery}
|
||||
repoFilter="all"
|
||||
workflowFilter="all"
|
||||
createdCutoffMs={null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
usePreviewRun,
|
||||
useRetryRun,
|
||||
useUnarchiveRun,
|
||||
type LifecycleMutationResult,
|
||||
} from "../lib/mutations";
|
||||
import { useRunEvents } from "../lib/run-events";
|
||||
import { useRunToasts } from "../hooks/use-run-toasts";
|
||||
|
|
@ -36,6 +38,7 @@ import {
|
|||
canRetry,
|
||||
deleteErrorMessage,
|
||||
deleteRun,
|
||||
type LifecycleAction,
|
||||
} from "../lib/run-actions";
|
||||
import {
|
||||
type ActionGroups,
|
||||
|
|
@ -47,8 +50,9 @@ import {
|
|||
} from "./run-detail/docked-controls";
|
||||
import { RunDetailHeader } from "./run-detail/header";
|
||||
import {
|
||||
createLifecycleToastState,
|
||||
lifecycleActionVisibility,
|
||||
useLifecycleToastResults,
|
||||
updateLifecycleToastState,
|
||||
} from "./run-detail/lifecycle-toasts";
|
||||
import {
|
||||
buildRunDetailRun,
|
||||
|
|
@ -63,6 +67,8 @@ import {
|
|||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
type LifecycleTrigger = () => Promise<LifecycleMutationResult | undefined>;
|
||||
|
||||
export function meta({ data }: any) {
|
||||
const run = data?.run;
|
||||
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
|
||||
|
|
@ -95,6 +101,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deletePending, setDeletePending] = useState(false);
|
||||
const { push, dismiss } = useToast();
|
||||
const lifecycleToastStateRef = useRef(createLifecycleToastState());
|
||||
const filesCount = runQuery.data?.diff?.files_changed ?? null;
|
||||
const childrenCount = runQuery.data?.children_count ?? null;
|
||||
const hasSandbox = runHasSandbox(runStateQuery.data);
|
||||
|
|
@ -110,17 +117,27 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
useRunEvents(params.id);
|
||||
useRunToasts(params.id);
|
||||
|
||||
useLifecycleToastResults(
|
||||
{
|
||||
cancel: cancelMutation.data,
|
||||
approve: approveMutation.data,
|
||||
deny: denyMutation.data,
|
||||
archive: archiveMutation.data,
|
||||
unarchive: unarchiveMutation.data,
|
||||
retry: retryMutation.data,
|
||||
const handleLifecycleMutationResult = useCallback(
|
||||
(
|
||||
intent: LifecycleAction,
|
||||
result: LifecycleMutationResult | undefined,
|
||||
) => {
|
||||
updateLifecycleToastState(
|
||||
intent,
|
||||
result,
|
||||
lifecycleToastStateRef,
|
||||
{ push, dismiss },
|
||||
intent === "retry" ? navigate : undefined,
|
||||
);
|
||||
},
|
||||
{ push, dismiss },
|
||||
navigate,
|
||||
[dismiss, navigate, push],
|
||||
);
|
||||
const triggerLifecycleAction = useCallback(
|
||||
async (intent: LifecycleAction, trigger: LifecycleTrigger) => {
|
||||
const result = await trigger();
|
||||
handleLifecycleMutationResult(intent, result);
|
||||
},
|
||||
[handleLifecycleMutationResult],
|
||||
);
|
||||
|
||||
if (runQuery.isLoading && !run) {
|
||||
|
|
@ -198,9 +215,9 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
key: "interrupt",
|
||||
label: "Send interrupt",
|
||||
pendingLabel: "Interrupting…",
|
||||
pending: interruptMutation.isMutating,
|
||||
disabled: statusKind !== "running",
|
||||
onSelect: () => void interruptMutation.trigger(),
|
||||
pending: interruptMutation.isMutating,
|
||||
disabled: statusKind !== "running",
|
||||
onSelect: () => void interruptMutation.trigger(),
|
||||
},
|
||||
{
|
||||
key: "steer",
|
||||
|
|
@ -218,7 +235,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
label: "Retry",
|
||||
pendingLabel: "Retrying…",
|
||||
pending: retryPending,
|
||||
onSelect: () => void retryMutation.trigger(),
|
||||
onSelect: () => void triggerLifecycleAction("retry", retryMutation.trigger),
|
||||
}]
|
||||
: []),
|
||||
...(visibility.showArchive
|
||||
|
|
@ -227,7 +244,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
label: "Archive",
|
||||
pendingLabel: "Archiving…",
|
||||
pending: archivePending,
|
||||
onSelect: () => void archiveMutation.trigger(),
|
||||
onSelect: () => void triggerLifecycleAction("archive", archiveMutation.trigger),
|
||||
}]
|
||||
: []),
|
||||
...(visibility.showUnarchive
|
||||
|
|
@ -236,7 +253,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
label: "Unarchive",
|
||||
pendingLabel: "Restoring…",
|
||||
pending: unarchivePending,
|
||||
onSelect: () => void unarchiveMutation.trigger(),
|
||||
onSelect: () => void triggerLifecycleAction("unarchive", unarchiveMutation.trigger),
|
||||
}]
|
||||
: []),
|
||||
],
|
||||
|
|
@ -247,7 +264,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
label: "Deny",
|
||||
pendingLabel: "Denying…",
|
||||
pending: denyPending,
|
||||
onSelect: () => void denyMutation.trigger(),
|
||||
onSelect: () => void triggerLifecycleAction("deny", denyMutation.trigger),
|
||||
}]
|
||||
: []),
|
||||
...(visibility.showPrimaryCancel
|
||||
|
|
@ -256,7 +273,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
label: "Cancel",
|
||||
pendingLabel: "Cancelling…",
|
||||
pending: cancelPending,
|
||||
onSelect: () => void cancelMutation.trigger(),
|
||||
onSelect: () => void triggerLifecycleAction("cancel", cancelMutation.trigger),
|
||||
}]
|
||||
: []),
|
||||
...(visibility.showDelete
|
||||
|
|
@ -292,7 +309,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
approval: {
|
||||
visible: approvalActionVisible,
|
||||
pending: approvePending,
|
||||
onApprove: () => void approveMutation.trigger(),
|
||||
onApprove: () => void triggerLifecycleAction("approve", approveMutation.trigger),
|
||||
},
|
||||
menu: {
|
||||
runId: params.id,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
|
|
@ -20,7 +19,6 @@ import {
|
|||
type ApiQuestion,
|
||||
type AskFabro,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
import { useAskFabroLayout } from "../../lib/ask-fabro-layout";
|
||||
import { classNames } from "./model";
|
||||
|
||||
const ASK_FABRO_UNAVAILABLE_TOOLTIPS: Record<
|
||||
|
|
@ -51,16 +49,19 @@ export function RunDetailAskFabroShell({
|
|||
const askDefaultModel = askFabro?.default_model ?? null;
|
||||
const [askOpen, setAskOpen] = useState(false);
|
||||
const [askWidth, setAskWidth] = useState(SIDEBAR_WIDTH);
|
||||
const [resizeActive, setResizeActive] = useState(false);
|
||||
const sidebarWidth = askAvailable && askOpen ? askWidth : 0;
|
||||
const { setSidebarWidth, isResizing } = useAskFabroLayout();
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarWidth(sidebarWidth);
|
||||
return () => setSidebarWidth(0);
|
||||
}, [sidebarWidth, setSidebarWidth]);
|
||||
const isResizing = askAvailable && resizeActive;
|
||||
const shellLayoutStyle = `:root {
|
||||
--fabro-ask-sidebar-width: ${sidebarWidth}px;
|
||||
--fabro-ask-sidebar-transition: ${
|
||||
isResizing ? "none" : "padding 300ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
};
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{shellLayoutStyle}</style>
|
||||
{children({
|
||||
askTrigger: (
|
||||
<AskFabroTriggerButton
|
||||
|
|
@ -82,6 +83,7 @@ export function RunDetailAskFabroShell({
|
|||
defaultModel={askDefaultModel}
|
||||
width={askWidth}
|
||||
onWidthChange={setAskWidth}
|
||||
onResizeActiveChange={setResizeActive}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { ToastInput } from "../../components/toast";
|
||||
import type {
|
||||
LifecycleMutationResult,
|
||||
|
|
@ -27,17 +25,35 @@ interface ToastApi {
|
|||
dismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const INITIAL_LIFECYCLE_TOAST_STATE: LifecycleToastState = {
|
||||
activeArchiveToastId: null,
|
||||
lastProcessed: {
|
||||
cancel: null,
|
||||
approve: null,
|
||||
deny: null,
|
||||
archive: null,
|
||||
unarchive: null,
|
||||
retry: null,
|
||||
},
|
||||
};
|
||||
export function createLifecycleToastState(): LifecycleToastState {
|
||||
return {
|
||||
activeArchiveToastId: null,
|
||||
lastProcessed: {
|
||||
cancel: null,
|
||||
approve: null,
|
||||
deny: null,
|
||||
archive: null,
|
||||
unarchive: null,
|
||||
retry: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function updateLifecycleToastState(
|
||||
intent: LifecycleAction,
|
||||
result: RunDetailActionResult | undefined,
|
||||
stateRef: { current: LifecycleToastState },
|
||||
toastApi: ToastApi,
|
||||
navigate?: (path: string) => void,
|
||||
) {
|
||||
stateRef.current = handleLifecycleToastResult(
|
||||
intent,
|
||||
result,
|
||||
stateRef.current,
|
||||
toastApi,
|
||||
navigate,
|
||||
);
|
||||
}
|
||||
|
||||
export function lifecycleActionVisibility(status: string | null | undefined) {
|
||||
return {
|
||||
|
|
@ -111,71 +127,3 @@ export function handleLifecycleToastResult(
|
|||
toastApi.push({ message: "Run restored." });
|
||||
return { ...nextState, activeArchiveToastId: null };
|
||||
}
|
||||
|
||||
function useLifecycleToastResult(
|
||||
intent: LifecycleAction,
|
||||
result: RunDetailActionResult | undefined,
|
||||
stateRef: { current: LifecycleToastState },
|
||||
toastApi: ToastApi,
|
||||
navigate?: (path: string) => void,
|
||||
) {
|
||||
const { dismiss, push } = toastApi;
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = handleLifecycleToastResult(
|
||||
intent,
|
||||
result,
|
||||
stateRef.current,
|
||||
{ dismiss, push },
|
||||
navigate,
|
||||
);
|
||||
}, [dismiss, intent, navigate, push, result, stateRef]);
|
||||
}
|
||||
|
||||
export function useLifecycleToastResults(
|
||||
results: Record<LifecycleAction, RunDetailActionResult | undefined>,
|
||||
toastApi: ToastApi,
|
||||
navigate: (path: string) => void,
|
||||
) {
|
||||
const lifecycleToastStateRef = useRef<LifecycleToastState>(
|
||||
INITIAL_LIFECYCLE_TOAST_STATE,
|
||||
);
|
||||
|
||||
useLifecycleToastResult(
|
||||
"cancel",
|
||||
results.cancel,
|
||||
lifecycleToastStateRef,
|
||||
toastApi,
|
||||
);
|
||||
useLifecycleToastResult(
|
||||
"archive",
|
||||
results.archive,
|
||||
lifecycleToastStateRef,
|
||||
toastApi,
|
||||
);
|
||||
useLifecycleToastResult(
|
||||
"approve",
|
||||
results.approve,
|
||||
lifecycleToastStateRef,
|
||||
toastApi,
|
||||
);
|
||||
useLifecycleToastResult(
|
||||
"deny",
|
||||
results.deny,
|
||||
lifecycleToastStateRef,
|
||||
toastApi,
|
||||
);
|
||||
useLifecycleToastResult(
|
||||
"unarchive",
|
||||
results.unarchive,
|
||||
lifecycleToastStateRef,
|
||||
toastApi,
|
||||
);
|
||||
useLifecycleToastResult(
|
||||
"retry",
|
||||
results.retry,
|
||||
lifecycleToastStateRef,
|
||||
toastApi,
|
||||
navigate,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useInterval } from "../../hooks/effects";
|
||||
import {
|
||||
isRunStatus,
|
||||
mapRunToRunItem,
|
||||
|
|
@ -13,10 +14,7 @@ export function classNames(...classes: Array<string | false | null | undefined>)
|
|||
|
||||
export function useTickingNow(intervalMs: number): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
useInterval(() => setNow(Date.now()), intervalMs);
|
||||
return now;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
|
|||
import { useRef } from "react";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { toast as sonnerToast } from "sonner";
|
||||
|
||||
import { ToastProvider } from "../components/toast";
|
||||
|
||||
|
|
@ -177,6 +178,22 @@ function renderRunFiles(initialEntry = "/runs/run_1/files") {
|
|||
return renderer!;
|
||||
}
|
||||
|
||||
function treeText(
|
||||
node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>,
|
||||
): string {
|
||||
if (!node) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(treeText).join("");
|
||||
return (node.children ?? []).map(treeText).join("");
|
||||
}
|
||||
|
||||
async function flushAsyncUpdates() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("RunFiles rendering", () => {
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
|
|
@ -193,6 +210,7 @@ describe("RunFiles rendering", () => {
|
|||
virtualizerCalls.length = 0;
|
||||
providerCalls.length = 0;
|
||||
useRunFilesCalls.length = 0;
|
||||
sonnerToast.dismiss();
|
||||
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
});
|
||||
|
||||
|
|
@ -306,4 +324,17 @@ describe("RunFiles rendering", () => {
|
|||
expect(lastCall.patch).toContain("+uncommitted");
|
||||
expect(lastCall.mountId).not.toBe(firstMountId);
|
||||
});
|
||||
|
||||
test("refreshing from a populated diff to an empty diff shows a no-changes toast", async () => {
|
||||
currentFilesPayload = makePayload(1);
|
||||
const renderer = renderRunFiles("/runs/run_1/files?scope=all");
|
||||
|
||||
currentFilesPayload = makePayload(0);
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ "aria-label": "Refresh files" }).props.onClick();
|
||||
});
|
||||
await flushAsyncUpdates();
|
||||
|
||||
expect(treeText(renderer.toJSON())).toContain("No changes in this run.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
memo,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
|
|
@ -43,6 +42,10 @@ import {
|
|||
import { fileCacheKey, stringHash } from "./run-files/cache-keys";
|
||||
import { buildRunCommitOptions } from "./run-files/commit-options";
|
||||
import { VirtualizedDiffList } from "./run-files/virtualized-diff-list";
|
||||
import { useLocationHash, useMediaQuery } from "../hooks/effects";
|
||||
import { useFocusAfterRefreshCompletes } from "../hooks/use-focus-after-refresh";
|
||||
import { useMinimumRefreshSpinner } from "../hooks/use-minimum-refresh-spinner";
|
||||
import { useRunFileDeepLinkFocus } from "../hooks/use-run-file-deep-link";
|
||||
import { ApiError, extractRequestId } from "../lib/api-client";
|
||||
import { useRun, useRunCommits, useRunFiles } from "../lib/queries";
|
||||
import {
|
||||
|
|
@ -50,6 +53,7 @@ import {
|
|||
type RunFileScope,
|
||||
type RunFileSelection,
|
||||
} from "../lib/query-keys";
|
||||
import { useTickingNow } from "../lib/time";
|
||||
|
||||
export { extractRequestId };
|
||||
|
||||
|
|
@ -78,18 +82,7 @@ export function normalizeRunFileScope(value: string | null): RunFileScope {
|
|||
}
|
||||
|
||||
function useNarrowViewport(): boolean {
|
||||
const [narrow, setNarrow] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`).matches;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mql = window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`);
|
||||
const apply = () => setNarrow(mql.matches);
|
||||
mql.addEventListener("change", apply);
|
||||
return () => mql.removeEventListener("change", apply);
|
||||
}, []);
|
||||
return narrow;
|
||||
return useMediaQuery(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`);
|
||||
}
|
||||
|
||||
function useFreshness(
|
||||
|
|
@ -101,15 +94,9 @@ function useFreshness(
|
|||
// would show nothing.
|
||||
const hasLabel =
|
||||
!!meta && (!!meta.to_sha_committed_at || lastFetchedAt !== null);
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!hasLabel) return undefined;
|
||||
const id = setInterval(() => setTick((t) => t + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [hasLabel]);
|
||||
const now = useTickingNow(hasLabel, 10_000);
|
||||
|
||||
if (!meta) return null;
|
||||
const now = Date.now();
|
||||
const captured = meta.to_sha_committed_at
|
||||
? `Captured ${formatRelative(meta.to_sha_committed_at, now)}`
|
||||
: null;
|
||||
|
|
@ -464,6 +451,9 @@ export default function RunFiles() {
|
|||
toSha: selectedCommit.toSha,
|
||||
}
|
||||
: runFileScopeSelection(selectedScope);
|
||||
const effectiveScope = fileSelection.kind === "commit"
|
||||
? `commit:${fileSelection.toSha}`
|
||||
: fileSelection.scope;
|
||||
const filesQuery = useRunFiles(
|
||||
waitingForCommitSelection ? undefined : params.id,
|
||||
fileSelection,
|
||||
|
|
@ -472,27 +462,14 @@ export default function RunFiles() {
|
|||
const { push } = useToast();
|
||||
const narrow = useNarrowViewport();
|
||||
const runStatus = runQuery.data?.lifecycle.status.kind;
|
||||
|
||||
// Preserve the last successful payload so a failed revalidation can keep
|
||||
// rendering the previous files while surfacing an inline banner.
|
||||
const lastGoodDataRef = useRef<PaginatedRunFileList | null>(null);
|
||||
const lastFetchedAtRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filesQuery.data) return;
|
||||
const message = emptyTransitionToastMessage(
|
||||
lastGoodDataRef.current?.data.length ?? null,
|
||||
filesQuery.data.data.length,
|
||||
);
|
||||
if (message) {
|
||||
push({ message });
|
||||
}
|
||||
lastGoodDataRef.current = filesQuery.data;
|
||||
lastFetchedAtRef.current = Date.now();
|
||||
}, [push, filesQuery.data]);
|
||||
|
||||
const data: PaginatedRunFileList | null =
|
||||
filesQuery.data ?? lastGoodDataRef.current;
|
||||
// `useRunFiles` owns server-state retention with SWR `keepPreviousData`; when
|
||||
// a revalidation fails, SWR keeps the last successful payload in `data`.
|
||||
const data: PaginatedRunFileList | null = filesQuery.data ?? null;
|
||||
const dataFetchedAt = useMemo(() => data ? Date.now() : null, [data]);
|
||||
const [refreshConfirmation, setRefreshConfirmation] = useState<{
|
||||
scope: string;
|
||||
toSha: string;
|
||||
} | null>(null);
|
||||
|
||||
const isInitialLoading = (waitingForCommitSelection || filesQuery.isLoading) && !data;
|
||||
const isRevalidating = filesQuery.isValidating;
|
||||
|
|
@ -504,12 +481,12 @@ export default function RunFiles() {
|
|||
// on with no data).
|
||||
const apiError = filesQuery.error instanceof ApiError ? filesQuery.error : null;
|
||||
const revalidationError =
|
||||
apiError && lastGoodDataRef.current
|
||||
apiError && data
|
||||
? `Couldn't refresh (${apiError.status}).`
|
||||
: null;
|
||||
const initialError = apiError && !lastGoodDataRef.current ? apiError : null;
|
||||
const initialError = apiError && !data ? apiError : null;
|
||||
|
||||
const freshness = useFreshness(data?.meta ?? null, lastFetchedAtRef.current);
|
||||
const freshness = useFreshness(data?.meta ?? null, dataFetchedAt);
|
||||
|
||||
// Persisted desktop preference + md-breakpoint forced unified.
|
||||
const [persistedStyle, setPersistedStyle] = useState<DiffStyle>(
|
||||
|
|
@ -528,25 +505,33 @@ export default function RunFiles() {
|
|||
|
||||
const refreshButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastDeepLinkToastRef = useRef<string | null>(null);
|
||||
|
||||
const minRefreshTimerRef = useRef<number | null>(null);
|
||||
const [minRefreshActive, setMinRefreshActive] = useState(false);
|
||||
const clearMinRefreshTimer = useCallback(() => {
|
||||
if (minRefreshTimerRef.current !== null) {
|
||||
window.clearTimeout(minRefreshTimerRef.current);
|
||||
minRefreshTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const {
|
||||
active: minRefreshActive,
|
||||
start: startMinRefresh,
|
||||
} = useMinimumRefreshSpinner(MIN_REFRESH_SPIN_MS);
|
||||
const handleRefresh = useCallback(() => {
|
||||
clearMinRefreshTimer();
|
||||
setMinRefreshActive(true);
|
||||
minRefreshTimerRef.current = window.setTimeout(() => {
|
||||
setMinRefreshActive(false);
|
||||
minRefreshTimerRef.current = null;
|
||||
}, MIN_REFRESH_SPIN_MS);
|
||||
void filesQuery.mutate();
|
||||
}, [clearMinRefreshTimer, filesQuery]);
|
||||
const previousFileCount = data?.data.length ?? null;
|
||||
const previousToSha = data?.meta.to_sha ?? null;
|
||||
startMinRefresh();
|
||||
void filesQuery.mutate()
|
||||
.then((nextData) => {
|
||||
if (nextData) {
|
||||
const message = emptyTransitionToastMessage(
|
||||
previousFileCount,
|
||||
nextData.data.length,
|
||||
);
|
||||
if (message) push({ message });
|
||||
}
|
||||
|
||||
const nextToSha = nextData?.meta.to_sha ?? null;
|
||||
setRefreshConfirmation(
|
||||
previousToSha && nextToSha === previousToSha
|
||||
? { scope: effectiveScope, toSha: nextToSha }
|
||||
: null,
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [data, effectiveScope, filesQuery, push, startMinRefresh]);
|
||||
const handlePickerChange = useCallback(
|
||||
(selection: DiffPickerValue) => {
|
||||
const search = new URLSearchParams(routeLocation.search);
|
||||
|
|
@ -565,20 +550,12 @@ export default function RunFiles() {
|
|||
},
|
||||
[routeLocation.hash, routeLocation.pathname, routeLocation.search, navigate],
|
||||
);
|
||||
useEffect(() => clearMinRefreshTimer, [clearMinRefreshTimer]);
|
||||
// react-doctor-disable-next-line react-doctor/no-event-handler -- The refresh spinner is driven by both SWR revalidation and the click-owned minimum timer.
|
||||
const showRefreshing = isRevalidating || minRefreshActive;
|
||||
|
||||
// Return focus to the Refresh button after a refresh visibly completes so
|
||||
// keyboard-first users stay oriented.
|
||||
const refreshingPrev = useRef(false);
|
||||
useEffect(() => {
|
||||
// react-doctor-disable-next-line react-doctor/no-event-handler -- Returning focus after async refresh completion is an accessibility sync effect.
|
||||
if (refreshingPrev.current && !showRefreshing) {
|
||||
refreshButtonRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
refreshingPrev.current = showRefreshing;
|
||||
}, [showRefreshing]);
|
||||
useFocusAfterRefreshCompletes(showRefreshing, refreshButtonRef);
|
||||
|
||||
const fileCount = data?.data.length ?? 0;
|
||||
useFileKeyboardNav(containerRef, fileCount);
|
||||
|
|
@ -588,37 +565,19 @@ export default function RunFiles() {
|
|||
// via per-file options on `RunFileRow` — @pierre/diffs 1.1.x
|
||||
// exposes no imperative expand API, so click-based "expand" is not
|
||||
// available.
|
||||
const [hashFile, setHashFile] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return decodeDeepLinkFile(window.location.hash);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onHashChange = () =>
|
||||
setHashFile(decodeDeepLinkFile(window.location.hash));
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
const locationHash = useLocationHash();
|
||||
const hashFile = useMemo(
|
||||
() => decodeDeepLinkFile(locationHash),
|
||||
[locationHash],
|
||||
);
|
||||
|
||||
// react-doctor-disable-next-line react-doctor/no-event-handler -- Deep-link focus has to run after URL hash and file data have both rendered matching DOM rows.
|
||||
useEffect(() => {
|
||||
// react-doctor-disable-next-line react-doctor/no-event-handler -- Toasting missing deep links also depends on resolved file data.
|
||||
const toast = resolveDeepLinkToast(hashFile, data);
|
||||
if (toast) {
|
||||
if (lastDeepLinkToastRef.current !== toast.key) {
|
||||
push({ message: toast.message, autoDismissMs: 5000 });
|
||||
lastDeepLinkToastRef.current = toast.key;
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastDeepLinkToastRef.current = null;
|
||||
if (!hashFile || !data) return;
|
||||
const el = document.getElementById(fileRowId(hashFile));
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
el.focus({ preventScroll: true });
|
||||
}
|
||||
}, [data, hashFile, push]);
|
||||
useRunFileDeepLinkFocus({
|
||||
data,
|
||||
hashFile,
|
||||
rowId: fileRowId,
|
||||
resolveToast: resolveDeepLinkToast,
|
||||
push,
|
||||
});
|
||||
|
||||
const handleFileSelect = useCallback((path: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
|
@ -660,17 +619,12 @@ export default function RunFiles() {
|
|||
selectedCommit && selectedCommit.fromSha
|
||||
? { kind: "commit", sha: selectedCommit.sha }
|
||||
: { kind: "scope", scope: showScopePicker ? selectedScope : "committed" };
|
||||
const effectiveScope = fileSelection.kind === "commit"
|
||||
? `commit:${fileSelection.toSha}`
|
||||
: fileSelection.scope;
|
||||
|
||||
// Refresh is disabled when the server reports the same `to_sha` it
|
||||
// reported on the previous successful fetch — no new checkpoint yet.
|
||||
// `lastGoodDataRef.current` is updated in a useEffect, so during render
|
||||
// it still holds the previous render's data (or null on first load).
|
||||
const prevToSha = lastGoodDataRef.current?.meta?.to_sha ?? null;
|
||||
// Refresh is disabled only after a user-triggered refresh confirms that the
|
||||
// same selection still resolves to the same `to_sha` — no new checkpoint yet.
|
||||
const refreshDisabled =
|
||||
!!meta.to_sha && prevToSha !== null && prevToSha === meta.to_sha;
|
||||
!!meta.to_sha &&
|
||||
refreshConfirmation?.scope === effectiveScope &&
|
||||
refreshConfirmation.toSha === meta.to_sha;
|
||||
|
||||
const toolbar = (
|
||||
<Toolbar
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type CSSProperties,
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
} from "@pierre/trees";
|
||||
import pierreDark from "@pierre/theme/pierre-dark";
|
||||
import type { FileDiff } from "@qltysh/fabro-api-client";
|
||||
import { useChangedFilesTreeSync } from "../../hooks/use-changed-files-tree-sync";
|
||||
|
||||
type TreeThemeStyle = CSSProperties & Record<`--${string}`, string | number>;
|
||||
|
||||
|
|
@ -117,39 +117,19 @@ export function FileTreeSidebar({
|
|||
},
|
||||
});
|
||||
|
||||
const didSyncModelRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didSyncModelRef.current) {
|
||||
didSyncModelRef.current = true;
|
||||
return;
|
||||
}
|
||||
model.resetPaths(paths);
|
||||
model.setGitStatus(gitStatus);
|
||||
pendingSelectedPathRef.current = null;
|
||||
const currentSelectedPath = selectedPathRef.current;
|
||||
syncSelection(
|
||||
model,
|
||||
model.getSelectedPaths(),
|
||||
currentSelectedPath && changedPathsRef.current.has(currentSelectedPath)
|
||||
? currentSelectedPath
|
||||
: null,
|
||||
);
|
||||
}, [gitStatus, model, paths]);
|
||||
|
||||
const selection = useFileTreeSelection(model);
|
||||
useEffect(() => {
|
||||
const pendingSelectedPath = pendingSelectedPathRef.current;
|
||||
// react-doctor-disable-next-line react-doctor/no-event-handler -- This keeps Pierre's imperative tree model aligned after the tree emits a selection change.
|
||||
if (pendingSelectedPath === selectedPath) {
|
||||
pendingSelectedPathRef.current = null;
|
||||
}
|
||||
const nextSelectedPath = pendingSelectedPath ?? selectedPath;
|
||||
syncSelection(
|
||||
model,
|
||||
selection,
|
||||
nextSelectedPath && changedPaths.has(nextSelectedPath) ? nextSelectedPath : null,
|
||||
);
|
||||
}, [changedPaths, model, selectedPath, selection]);
|
||||
useChangedFilesTreeSync({
|
||||
changedPaths,
|
||||
changedPathsRef,
|
||||
gitStatus,
|
||||
model,
|
||||
paths,
|
||||
pendingSelectedPathRef,
|
||||
selectedPath,
|
||||
selectedPathRef,
|
||||
selection,
|
||||
syncSelection,
|
||||
});
|
||||
|
||||
const themeStyles = useMemo<TreeThemeStyle>(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useDocumentEvent } from "../../hooks/effects";
|
||||
|
||||
export function isEditableElement(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
|
|
@ -23,9 +24,9 @@ export function useFileKeyboardNav(
|
|||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
fileCount: number,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
useDocumentEvent(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key !== "j" && event.key !== "k") return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (isEditableElement(document.activeElement)) return;
|
||||
|
|
@ -50,10 +51,8 @@ export function useFileKeyboardNav(
|
|||
const target = rows[nextIdx];
|
||||
target.focus({ preventScroll: false });
|
||||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
// fileCount drives re-attachment so rows picked up after data changes
|
||||
// stay addressable without stale references.
|
||||
}, [containerRef, fileCount]);
|
||||
},
|
||||
undefined,
|
||||
fileCount > 0,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
import { ApiError } from "../lib/api-client";
|
||||
import { useRun, useRunGraph, useRunStages } from "../lib/queries";
|
||||
import { FloatingTooltip } from "../components/floating-tooltip";
|
||||
|
|
@ -14,19 +13,12 @@ import {
|
|||
import { GraphToolbar } from "../components/graph-toolbar";
|
||||
import { EmptyState, ErrorState } from "../components/state";
|
||||
import {
|
||||
ACTIVE_STAGE_STATES,
|
||||
SUCCEEDED_STAGE_STATES,
|
||||
aggregateGraphNodeStatus,
|
||||
mapRunStagesToSidebarStages,
|
||||
type Stage,
|
||||
} from "../lib/stage-sidebar";
|
||||
|
||||
const HOVER_OPEN_DELAY_MS = 200;
|
||||
|
||||
interface NodeHover {
|
||||
stage: Stage;
|
||||
rect: DOMRect;
|
||||
}
|
||||
import {
|
||||
useAnnotatedRunGraphSvg,
|
||||
type RunGraphNodeHover,
|
||||
} from "../hooks/use-annotated-run-graph-svg";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -64,153 +56,21 @@ export default function RunOverview() {
|
|||
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 [hoveredNode, setHoveredNode] = useState<NodeHover | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<RunGraphNodeHover | null>(null);
|
||||
|
||||
// Per-stage lookup keyed by latest visit's `stageId`, used when the SVG's
|
||||
// imperative hover handlers need to resolve a node to its sidebar Stage.
|
||||
const stageById = useMemo(() => {
|
||||
const map = new Map<string, Stage>();
|
||||
for (const stage of stages) map.set(stage.id, stage);
|
||||
return map;
|
||||
}, [stages]);
|
||||
|
||||
// Render SVG with stage annotations
|
||||
// react-doctor-disable-next-line react-doctor/no-cascading-set-state -- This effect mutates local Set/Map instances and the Graphviz SVG DOM; it does not call React state setters.
|
||||
useEffect(() => {
|
||||
const inner = innerRef.current;
|
||||
if (!inner || !graphSvg) return;
|
||||
|
||||
inner.innerHTML = graphSvg;
|
||||
const svg = inner.querySelector("svg");
|
||||
if (!svg) return;
|
||||
svgRef.current = svg;
|
||||
|
||||
const gt = graphTheme;
|
||||
const aggregated = aggregateGraphNodeStatus(stages);
|
||||
const runningDotIds = new Set<string>();
|
||||
const failedDotIds = new Set<string>();
|
||||
const completedDotIds = new Set<string>();
|
||||
const dotIdToStageId = new Map<string, string>();
|
||||
for (const [nodeId, { displayStatus, latestStageId }] of aggregated) {
|
||||
dotIdToStageId.set(nodeId, latestStageId);
|
||||
if (ACTIVE_STAGE_STATES.has(displayStatus)) {
|
||||
runningDotIds.add(nodeId);
|
||||
} else if (displayStatus === "failed") {
|
||||
failedDotIds.add(nodeId);
|
||||
} else if (SUCCEEDED_STAGE_STATES.has(displayStatus)) {
|
||||
completedDotIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearOpenTimer = () => {
|
||||
if (openTimer !== null) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = null;
|
||||
}
|
||||
};
|
||||
const listeners: Array<{ target: Element; type: string; listener: EventListener }> = [];
|
||||
const addListener = (target: Element, type: string, listener: EventListener) => {
|
||||
target.addEventListener(type, listener);
|
||||
listeners.push({ target, type, listener });
|
||||
};
|
||||
|
||||
for (const group of svg.querySelectorAll(".node")) {
|
||||
const nodeId = group.querySelector("title")?.textContent?.trim();
|
||||
if (!nodeId) continue;
|
||||
|
||||
const stageId = dotIdToStageId.get(nodeId);
|
||||
const stage = stageId ? stageById.get(stageId) : undefined;
|
||||
if (stageId) {
|
||||
(group as SVGElement).style.cursor = "pointer";
|
||||
addListener(group, "click", () => navigate(`/runs/${id}/stages/${stageId}`));
|
||||
}
|
||||
if (stage) {
|
||||
addListener(group, "mouseenter", () => {
|
||||
clearOpenTimer();
|
||||
const target = group as SVGGElement;
|
||||
openTimer = setTimeout(() => {
|
||||
openTimer = null;
|
||||
setHoveredNode({ stage, rect: target.getBoundingClientRect() });
|
||||
}, HOVER_OPEN_DELAY_MS);
|
||||
});
|
||||
addListener(group, "mouseleave", () => {
|
||||
clearOpenTimer();
|
||||
setHoveredNode(null);
|
||||
});
|
||||
}
|
||||
|
||||
// Color exit node based on run outcome
|
||||
if (nodeId === "exit" && terminalOutcome) {
|
||||
const isSuccess = terminalOutcome === "succeeded";
|
||||
const fill = isSuccess ? gt.completedFill : gt.failedFill;
|
||||
const border = isSuccess ? gt.completedBorder : gt.failedBorder;
|
||||
const text = isSuccess ? gt.completedText : gt.failedText;
|
||||
for (const shape of group.querySelectorAll("ellipse, polygon, path")) {
|
||||
shape.setAttribute("fill", fill);
|
||||
shape.setAttribute("stroke", border);
|
||||
}
|
||||
for (const t of group.querySelectorAll("text")) {
|
||||
t.setAttribute("fill", text);
|
||||
}
|
||||
} else if (runningDotIds.has(nodeId)) {
|
||||
for (const shape of group.querySelectorAll("ellipse, polygon, path")) {
|
||||
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);
|
||||
}
|
||||
for (const text of group.querySelectorAll("text")) {
|
||||
text.setAttribute("fill", gt.runningText);
|
||||
}
|
||||
} else if (failedDotIds.has(nodeId)) {
|
||||
for (const shape of group.querySelectorAll("ellipse, polygon, path")) {
|
||||
shape.setAttribute("fill", gt.failedFill);
|
||||
shape.setAttribute("stroke", gt.failedBorder);
|
||||
}
|
||||
for (const text of group.querySelectorAll("text")) {
|
||||
text.setAttribute("fill", gt.failedText);
|
||||
}
|
||||
} else if (completedDotIds.has(nodeId)) {
|
||||
for (const shape of group.querySelectorAll("ellipse, polygon, path")) {
|
||||
shape.setAttribute("fill", gt.completedFill);
|
||||
shape.setAttribute("stroke", gt.completedBorder);
|
||||
}
|
||||
for (const text of group.querySelectorAll("text")) {
|
||||
text.setAttribute("fill", gt.completedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearOpenTimer();
|
||||
for (const { target, type, listener } of listeners) {
|
||||
target.removeEventListener(type, listener);
|
||||
}
|
||||
setHoveredNode(null);
|
||||
};
|
||||
}, [stages, stageById, graphSvg, id, navigate, terminalOutcome]);
|
||||
const openStage = useCallback(
|
||||
(stageId: string) => navigate(`/runs/${id}/stages/${stageId}`),
|
||||
[id, navigate],
|
||||
);
|
||||
useAnnotatedRunGraphSvg({
|
||||
graphSvg,
|
||||
innerRef,
|
||||
onHoverChange: setHoveredNode,
|
||||
onStageClick: openStage,
|
||||
stages,
|
||||
svgRef,
|
||||
terminalOutcome,
|
||||
});
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
|
|
@ -32,6 +31,7 @@ import { EmptyState, ErrorState, LoadingState } from "../../components/state";
|
|||
import { SECONDARY_BUTTON_CLASS, Tooltip } from "../../components/ui";
|
||||
import { workerFactory } from "../../lib/pierre-diffs-worker";
|
||||
import { stringHash } from "../run-files/cache-keys";
|
||||
import { useResetFileTreePaths } from "../../hooks/use-file-tree-model";
|
||||
|
||||
export const DEFAULT_DIR = "/";
|
||||
|
||||
|
|
@ -371,9 +371,7 @@ function DirectoryPane({
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
model.resetPaths(treeInputs.paths);
|
||||
}, [model, treeInputs.paths]);
|
||||
useResetFileTreePaths(model, treeInputs.paths);
|
||||
|
||||
const themeStyles = useMemo<TreeThemeStyle>(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import type { BundledLanguage } from "@pierre/diffs";
|
||||
import { useRunGraphSource, useRunStages } from "../lib/queries";
|
||||
import { LoadingState } from "../components/state";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import { registerDotLanguage } from "../data/register-dot-language";
|
||||
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
|
||||
import { useDotLanguageReady } from "../hooks/use-dot-language-ready";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -18,17 +18,7 @@ export default function RunSource() {
|
|||
() => mapRunStagesToSidebarStages(stagesQuery.data),
|
||||
[stagesQuery.data],
|
||||
);
|
||||
const [dotReady, setDotReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
registerDotLanguage().then(() => {
|
||||
if (!cancelled) setDotReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const dotReady = useDotLanguageReady();
|
||||
|
||||
const source = sourceQuery.data;
|
||||
const loading = source === undefined && !sourceQuery.error;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import { useEffect } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import TerminalView from "../components/terminal-view";
|
||||
import { ToastProvider } from "../components/toast";
|
||||
import { useDocumentTitle } from "../hooks/effects";
|
||||
|
||||
export default function RunTerminal({ params }: { params: { id: string } }) {
|
||||
useEffect(() => {
|
||||
const previous = document.title;
|
||||
document.title = `Terminal · ${params.id} · Fabro`;
|
||||
return () => {
|
||||
document.title = previous;
|
||||
};
|
||||
}, [params.id]);
|
||||
useDocumentTitle(`Terminal · ${params.id} · Fabro`);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createMemoryRouter, RouterProvider } from "react-router";
|
|||
import type { PaginatedRunList, Run } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { ToastProvider } from "../components/toast";
|
||||
import { CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY } from "../components/runs-list/preferences";
|
||||
import { setupReactTestEnv } from "../lib/test-utils";
|
||||
|
||||
class MemoryStorage {
|
||||
|
|
@ -73,22 +74,62 @@ const pageRuns: PaginatedRunList = {
|
|||
};
|
||||
|
||||
const queryCalls: Array<{ hook: string; args: unknown[] }> = [];
|
||||
const BOARD_REFRESH_EVENTS = new Set([
|
||||
"run.submitted",
|
||||
"run.start_requested",
|
||||
"run.pending",
|
||||
"run.approved",
|
||||
"run.denied",
|
||||
"run.runnable",
|
||||
"run.starting",
|
||||
"run.running",
|
||||
"run.removing",
|
||||
"run.paused",
|
||||
"run.unpaused",
|
||||
"run.blocked",
|
||||
"run.unblocked",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
"run.archived",
|
||||
"run.unarchived",
|
||||
"run.title.updated",
|
||||
"interview.started",
|
||||
"interview.completed",
|
||||
"interview.timeout",
|
||||
"interview.interrupted",
|
||||
"pull_request.created",
|
||||
"pull_request.linked",
|
||||
"pull_request.unlinked",
|
||||
]);
|
||||
|
||||
mock.module("../lib/queries", () => ({
|
||||
useAllRuns: (...args: unknown[]) => {
|
||||
queryCalls.push({ hook: "useAllRuns", args });
|
||||
return { data: allRuns, isLoading: false };
|
||||
},
|
||||
useRun: (...args: unknown[]) => {
|
||||
queryCalls.push({ hook: "useRun", args });
|
||||
return {
|
||||
data: run(String(args[0] ?? "run-1")),
|
||||
isLoading: false,
|
||||
mutate: () => Promise.resolve(undefined),
|
||||
};
|
||||
},
|
||||
useRunsPage: (...args: unknown[]) => {
|
||||
queryCalls.push({ hook: "useRunsPage", args });
|
||||
return { data: pageRuns, isLoading: false };
|
||||
return {
|
||||
data: pageRuns,
|
||||
isLoading: false,
|
||||
isValidating: false,
|
||||
mutate: () => Promise.resolve(pageRuns),
|
||||
};
|
||||
},
|
||||
useAuthConfig: () => ({ data: { methods: ["github"] } }),
|
||||
useSystemInfo: () => ({ data: { server_url: "http://127.0.0.1:32276" } }),
|
||||
}));
|
||||
|
||||
mock.module("../lib/board-events", () => ({
|
||||
shouldRefreshBoardForEvent: () => false,
|
||||
shouldRefreshBoardForEvent: (event: string) => BOARD_REFRESH_EVENTS.has(event),
|
||||
useBoardEvents: () => {},
|
||||
}));
|
||||
|
||||
|
|
@ -100,6 +141,8 @@ const {
|
|||
default: Runs,
|
||||
RUNS_PREFERENCES_STORAGE_KEY,
|
||||
} = await import("./runs");
|
||||
const { default: RunChildren } = await import("./run-children");
|
||||
mock.restore();
|
||||
|
||||
function installWindow() {
|
||||
class TestElement {}
|
||||
|
|
@ -149,6 +192,23 @@ async function renderRuns(initialEntry: string) {
|
|||
return { renderer, router };
|
||||
}
|
||||
|
||||
async function renderChildRuns(initialEntry: string) {
|
||||
const router = createMemoryRouter(
|
||||
[{ path: "/runs/:id/children", element: <RunChildren /> }],
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
let renderer!: TestRenderer.ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
mountedRenderers.push(renderer);
|
||||
return { renderer, router };
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {});
|
||||
}
|
||||
|
|
@ -210,8 +270,8 @@ describe("Runs workspace preference restoration", () => {
|
|||
|
||||
// The first frame the user sees must already reflect stored prefs.
|
||||
// Before this was fixed, the route briefly rendered the columns view
|
||||
// with includeArchived=false (default state) before a post-commit
|
||||
// useEffect restored the URL, flashing the Quick Start empty state for
|
||||
// with includeArchived=false (default state) before a post-commit URL
|
||||
// repair restored the URL, flashing the Quick Start empty state for
|
||||
// users whose only runs were archived.
|
||||
const firstAllRuns = queryCalls.find((c) => c.hook === "useAllRuns");
|
||||
const firstRunsPage = queryCalls.find((c) => c.hook === "useRunsPage");
|
||||
|
|
@ -220,6 +280,33 @@ describe("Runs workspace preference restoration", () => {
|
|||
expect(firstRunsPage?.args[1]).toBe(true);
|
||||
});
|
||||
|
||||
test("child runs applies stored list prefs on the first render and hydrates the URL", async () => {
|
||||
storage.setItem(
|
||||
CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sort: "title",
|
||||
direction: "asc",
|
||||
size: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
const { router } = await renderChildRuns("/runs/parent-run/children");
|
||||
|
||||
const firstRunsPage = queryCalls.find((c) => c.hook === "useRunsPage");
|
||||
expect(firstRunsPage?.args[0]).toMatchObject({
|
||||
parentId: "parent-run",
|
||||
sort: "title",
|
||||
direction: "asc",
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
expect(firstRunsPage?.args[1]).toBe(true);
|
||||
|
||||
await flushEffects();
|
||||
expect(router.state.location.search).toBe("?sort=title&direction=asc&size=50");
|
||||
});
|
||||
|
||||
test("/runs?view=columns ignores stored list view", async () => {
|
||||
storage.setItem(
|
||||
RUNS_PREFERENCES_STORAGE_KEY,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { Link, Navigate } from "react-router";
|
||||
import { CheckIcon, ChevronDownIcon, CommandLineIcon } from "@heroicons/react/24/outline";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
|
|
@ -718,6 +718,7 @@ function RunsLandingEmpty({
|
|||
|
||||
export default function Runs() {
|
||||
const {
|
||||
hydratedSearch,
|
||||
query,
|
||||
repoFilter,
|
||||
workflowFilter,
|
||||
|
|
@ -781,14 +782,15 @@ export default function Runs() {
|
|||
),
|
||||
);
|
||||
allWorkflows.sort();
|
||||
const [columns, setColumns] = useState(initialColumns);
|
||||
const [columnsState, setColumnsState] = useState(() => ({
|
||||
base: initialColumns,
|
||||
columns: initialColumns,
|
||||
}));
|
||||
const columns =
|
||||
columnsState.base === initialColumns ? columnsState.columns : initialColumns;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
useBoardEvents();
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(initialColumns);
|
||||
}, [initialColumns]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
|
|
@ -798,15 +800,16 @@ export default function Runs() {
|
|||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
setColumns((prev) =>
|
||||
prev.map((col) => {
|
||||
setColumnsState({
|
||||
base: initialColumns,
|
||||
columns: columns.map((col) => {
|
||||
const oldIndex = col.items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = col.items.findIndex((item) => item.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return col;
|
||||
return { ...col, items: arrayMove(col.items, oldIndex, newIndex) };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
});
|
||||
}, [columns, initialColumns]);
|
||||
|
||||
const totalRuns = columns.reduce((sum, col) => sum + col.items.length, 0);
|
||||
|
||||
|
|
@ -841,77 +844,80 @@ export default function Runs() {
|
|||
);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<RunsToolbar
|
||||
query={query}
|
||||
repoFilter={repoFilter}
|
||||
workflowFilter={workflowFilter}
|
||||
createdFilter={createdFilter}
|
||||
statusFilter={statusFilter}
|
||||
includeArchived={includeArchived}
|
||||
view={view}
|
||||
hiddenColumns={hiddenColumns}
|
||||
allRepos={allRepos}
|
||||
allWorkflows={allWorkflows}
|
||||
onQueryChange={setQuery}
|
||||
onRepoFilterChange={setRepoFilter}
|
||||
onWorkflowFilterChange={setWorkflowFilter}
|
||||
onCreatedFilterChange={setCreatedFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onIncludeArchivedChange={setIncludeArchived}
|
||||
onViewChange={setView}
|
||||
onHiddenColumnsChange={setHiddenColumns}
|
||||
/>
|
||||
<>
|
||||
{hydratedSearch ? <Navigate to={{ search: hydratedSearch }} replace /> : null}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<RunsToolbar
|
||||
query={query}
|
||||
repoFilter={repoFilter}
|
||||
workflowFilter={workflowFilter}
|
||||
createdFilter={createdFilter}
|
||||
statusFilter={statusFilter}
|
||||
includeArchived={includeArchived}
|
||||
view={view}
|
||||
hiddenColumns={hiddenColumns}
|
||||
allRepos={allRepos}
|
||||
allWorkflows={allWorkflows}
|
||||
onQueryChange={setQuery}
|
||||
onRepoFilterChange={setRepoFilter}
|
||||
onWorkflowFilterChange={setWorkflowFilter}
|
||||
onCreatedFilterChange={setCreatedFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onIncludeArchivedChange={setIncludeArchived}
|
||||
onViewChange={setView}
|
||||
onHiddenColumnsChange={setHiddenColumns}
|
||||
/>
|
||||
|
||||
{view === "columns" ? (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 gap-5 overflow-x-auto pb-4">
|
||||
{visibleColumns.map((col) => (
|
||||
<div key={col.id} className="w-72 shrink-0">
|
||||
<BoardColumnView column={col} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isLandingReady && totalRuns === 0 ? (
|
||||
<RunsLandingEmpty
|
||||
hasGitHubAuth={hasGitHubAuth}
|
||||
serverUrl={serverUrl}
|
||||
/>
|
||||
) : totalRuns > 0 && filteredRuns === 0 ? (
|
||||
<div className="py-8">
|
||||
<EmptyState
|
||||
title="No matching runs"
|
||||
description="Try clearing the search or repo filter."
|
||||
/>
|
||||
{view === "columns" ? (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 gap-5 overflow-x-auto pb-4">
|
||||
{visibleColumns.map((col) => (
|
||||
<div key={col.id} className="w-72 shrink-0">
|
||||
<BoardColumnView column={col} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<RunsListView
|
||||
data={listRunsPage.data}
|
||||
isLoading={listRunsPage.data === undefined && listRunsPage.isLoading}
|
||||
emptyState={
|
||||
<RunsLandingEmpty hasGitHubAuth={hasGitHubAuth} serverUrl={serverUrl} />
|
||||
}
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onSortClick={handleSortClick}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
query={lowerQuery}
|
||||
repoFilter={repoFilter}
|
||||
workflowFilter={workflowFilter}
|
||||
statusFilter={statusFilter}
|
||||
createdCutoffMs={createdCutoffMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
{isLandingReady && totalRuns === 0 ? (
|
||||
<RunsLandingEmpty
|
||||
hasGitHubAuth={hasGitHubAuth}
|
||||
serverUrl={serverUrl}
|
||||
/>
|
||||
) : totalRuns > 0 && filteredRuns === 0 ? (
|
||||
<div className="py-8">
|
||||
<EmptyState
|
||||
title="No matching runs"
|
||||
description="Try clearing the search or repo filter."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<RunsListView
|
||||
data={listRunsPage.data}
|
||||
isLoading={listRunsPage.data === undefined && listRunsPage.isLoading}
|
||||
emptyState={
|
||||
<RunsLandingEmpty hasGitHubAuth={hasGitHubAuth} serverUrl={serverUrl} />
|
||||
}
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onSortClick={handleSortClick}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
query={lowerQuery}
|
||||
repoFilter={repoFilter}
|
||||
workflowFilter={workflowFilter}
|
||||
statusFilter={statusFilter}
|
||||
createdCutoffMs={createdCutoffMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { BoardColumn, ListRunsSortEnum } from "@qltysh/fabro-api-client";
|
||||
|
|
@ -32,6 +30,8 @@ export function useRunsWorkspacePreferences() {
|
|||
() => resolveRunsWorkspaceSearchParams(urlSearchParams),
|
||||
[urlSearchParams],
|
||||
);
|
||||
const hydratedSearch =
|
||||
searchParams === urlSearchParams ? null : `?${searchParams.toString()}`;
|
||||
const preferences = useMemo(
|
||||
() => runsWorkspacePreferencesFromSearchParams(searchParams),
|
||||
[searchParams],
|
||||
|
|
@ -103,15 +103,8 @@ export function useRunsWorkspacePreferences() {
|
|||
[updatePreferences],
|
||||
);
|
||||
|
||||
const hydratedFromStorage = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hydratedFromStorage.current) return;
|
||||
hydratedFromStorage.current = true;
|
||||
if (searchParams === urlSearchParams) return;
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}, [searchParams, urlSearchParams, setSearchParams]);
|
||||
|
||||
return {
|
||||
hydratedSearch,
|
||||
query,
|
||||
repoFilter,
|
||||
workflowFilter,
|
||||
|
|
|
|||
|
|
@ -15,11 +15,15 @@ mock.module("../lib/live-events", () => ({
|
|||
if (capturedOnEvent === onEvent) capturedOnEvent = null;
|
||||
};
|
||||
},
|
||||
useLiveEventsSubscription: (onEvent: (payload: LiveEventPayload) => void) => {
|
||||
capturedOnEvent = onEvent;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: SettingsLiveEvents, appendLiveEvent, MAX_EVENTS } = await import(
|
||||
"./settings-live-events"
|
||||
);
|
||||
mock.restore();
|
||||
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import {
|
||||
|
|
@ -18,7 +18,7 @@ import { Tooltip } from "../components/ui";
|
|||
import { eventDedupeKey } from "../lib/cross-tab-sse";
|
||||
import { formatAbsoluteTs } from "../lib/format";
|
||||
import {
|
||||
subscribeToLiveEvents,
|
||||
useLiveEventsSubscription,
|
||||
type LiveEventPayload,
|
||||
} from "../lib/live-events";
|
||||
|
||||
|
|
@ -49,11 +49,9 @@ export default function SettingsLiveEvents() {
|
|||
const [selectedCategories, setSelectedCategories] = useState<DebugCategory[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToLiveEvents((payload) => {
|
||||
setEvents((prev) => appendLiveEvent(prev, payload));
|
||||
});
|
||||
}, []);
|
||||
useLiveEventsSubscription((payload) => {
|
||||
setEvents((prev) => appendLiveEvent(prev, payload));
|
||||
});
|
||||
|
||||
const filtered = useMemo<LiveEventPayload[]>(() => {
|
||||
const useCategoryFilter = selectedCategories.length > 0;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from "../components/runs-list/sort-header";
|
||||
import { Tooltip } from "../components/ui";
|
||||
import { formatContextWindow, formatTokensPerSecond } from "../lib/format";
|
||||
import { useDebouncedValue } from "../hooks/effects";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "Models — Fabro" }];
|
||||
|
|
@ -608,12 +609,3 @@ function sortModels(
|
|||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
|
|
@ -50,10 +50,6 @@ export default function Start() {
|
|||
const [openCategory, setOpenCategory] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
|
|
@ -96,6 +92,7 @@ export default function Start() {
|
|||
onKeyDown={handleKeyDown}
|
||||
aria-label="Workflow prompt"
|
||||
placeholder="Describe a workflow, pipeline, or automation..."
|
||||
autoFocus
|
||||
rows={3}
|
||||
className="w-full resize-none bg-transparent px-5 pt-4 pb-14 text-[15px] leading-relaxed text-fg-2 placeholder:text-fg-muted focus:outline-none"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue