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 ( ); }