import { Link, useLocation, useNavigate, useSearch } from "../../lib/navigation";
import { ArrowLeftIcon, ArrowRightIcon, LoaderCircleIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ArtifactCatalogItem } from "@opengeni/sdk";
import { Button } from "../../../ui/button";
import { useAppContext } from "../../app-context";
import { artifactRoute, type ArtifactCatalogFilters } from "../../lib/artifact-catalog";
import {
artifactLibraryFilters,
artifactLibrarySearch,
} from "../../lib/artifact-library-navigation";
import { useArtifactCatalog } from "../../lib/use-artifact-catalog";
import type { ArtifactReturnSearch } from "../../lib/routes";
type BrowseProps = { workspaceId: string; artifactId: string; fromSession?: string | undefined };
const keyboardBoundary = [
"input",
"textarea",
"select",
'[contenteditable]:not([contenteditable="false"])',
'[role="textbox"]',
'[role="slider"]',
'[role="spinbutton"]',
'[role="grid"]',
'[role="treegrid"]',
'[role="tablist"]',
'[role="menu"]',
'[role="menubar"]',
'[role="listbox"]',
'[role="combobox"]',
'[role="dialog"]',
'[role="alertdialog"]',
"dialog",
"iframe",
"object",
"embed",
"video",
"audio",
].join(",");
function hasOpenDialog() {
return [
...document.querySelectorAll(
'[role="dialog"], [role="alertdialog"], [aria-modal="true"], dialog[open]',
),
].some((element) => {
if (element.closest('[hidden], [inert], [aria-hidden="true"], [data-state="closed"]'))
return false;
const style = window.getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden";
});
}
/** Only list-origin artifact pages participate; direct/chat links do not fetch a list. */
export function ArtifactBrowseControls({ workspaceId, artifactId, fromSession }: BrowseProps) {
const search = useSearch({ strict: false }) as ArtifactReturnSearch;
if (search.browse !== true) return null;
return (
);
}
function BrowseControls({
workspaceId,
artifactId,
fromSession,
filters,
}: BrowseProps & { filters: ArtifactCatalogFilters }) {
const { client, accessKeyVersion } = useAppContext();
const catalog = useArtifactCatalog(client, workspaceId, filters, accessKeyVersion);
const { loadMore, nextCursor } = catalog;
const { kind, q, sort, status } = filters;
const location = useLocation();
const navigate = useNavigate();
const controls = useRef(null);
const search = useMemo(
() => artifactLibrarySearch({ kind, q, sort, status }, fromSession, true),
[kind, q, sort, status, fromSession],
);
const contextKey = JSON.stringify([
workspaceId,
artifactId,
location.href,
accessKeyVersion,
search,
]);
const [pendingNext, setPendingNext] = useState<{
contextKey: string;
client: typeof client;
cursors: string[];
} | null>(null);
// IDs can collide across Site, retained-file and editable-artifact stores.
const index = catalog.items.findIndex(
(item) =>
item.id === artifactId &&
location.pathname.replace(/\/$/, "") ===
artifactRoute(item.kind)
.replace("$workspaceId", encodeURIComponent(workspaceId))
.replace("$artifactId", encodeURIComponent(artifactId)),
);
const previous = index > 0 ? catalog.items[index - 1] : undefined;
const next = index >= 0 ? catalog.items[index + 1] : undefined;
const locate = useRef({ contextKey, client, cursors: [] as string[] });
if (locate.current.contextKey !== contextKey || locate.current.client !== client)
locate.current = { contextKey, client, cursors: [] };
const locating = Boolean(
index < 0 &&
location.pathname.replace(/\/$/, "").endsWith(`/${encodeURIComponent(artifactId)}`) &&
nextCursor &&
!catalog.error &&
!locate.current.cursors.includes(nextCursor),
);
const waiting = pendingNext?.contextKey === contextKey && pendingNext.client === client;
const busy = catalog.loading || waiting || locating;
const navigateTo = useCallback(
(item: ArtifactCatalogItem) =>
void navigate({
to: artifactRoute(item.kind),
params: { workspaceId, artifactId: item.id },
search,
replace: true,
}),
[navigate, workspaceId, search],
);
// Reloads and new tabs do not have the library's in-memory pages. Locate a
// later-page artifact before declaring it absent; empty pages are not EOF.
useEffect(() => {
if (!locating || catalog.loading || !nextCursor) return;
locate.current.cursors.push(nextCursor);
loadMore();
}, [locating, catalog.loading, nextCursor, loadMore]);
// loadMore returns void: navigate from the committed list, never an async
// closure. Unmounting removes this effect; route/filter/authority changes
// invalidate the captured context before a delayed page can navigate.
useEffect(() => {
if (!pendingNext) return;
if (pendingNext.contextKey !== contextKey || pendingNext.client !== client) {
setPendingNext(null);
return;
}
if (catalog.loading) return;
// An empty/duplicate-only page is not EOF. Follow each new cursor until
// an ordered sibling arrives; never spin on a cursor the server repeated.
if (
!catalog.error &&
!next &&
index >= 0 &&
nextCursor &&
!pendingNext.cursors.includes(nextCursor)
) {
setPendingNext({ ...pendingNext, cursors: [...pendingNext.cursors, nextCursor] });
loadMore();
return;
}
setPendingNext(null);
if (!catalog.error && next) navigateTo(next);
}, [
pendingNext,
contextKey,
client,
catalog.loading,
catalog.error,
next,
index,
nextCursor,
loadMore,
navigateTo,
]);
const move = useCallback(
(direction: "previous" | "next") => {
if (busy || index < 0) return false;
const item = direction === "previous" ? previous : next;
if (item) navigateTo(item);
else if (direction === "next" && nextCursor) {
setPendingNext({ contextKey, client, cursors: [nextCursor] });
loadMore();
} else return false;
return true;
},
[busy, index, previous, next, navigateTo, nextCursor, loadMore, contextKey, client],
);
useEffect(() => {
const pageOrControls = (target: EventTarget | null) =>
target === window ||
target === document ||
target === document.body ||
target === document.documentElement ||
(target instanceof Element && target.matches('[data-slot="detail-page-title"]')) ||
(target instanceof Node && Boolean(controls.current?.contains(target)));
const onKeyDown = (event: KeyboardEvent) => {
if (
event.defaultPrevented ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
event.shiftKey ||
event.repeat ||
event.isComposing ||
event.keyCode === 229 ||
(event.key !== "ArrowLeft" && event.key !== "ArrowRight")
)
return;
if (
!pageOrControls(event.target) ||
!pageOrControls(document.activeElement) ||
event
.composedPath()
.some((target) => target instanceof Element && target.closest(keyboardBoundary)) ||
document.activeElement?.closest(keyboardBoundary) ||
hasOpenDialog()
)
return;
if (move(event.key === "ArrowLeft" ? "previous" : "next")) event.preventDefault();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [move]);
const unavailable = busy
? waiting
? "Loading more artifacts…"
: "Loading artifact list…"
: index < 0
? catalog.error
? "Artifact list could not be loaded. Return to Artifacts to retry."
: "This artifact is not in the loaded list."
: undefined;
const position =
index >= 0
? `${index + 1} of ${catalog.items.length}${catalog.nextCursor ? " loaded" : ""}`
: busy
? "Loading artifacts…"
: catalog.error
? "List unavailable"
: "Not in loaded list";
const buttonClass = "pointer-coarse:min-h-11";
return (
);
}