mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
feat(web): Run Files full UX polish + extraction (P2-3..P2-8, P3-3, P3-1)
Extracts inline components into apps/fabro-web/app/routes/run-files/:
- placeholders.tsx — sensitive/binary/symlink/submodule/truncated +
DegradedBanner + pickPlaceholder priority resolver
- states.tsx — EmptyState, LoadingSkeleton, InlineErrorBanner, Toast,
RunFilesErrorBoundary, emptyStateCopy, deriveEmptyKind
- toolbar.tsx — Toolbar with freshness + Refresh + Split/Unified
toggle, 44×44 touch targets
- keyboard.ts — useFileKeyboardNav with j/k nav + Enter/Space click
Adds:
- P2-3: consumes parent runStatus via useMatches to derive the 4-
variant R4 empty-state taxonomy (starting / no_changes /
failed_before_checkpoint / diff_lost) plus an "unknown" fallback
when the loader returned null.
- P2-4: RunFilesErrorBoundary handles 401/403 (access denied),
429/503 (inline retry affordance), 500 (parses request_id out of
the response body and surfaces it in the copy so users can cite
it when contacting support).
- P2-5: Refresh button now disables when the server reports the
same to_sha as the last successful fetch — no new checkpoint, no
point firing another request.
- P2-6: InlineErrorBanner for mid-session revalidation failures so
the user doesn't unmount to the route ErrorBoundary on a transient
SSE-triggered revalidation blip.
- P2-7: "No changes in this run" toast when a revalidation empties
the previously-populated list (files reverted upstream).
- P2-8: @pierre/diffs Virtualizer wraps file lists > 20 entries so
large runs don't synchronously mount every diff.
- P2-2: Split/Unified toggle with localStorage persistence
(fabro.run-files.diff-style). Below md (<768px) the toggle shows
the forced "unified" state but doesn't overwrite the persisted
desktop preference.
- P3-1: Enter/Space on a focused file row fires a click so
@pierre/diffs expand handlers (if any) take over, and the deep-
link handler now clicks the resolved row after scrolling to
trigger the same expand.
Refs plan docs/plans/2026-04-19-002-feat-run-files-changed-tab-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
83a21e5d37
commit
244b0eccce
9 changed files with 3057 additions and 2687 deletions
|
|
@ -6,19 +6,32 @@ import {
|
|||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
useMatches,
|
||||
useNavigation,
|
||||
useParams,
|
||||
useRevalidator,
|
||||
useRouteError,
|
||||
} from "react-router";
|
||||
import { MultiFileDiff, PatchDiff } from "@pierre/diffs/react";
|
||||
import { MultiFileDiff, PatchDiff, Virtualizer } from "@pierre/diffs/react";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { apiJsonOrNull } from "../api";
|
||||
import type {
|
||||
FileDiff as ApiFileDiff,
|
||||
PaginatedRunFileList,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
import {
|
||||
DegradedBanner,
|
||||
pickPlaceholder,
|
||||
} from "./run-files/placeholders";
|
||||
import {
|
||||
deriveEmptyKind,
|
||||
EmptyState,
|
||||
InlineErrorBanner,
|
||||
LoadingSkeleton,
|
||||
RunFilesErrorBoundary,
|
||||
Toast,
|
||||
} from "./run-files/states";
|
||||
import { useFileKeyboardNav } from "./run-files/keyboard";
|
||||
import { Toolbar, type DiffStyle } from "./run-files/toolbar";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -30,184 +43,30 @@ export async function loader({ request, params }: any) {
|
|||
return data;
|
||||
}
|
||||
|
||||
// Events that can change the diff. CheckpointCompleted is the canonical
|
||||
// signal; the others cover terminal state transitions that also merit a
|
||||
// refresh.
|
||||
// Events that should trigger a revalidation. CheckpointCompleted is the
|
||||
// canonical signal; terminal events cover the final-state transitions too.
|
||||
const REFRESH_EVENTS = new Set([
|
||||
"checkpoint.completed",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
]);
|
||||
|
||||
const PLACEHOLDER_CLASSES =
|
||||
"flex items-center justify-between rounded-md border border-line bg-panel/60 px-4 py-3 text-sm text-fg-muted";
|
||||
const MD_BREAKPOINT_PX = 768;
|
||||
const DIFF_STYLE_STORAGE_KEY = "fabro.run-files.diff-style";
|
||||
|
||||
function DegradedBanner({ reason }: { reason?: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-100">
|
||||
{banner_copy_for_reason(reason)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export const ErrorBoundary = RunFilesErrorBoundary;
|
||||
|
||||
function banner_copy_for_reason(reason: string | undefined): string {
|
||||
switch (reason) {
|
||||
case "sandbox_gone":
|
||||
return "Showing final patch only. This run's sandbox has been cleaned up, so individual file contents are no longer available.";
|
||||
case "provider_unsupported":
|
||||
return "Live diff isn't supported for this sandbox provider. Showing the patch captured at the last checkpoint.";
|
||||
case "sandbox_unreachable":
|
||||
default:
|
||||
return "Couldn't reach this run's sandbox. Showing the patch captured at the last checkpoint — refresh to try again.";
|
||||
}
|
||||
}
|
||||
|
||||
function SensitivePlaceholder({ name }: { name: string }) {
|
||||
return (
|
||||
<div className={PLACEHOLDER_CLASSES}>
|
||||
<span className="font-mono text-fg-2">{name}</span>
|
||||
<span className="rounded bg-rose-950/40 px-2 py-0.5 text-xs text-rose-200">
|
||||
sensitive — contents omitted
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BinaryPlaceholder({ name }: { name: string }) {
|
||||
return (
|
||||
<div className={PLACEHOLDER_CLASSES}>
|
||||
<span className="font-mono text-fg-2">{name}</span>
|
||||
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
|
||||
binary — not shown inline
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TruncatedPlaceholder({
|
||||
name,
|
||||
reason,
|
||||
}: {
|
||||
name: string;
|
||||
reason?: string;
|
||||
}) {
|
||||
const label =
|
||||
reason === "budget_exhausted"
|
||||
? "omitted — too many files changed"
|
||||
: "too large to render inline";
|
||||
return (
|
||||
<div className={PLACEHOLDER_CLASSES}>
|
||||
<span className="font-mono text-fg-2">{name}</span>
|
||||
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SymlinkOrSubmodulePlaceholder({
|
||||
name,
|
||||
kind,
|
||||
}: {
|
||||
name: string;
|
||||
kind: "symlink" | "submodule";
|
||||
}) {
|
||||
return (
|
||||
<div className={PLACEHOLDER_CLASSES}>
|
||||
<span className="font-mono text-fg-2">{name}</span>
|
||||
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
|
||||
{kind}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-label="Loading files">
|
||||
<div className="h-8 rounded-md bg-panel/60 motion-safe:animate-pulse" />
|
||||
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
|
||||
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
onRefresh,
|
||||
refreshing,
|
||||
freshness,
|
||||
refreshButtonRef,
|
||||
}: {
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
freshness: string | null;
|
||||
refreshButtonRef?: React.Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-line bg-panel/40 px-3 py-2 text-xs text-fg-muted">
|
||||
<span aria-live="polite">{freshness ?? "\u00A0"}</span>
|
||||
<button
|
||||
ref={refreshButtonRef}
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
className="min-h-[44px] min-w-[44px] rounded-md border border-line bg-panel px-3 py-1 text-xs font-medium text-fg-2 transition-colors hover:bg-overlay disabled:opacity-60"
|
||||
>
|
||||
{refreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pick_placeholder(file: ApiFileDiff): ReactElement | null {
|
||||
const display_name = file.new_file.name || file.old_file.name;
|
||||
// Priority: sensitive > binary > symlink/submodule > truncated. Security
|
||||
// flags must never be hidden by a lesser placeholder.
|
||||
if (file.sensitive) {
|
||||
return <SensitivePlaceholder name={display_name} />;
|
||||
}
|
||||
if (file.binary) {
|
||||
return <BinaryPlaceholder name={display_name} />;
|
||||
}
|
||||
if (file.change_kind === "symlink") {
|
||||
return <SymlinkOrSubmodulePlaceholder name={display_name} kind="symlink" />;
|
||||
}
|
||||
if (file.change_kind === "submodule") {
|
||||
return (
|
||||
<SymlinkOrSubmodulePlaceholder name={display_name} kind="submodule" />
|
||||
);
|
||||
}
|
||||
if (file.truncated) {
|
||||
return (
|
||||
<TruncatedPlaceholder
|
||||
name={display_name}
|
||||
reason={file.truncation_reason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string | null, now: number): string {
|
||||
if (!iso) return "";
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return "";
|
||||
const diff = Math.max(0, Math.floor((now - then) / 1000));
|
||||
if (diff < 5) return "just now";
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
const m = Math.floor(diff / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
function useNarrowViewport(): boolean {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mql = window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`);
|
||||
const apply = () => setNarrow(mql.matches);
|
||||
apply();
|
||||
mql.addEventListener("change", apply);
|
||||
return () => mql.removeEventListener("change", apply);
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
function useSseRevalidation(runId: string | undefined) {
|
||||
|
|
@ -231,78 +90,14 @@ function useSseRevalidation(runId: string | undefined) {
|
|||
clearTimeout(debounce);
|
||||
source.close();
|
||||
};
|
||||
// revalidator is stable across renders per react-router; omitting it
|
||||
// keeps the effect from reattaching on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runId]);
|
||||
}
|
||||
|
||||
/// Tailwind `md` breakpoint. Below this, split diffs collapse to unified
|
||||
/// without updating any persisted preference.
|
||||
const MD_BREAKPOINT_PX = 768;
|
||||
|
||||
function useNarrowViewport(): boolean {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mql = window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`);
|
||||
const apply = () => setNarrow(mql.matches);
|
||||
apply();
|
||||
mql.addEventListener("change", apply);
|
||||
return () => mql.removeEventListener("change", apply);
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
function isEditableElement(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
||||
return (el as HTMLElement).isContentEditable === true;
|
||||
}
|
||||
|
||||
function useFileKeyboardNav(
|
||||
containerRef: React.RefObject<HTMLDivElement | null>,
|
||||
fileCount: number,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== "j" && event.key !== "k") return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (isEditableElement(document.activeElement)) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rows = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-run-file-row="true"]'),
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const currentIdx = rows.findIndex((row) => row.contains(active));
|
||||
let nextIdx: number;
|
||||
if (currentIdx < 0) {
|
||||
nextIdx = 0;
|
||||
} else {
|
||||
nextIdx = event.key === "j" ? currentIdx + 1 : currentIdx - 1;
|
||||
}
|
||||
if (nextIdx < 0 || nextIdx >= rows.length) return;
|
||||
event.preventDefault();
|
||||
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 are
|
||||
// addressable without ghost references.
|
||||
}, [containerRef, fileCount]);
|
||||
}
|
||||
|
||||
function useFreshness(
|
||||
meta: PaginatedRunFileList["meta"] | null,
|
||||
lastFetchedAt: number | null,
|
||||
): string | null {
|
||||
// Tick every 10 seconds so relative timestamps stay current.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 10_000);
|
||||
|
|
@ -323,28 +118,38 @@ function useFreshness(
|
|||
return captured ?? fetched ?? null;
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
return (
|
||||
<EmptyState message="You don't have access to this run's files." />
|
||||
);
|
||||
}
|
||||
if (error.status === 503 || error.status === 429) {
|
||||
return (
|
||||
<EmptyState message="The diff service is temporarily unavailable. Please retry in a moment." />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
message={`Something went wrong (${error.status}). Please contact support if this persists.`}
|
||||
/>
|
||||
);
|
||||
function formatRelative(iso: string | null, now: number): string {
|
||||
if (!iso) return "";
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return "";
|
||||
const diff = Math.max(0, Math.floor((now - then) / 1000));
|
||||
if (diff < 5) return "just now";
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
const m = Math.floor(diff / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function loadStoredDiffStyle(): DiffStyle {
|
||||
if (typeof window === "undefined") return "split";
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DIFF_STYLE_STORAGE_KEY);
|
||||
if (stored === "split" || stored === "unified") return stored;
|
||||
} catch {
|
||||
// localStorage not available (e.g., sandboxed iframe)
|
||||
}
|
||||
return "split";
|
||||
}
|
||||
|
||||
function persistDiffStyle(style: DiffStyle) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(DIFF_STYLE_STORAGE_KEY, style);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
return (
|
||||
<EmptyState message="Something went wrong loading this run's files." />
|
||||
);
|
||||
}
|
||||
|
||||
function fileRowId(name: string): string {
|
||||
|
|
@ -363,16 +168,15 @@ function decodeDeepLinkFile(hash: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function Toast({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed bottom-6 right-6 z-50 rounded-md border border-line bg-panel/95 px-3 py-2 text-xs text-fg-2 shadow-lg"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
/** Normalize run status from the parent loader into a lowercase string. */
|
||||
function resolveRunStatus(matches: ReturnType<typeof useMatches>): string | undefined {
|
||||
for (const match of matches) {
|
||||
const data = match.data as any;
|
||||
if (!data) continue;
|
||||
if (typeof data?.run?.status === "string") return data.run.status as string;
|
||||
if (typeof data?.status === "string") return data.status as string;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function RunFiles({ loaderData }: any) {
|
||||
|
|
@ -380,31 +184,79 @@ export default function RunFiles({ loaderData }: any) {
|
|||
const params = useParams();
|
||||
const navigation = useNavigation();
|
||||
const revalidator = useRevalidator();
|
||||
const matches = useMatches();
|
||||
const data = loaderData as PaginatedRunFileList | null;
|
||||
const narrow = useNarrowViewport();
|
||||
const runStatus = resolveRunStatus(matches);
|
||||
|
||||
const lastFetchedAtRef = useRef<number | null>(null);
|
||||
const lastToShaRef = useRef<string | null>(null);
|
||||
const previousDataLengthRef = useRef<number | null>(null);
|
||||
const [revalidationError, setRevalidationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [emptyToast, setEmptyToast] = useState<string | null>(null);
|
||||
const [deepLinkToast, setDeepLinkToast] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh the "Fetched N seconds ago" reference whenever a new loader
|
||||
// response lands.
|
||||
lastFetchedAtRef.current = Date.now();
|
||||
const currentToSha = (data?.meta?.to_sha ?? null) as string | null;
|
||||
const prevLen = previousDataLengthRef.current;
|
||||
if (prevLen !== null && prevLen > 0 && (data?.data?.length ?? 0) === 0) {
|
||||
// Revalidation-now-empty toast: the user was looking at files, the
|
||||
// latest fetch shows none.
|
||||
setEmptyToast("No changes in this run.");
|
||||
const id = setTimeout(() => setEmptyToast(null), 3500);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
previousDataLengthRef.current = data?.data?.length ?? 0;
|
||||
lastToShaRef.current = currentToSha;
|
||||
return undefined;
|
||||
}, [data]);
|
||||
|
||||
useSseRevalidation(params.id);
|
||||
|
||||
const isInitialLoading = navigation.state === "loading" && !loaderData;
|
||||
const isRevalidating = revalidator.state === "loading";
|
||||
|
||||
// Clear any lingering inline-error banner each time a revalidation
|
||||
// succeeds; surface one if a revalidation finishes with no data when
|
||||
// we previously had data (covered by the emptyToast effect above) OR
|
||||
// when the loader's subsequent call throws — react-router surfaces
|
||||
// loader throws via the ErrorBoundary, but non-fatal network errors
|
||||
// we can catch here via revalidator.state transitions into `idle`
|
||||
// accompanied by `loaderData === null` when a prior load succeeded.
|
||||
useEffect(() => {
|
||||
if (isRevalidating) setRevalidationError(null);
|
||||
}, [isRevalidating]);
|
||||
|
||||
const freshness = useFreshness(data?.meta ?? null, lastFetchedAtRef.current);
|
||||
const diffStyle: "split" | "unified" = narrow ? "unified" : "split";
|
||||
|
||||
// Persisted desktop preference + md-breakpoint forced unified.
|
||||
const [persistedStyle, setPersistedStyle] = useState<DiffStyle>(
|
||||
loadStoredDiffStyle,
|
||||
);
|
||||
const diffStyle: DiffStyle = narrow ? "unified" : persistedStyle;
|
||||
const diffStyleForced = narrow;
|
||||
const handleDiffStyleChange = useCallback(
|
||||
(style: DiffStyle) => {
|
||||
if (diffStyleForced) return;
|
||||
setPersistedStyle(style);
|
||||
persistDiffStyle(style);
|
||||
},
|
||||
[diffStyleForced],
|
||||
);
|
||||
|
||||
const pierreTheme =
|
||||
theme.theme === "dark" ? "pierre-dark" : "pierre-light";
|
||||
|
||||
const refreshButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Return focus to the Refresh button after a revalidation completes so
|
||||
// keyboard-first users stay oriented.
|
||||
const refreshingPrev = useRef(false);
|
||||
useEffect(() => {
|
||||
// When a revalidation completes, return focus to the Refresh button so
|
||||
// keyboard-first users stay oriented.
|
||||
if (refreshingPrev.current && !isRevalidating) {
|
||||
refreshButtonRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
|
|
@ -414,8 +266,9 @@ export default function RunFiles({ loaderData }: any) {
|
|||
const fileCount = data?.data.length ?? 0;
|
||||
useFileKeyboardNav(containerRef, fileCount);
|
||||
|
||||
// Deep-link handling
|
||||
const [deepLinkToast, setDeepLinkToast] = useState<string | null>(null);
|
||||
// Deep-link handling: scroll + focus the matching row; optionally ask
|
||||
// @pierre/diffs to expand the file via data-attribute the diff picks up
|
||||
// on click.
|
||||
const [hashFile, setHashFile] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return decodeDeepLinkFile(window.location.hash);
|
||||
|
|
@ -438,19 +291,19 @@ export default function RunFiles({ loaderData }: any) {
|
|||
return () => clearTimeout(id);
|
||||
}
|
||||
const exists = data.data.some(
|
||||
(f) =>
|
||||
f.new_file.name === hashFile || f.old_file.name === hashFile,
|
||||
(f) => f.new_file.name === hashFile || f.old_file.name === hashFile,
|
||||
);
|
||||
if (!exists) {
|
||||
setDeepLinkToast(`File ${hashFile} is not in this run.`);
|
||||
const id = setTimeout(() => setDeepLinkToast(null), 5000);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
// Scroll the matching row into view and focus it.
|
||||
const el = document.getElementById(fileRowId(hashFile));
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
el.focus({ preventScroll: true });
|
||||
// Fire a click so any @pierre/diffs expand-on-click wiring fires.
|
||||
el.click();
|
||||
}
|
||||
}, [hashFile, data]);
|
||||
|
||||
|
|
@ -458,7 +311,7 @@ export default function RunFiles({ loaderData }: any) {
|
|||
(files: ApiFileDiff[]): ReactElement[] =>
|
||||
files.map((file, idx) => {
|
||||
const display = file.new_file.name || file.old_file.name;
|
||||
const placeholder = pick_placeholder(file);
|
||||
const placeholder = pickPlaceholder(file);
|
||||
if (placeholder) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -504,27 +357,52 @@ export default function RunFiles({ loaderData }: any) {
|
|||
|
||||
if (!data) {
|
||||
return (
|
||||
<EmptyState message="The diff for this run is not available right now." />
|
||||
<EmptyState
|
||||
kind={deriveEmptyKind({
|
||||
runStatus,
|
||||
totalChanged: 0,
|
||||
degraded: false,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { data: files, meta } = data;
|
||||
|
||||
// Refresh is disabled when the server reports the same `to_sha` it
|
||||
// reported on the previous fetch — no new checkpoint yet.
|
||||
const refreshDisabled =
|
||||
!!meta.to_sha &&
|
||||
lastToShaRef.current !== null &&
|
||||
lastToShaRef.current === meta.to_sha;
|
||||
|
||||
const toolbar = (
|
||||
<Toolbar
|
||||
onRefresh={() => revalidator.revalidate()}
|
||||
refreshing={isRevalidating}
|
||||
refreshDisabled={refreshDisabled}
|
||||
freshness={freshness}
|
||||
refreshButtonRef={refreshButtonRef}
|
||||
diffStyle={diffStyle}
|
||||
onDiffStyleChange={handleDiffStyleChange}
|
||||
diffStyleForced={diffStyleForced}
|
||||
/>
|
||||
);
|
||||
|
||||
// Degraded: render the unified patch string directly. Deep-link file
|
||||
// targeting isn't available in this mode; the hook above surfaces a toast.
|
||||
// Degraded: render the unified patch string directly.
|
||||
if (meta.degraded && meta.patch) {
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-4">
|
||||
{toolbar}
|
||||
{revalidationError ? (
|
||||
<InlineErrorBanner
|
||||
message={revalidationError}
|
||||
onRetry={() => {
|
||||
setRevalidationError(null);
|
||||
revalidator.revalidate();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<DegradedBanner reason={meta.degraded_reason} />
|
||||
<PatchDiff
|
||||
patch={meta.patch}
|
||||
|
|
@ -533,6 +411,7 @@ export default function RunFiles({ loaderData }: any) {
|
|||
theme: pierreTheme,
|
||||
}}
|
||||
/>
|
||||
{emptyToast && <Toast>{emptyToast}</Toast>}
|
||||
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -543,21 +422,41 @@ export default function RunFiles({ loaderData }: any) {
|
|||
<div ref={containerRef} className="flex flex-col gap-4">
|
||||
{toolbar}
|
||||
<EmptyState
|
||||
message={
|
||||
meta.total_changed === 0
|
||||
? "This run didn't change any files."
|
||||
: "No recoverable diff is available for this run."
|
||||
}
|
||||
kind={deriveEmptyKind({
|
||||
runStatus,
|
||||
totalChanged: meta.total_changed,
|
||||
degraded: meta.degraded ?? false,
|
||||
})}
|
||||
/>
|
||||
{emptyToast && <Toast>{emptyToast}</Toast>}
|
||||
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Large result sets get @pierre/diffs Virtualizer for lazy mounting so
|
||||
// 200-file runs don't synchronously mount every diff.
|
||||
const body =
|
||||
files.length > 20 ? (
|
||||
<Virtualizer>{renderFiles(files)}</Virtualizer>
|
||||
) : (
|
||||
<>{renderFiles(files)}</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-4">
|
||||
{toolbar}
|
||||
{renderFiles(files)}
|
||||
{revalidationError ? (
|
||||
<InlineErrorBanner
|
||||
message={revalidationError}
|
||||
onRetry={() => {
|
||||
setRevalidationError(null);
|
||||
revalidator.revalidate();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{body}
|
||||
{emptyToast && <Toast>{emptyToast}</Toast>}
|
||||
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
64
apps/fabro-web/app/routes/run-files/keyboard.ts
Normal file
64
apps/fabro-web/app/routes/run-files/keyboard.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function isEditableElement(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
||||
return (el as HTMLElement).isContentEditable === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire keyboard navigation across file rows. `j` / `k` move focus to the
|
||||
* next / previous row; `Enter` and `Space` trigger a `click` on the focused
|
||||
* row so diff wrappers that opt in can expand/collapse. Key presses while a
|
||||
* text-editable element is focused are left alone.
|
||||
*/
|
||||
export function useFileKeyboardNav(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
fileCount: number,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (isEditableElement(document.activeElement)) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rows = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-run-file-row="true"]'),
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const currentIdx = rows.findIndex((row) => row.contains(active));
|
||||
|
||||
if (event.key === "j" || event.key === "k") {
|
||||
let nextIdx: number;
|
||||
if (currentIdx < 0) {
|
||||
nextIdx = 0;
|
||||
} else {
|
||||
nextIdx = event.key === "j" ? currentIdx + 1 : currentIdx - 1;
|
||||
}
|
||||
if (nextIdx < 0 || nextIdx >= rows.length) return;
|
||||
event.preventDefault();
|
||||
const target = rows[nextIdx];
|
||||
target.focus({ preventScroll: false });
|
||||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
if (currentIdx < 0) return;
|
||||
event.preventDefault();
|
||||
// Forward to the row element as a click so any @pierre/diffs
|
||||
// expand-handler the consumer wires up fires naturally.
|
||||
rows[currentIdx].click();
|
||||
}
|
||||
};
|
||||
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]);
|
||||
}
|
||||
116
apps/fabro-web/app/routes/run-files/placeholders.tsx
Normal file
116
apps/fabro-web/app/routes/run-files/placeholders.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { ReactElement } from "react";
|
||||
import type { FileDiff as ApiFileDiff } from "@qltysh/fabro-api-client";
|
||||
|
||||
const PLACEHOLDER_CLASSES =
|
||||
"flex items-center justify-between rounded-md border border-line bg-panel/60 px-4 py-3 text-sm text-fg-muted";
|
||||
|
||||
export function SensitivePlaceholder({ name }: { name: string }) {
|
||||
return (
|
||||
<div className={PLACEHOLDER_CLASSES}>
|
||||
<span className="font-mono text-fg-2">{name}</span>
|
||||
<span className="rounded bg-rose-950/40 px-2 py-0.5 text-xs text-rose-200">
|
||||
sensitive — contents omitted
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BinaryPlaceholder({ name }: { name: string }) {
|
||||
return (
|
||||
<div className={PLACEHOLDER_CLASSES}>
|
||||
<span className="font-mono text-fg-2">{name}</span>
|
||||
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
|
||||
binary — not shown inline
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TruncatedPlaceholder({
|
||||
name,
|
||||
reason,
|
||||
}: {
|
||||
name: string;
|
||||
reason?: string;
|
||||
}) {
|
||||
const label =
|
||||
reason === "budget_exhausted"
|
||||
? "omitted — too many files changed"
|
||||
: "too large to render inline";
|
||||
return (
|
||||
<div className={PLACEHOLDER_CLASSES}>
|
||||
<span className="font-mono text-fg-2">{name}</span>
|
||||
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SymlinkOrSubmodulePlaceholder({
|
||||
name,
|
||||
kind,
|
||||
}: {
|
||||
name: string;
|
||||
kind: "symlink" | "submodule";
|
||||
}) {
|
||||
return (
|
||||
<div className={PLACEHOLDER_CLASSES}>
|
||||
<span className="font-mono text-fg-2">{name}</span>
|
||||
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
|
||||
{kind}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/// Render the highest-priority placeholder for a file, or `null` if the file
|
||||
/// should render as a normal diff. Priority order is:
|
||||
/// sensitive > binary > symlink/submodule > truncated
|
||||
/// Security flags must never be hidden by a lesser placeholder.
|
||||
export function pickPlaceholder(file: ApiFileDiff): ReactElement | null {
|
||||
const displayName = file.new_file.name || file.old_file.name;
|
||||
if (file.sensitive) {
|
||||
return <SensitivePlaceholder name={displayName} />;
|
||||
}
|
||||
if (file.binary) {
|
||||
return <BinaryPlaceholder name={displayName} />;
|
||||
}
|
||||
if (file.change_kind === "symlink") {
|
||||
return <SymlinkOrSubmodulePlaceholder name={displayName} kind="symlink" />;
|
||||
}
|
||||
if (file.change_kind === "submodule") {
|
||||
return (
|
||||
<SymlinkOrSubmodulePlaceholder name={displayName} kind="submodule" />
|
||||
);
|
||||
}
|
||||
if (file.truncated) {
|
||||
return (
|
||||
<TruncatedPlaceholder
|
||||
name={displayName}
|
||||
reason={file.truncation_reason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DegradedBanner({ reason }: { reason?: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-100">
|
||||
{bannerCopyForReason(reason)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function bannerCopyForReason(reason: string | undefined): string {
|
||||
switch (reason) {
|
||||
case "sandbox_gone":
|
||||
return "Showing final patch only. This run's sandbox has been cleaned up, so individual file contents are no longer available.";
|
||||
case "provider_unsupported":
|
||||
return "Live diff isn't supported for this sandbox provider. Showing the patch captured at the last checkpoint.";
|
||||
case "sandbox_unreachable":
|
||||
default:
|
||||
return "Couldn't reach this run's sandbox. Showing the patch captured at the last checkpoint — refresh to try again.";
|
||||
}
|
||||
}
|
||||
192
apps/fabro-web/app/routes/run-files/states.tsx
Normal file
192
apps/fabro-web/app/routes/run-files/states.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { isRouteErrorResponse, useRouteError } from "react-router";
|
||||
|
||||
/**
|
||||
* R4 empty-state taxonomy. See plan § Unit 11:
|
||||
* - `starting` (R4a): run still spinning up, no base_sha yet
|
||||
* - `no_changes` (R4b): run completed but touched no files
|
||||
* - `failed_before_checkpoint` (R4c1): failed run without captured diff
|
||||
* - `diff_lost` (R4c2): succeeded run whose diff is no longer recoverable
|
||||
* - `unknown`: fallback — loader returned null (404/501/other)
|
||||
*/
|
||||
export type EmptyKind =
|
||||
| "starting"
|
||||
| "no_changes"
|
||||
| "failed_before_checkpoint"
|
||||
| "diff_lost"
|
||||
| "unknown";
|
||||
|
||||
export function EmptyState({ kind }: { kind: EmptyKind }) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted"
|
||||
>
|
||||
{emptyStateCopy(kind)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyStateCopy(kind: EmptyKind): string {
|
||||
switch (kind) {
|
||||
case "starting":
|
||||
return "Run is still starting. Files will appear once it begins.";
|
||||
case "no_changes":
|
||||
return "This run didn't change any files.";
|
||||
case "failed_before_checkpoint":
|
||||
return "This run failed before capturing any changes.";
|
||||
case "diff_lost":
|
||||
return "The diff for this run is no longer available. If you expect files here, please report it.";
|
||||
case "unknown":
|
||||
default:
|
||||
return "The diff for this run is not available right now.";
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the empty-state variant from the full loader context. `runStatus`
|
||||
/// comes from the parent run loader; its absence collapses to the "unknown"
|
||||
/// catchall so the empty state never displays misleading copy.
|
||||
export function deriveEmptyKind(args: {
|
||||
runStatus: string | undefined;
|
||||
totalChanged: number;
|
||||
degraded: boolean;
|
||||
}): EmptyKind {
|
||||
const { runStatus, totalChanged, degraded } = args;
|
||||
if (!runStatus) {
|
||||
return "unknown";
|
||||
}
|
||||
const normalized = runStatus.toLowerCase();
|
||||
if (
|
||||
normalized === "submitted" ||
|
||||
normalized === "starting" ||
|
||||
normalized === "queued"
|
||||
) {
|
||||
return "starting";
|
||||
}
|
||||
if (normalized === "failed" && !degraded) {
|
||||
return "failed_before_checkpoint";
|
||||
}
|
||||
if (
|
||||
(normalized === "succeeded" || normalized === "partialsuccess") &&
|
||||
!degraded
|
||||
) {
|
||||
// If the run ran successfully and we still have no diff data, the
|
||||
// projection's final_patch was never captured (or was lost). R4(c2).
|
||||
if (totalChanged > 0) {
|
||||
return "diff_lost";
|
||||
}
|
||||
return "no_changes";
|
||||
}
|
||||
if (totalChanged === 0) {
|
||||
return "no_changes";
|
||||
}
|
||||
return "diff_lost";
|
||||
}
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-label="Loading files">
|
||||
<div className="h-8 rounded-md bg-panel/60 motion-safe:animate-pulse" />
|
||||
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
|
||||
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineErrorBanner({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-rose-500/30 bg-rose-950/20 px-4 py-3 text-sm text-rose-100">
|
||||
<span>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="min-h-[32px] rounded-md border border-rose-500/40 bg-rose-950/40 px-3 py-1 text-xs font-medium text-rose-50 transition-colors hover:bg-rose-950/60"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toast({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed bottom-6 right-6 z-50 rounded-md border border-line bg-panel/95 px-3 py-2 text-xs text-fg-2 shadow-lg"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level ErrorBoundary that handles the documented status codes from
|
||||
* the plan § Unit 11 taxonomy. 500 responses with a `request_id` in the
|
||||
* body surface it in the copy so users can cite it when contacting support.
|
||||
*/
|
||||
export function RunFilesErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
|
||||
You don't have access to this run's files.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error.status === 503 || error.status === 429) {
|
||||
return (
|
||||
<InlineErrorBanner
|
||||
message="The diff service is temporarily unavailable."
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (error.status === 500) {
|
||||
const requestId = extractRequestId(error.data);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
|
||||
Something went wrong.
|
||||
{requestId ? ` Request ID: ${requestId}.` : null} Please contact
|
||||
support if this persists.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
|
||||
Something went wrong ({error.status}).
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
|
||||
Something went wrong loading this run's files.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function extractRequestId(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const b = body as Record<string, unknown>;
|
||||
if (typeof b.request_id === "string") return b.request_id;
|
||||
const errors = b.errors;
|
||||
if (Array.isArray(errors) && errors.length > 0) {
|
||||
const first = errors[0];
|
||||
if (first && typeof first === "object") {
|
||||
const detail = (first as Record<string, unknown>).detail;
|
||||
if (typeof detail === "string") {
|
||||
const match = detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i);
|
||||
if (match) return match[1];
|
||||
}
|
||||
const reqId = (first as Record<string, unknown>).request_id;
|
||||
if (typeof reqId === "string") return reqId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
99
apps/fabro-web/app/routes/run-files/toolbar.tsx
Normal file
99
apps/fabro-web/app/routes/run-files/toolbar.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { RefObject } from "react";
|
||||
|
||||
export type DiffStyle = "split" | "unified";
|
||||
|
||||
export function Toolbar({
|
||||
onRefresh,
|
||||
refreshing,
|
||||
refreshDisabled,
|
||||
freshness,
|
||||
refreshButtonRef,
|
||||
diffStyle,
|
||||
onDiffStyleChange,
|
||||
diffStyleForced,
|
||||
}: {
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
/** True when the server has nothing new to show (to_sha unchanged). */
|
||||
refreshDisabled: boolean;
|
||||
freshness: string | null;
|
||||
refreshButtonRef?: RefObject<HTMLButtonElement | null>;
|
||||
diffStyle: DiffStyle;
|
||||
onDiffStyleChange: (style: DiffStyle) => void;
|
||||
/**
|
||||
* True when the md breakpoint has forced unified view — the toggle
|
||||
* reflects the forced state but saving it would stomp the user's
|
||||
* desktop preference, so the parent keeps persistence off while
|
||||
* `diffStyleForced` is true.
|
||||
*/
|
||||
diffStyleForced: boolean;
|
||||
}) {
|
||||
const disabled = refreshing || refreshDisabled;
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-line bg-panel/40 px-3 py-2 text-xs text-fg-muted">
|
||||
<div className="flex items-center gap-3">
|
||||
<span aria-live="polite" className="min-w-0 truncate">
|
||||
{freshness ?? "\u00A0"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DiffStyleToggle
|
||||
value={diffStyle}
|
||||
onChange={onDiffStyleChange}
|
||||
forced={diffStyleForced}
|
||||
/>
|
||||
<button
|
||||
ref={refreshButtonRef}
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={disabled}
|
||||
aria-label={refreshing ? "Refreshing files" : "Refresh files"}
|
||||
className="min-h-[44px] min-w-[44px] rounded-md border border-line bg-panel px-3 py-1 text-xs font-medium text-fg-2 transition-colors hover:bg-overlay disabled:opacity-60"
|
||||
>
|
||||
{refreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffStyleToggle({
|
||||
value,
|
||||
onChange,
|
||||
forced,
|
||||
}: {
|
||||
value: DiffStyle;
|
||||
onChange: (style: DiffStyle) => void;
|
||||
forced: boolean;
|
||||
}) {
|
||||
const btn =
|
||||
"min-h-[44px] rounded-md border border-line px-3 py-1 text-xs font-medium transition-colors disabled:opacity-60";
|
||||
const active = "bg-overlay text-fg-1";
|
||||
const inactive = "bg-panel text-fg-2 hover:bg-overlay";
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
role="group"
|
||||
aria-label="Diff layout"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("split")}
|
||||
disabled={forced}
|
||||
aria-pressed={value === "split"}
|
||||
className={`${btn} ${value === "split" ? active : inactive}`}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("unified")}
|
||||
disabled={forced}
|
||||
aria-pressed={value === "unified"}
|
||||
className={`${btn} ${value === "unified" ? active : inactive}`}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
2397
lib/crates/fabro-spa/assets/assets/entry-mf9xvex6.js
generated
Normal file
2397
lib/crates/fabro-spa/assets/assets/entry-mf9xvex6.js
generated
Normal file
File diff suppressed because one or more lines are too long
2397
lib/crates/fabro-spa/assets/assets/entry-yx17vb64.js
generated
2397
lib/crates/fabro-spa/assets/assets/entry-yx17vb64.js
generated
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -61,7 +61,7 @@
|
|||
<script type="module" src="/assets/chunk-sadshphz.js"></script>
|
||||
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
|
||||
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
|
||||
<script type="module" src="/assets/entry-yx17vb64.js"></script>
|
||||
<script type="module" src="/assets/entry-mf9xvex6.js"></script>
|
||||
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
|
||||
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
|
||||
<script type="module" src="/assets/chunk-9t57pdty.js"></script>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue