diff --git a/src/components/Home/RecentlyViewedSection/RecentlyViewedSection.tsx b/src/components/Home/RecentlyViewedSection/RecentlyViewedSection.tsx new file mode 100644 index 000000000..18bbc9d91 --- /dev/null +++ b/src/components/Home/RecentlyViewedSection/RecentlyViewedSection.tsx @@ -0,0 +1,79 @@ +import { useNavigate } from "@tanstack/react-router"; +import { Clock, GitBranch, Play } from "lucide-react"; + +import { InlineStack } from "@/components/ui/layout"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Paragraph, Text } from "@/components/ui/typography"; +import { + type RecentlyViewedItem, + useRecentlyViewed, +} from "@/hooks/useRecentlyViewed"; +import { EDITOR_PATH, RUNS_BASE_PATH } from "@/routes/router"; + +function getRecentlyViewedUrl(item: RecentlyViewedItem): string { + if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`; + if (item.type === "run") return `${RUNS_BASE_PATH}/${item.id}`; + // component support to be added later + return "/"; +} + +const RecentlyViewedChip = ({ item }: { item: RecentlyViewedItem }) => { + const navigate = useNavigate(); + + const tooltipContent = + item.type === "run" ? `${item.name} #${item.id}` : item.name; + + return ( + + +
navigate({ to: getRecentlyViewedUrl(item) })} + className={`flex items-center gap-1.5 pl-2 pr-2 py-1 border rounded-md cursor-pointer w-48 ${ + item.type === "pipeline" + ? "bg-violet-50/50 hover:bg-violet-50 border-violet-100" + : "bg-emerald-50/50 hover:bg-emerald-50 border-emerald-100" + }`} + > + {item.type === "pipeline" ? ( + + ) : ( + + )} + {item.name} +
+
+ {tooltipContent} +
+ ); +}; + +export const RecentlyViewedSection = () => { + const { recentlyViewed } = useRecentlyViewed(); + + return ( +
+ + + + Recently Viewed + + + + {recentlyViewed.length === 0 ? ( + + Nothing viewed yet. Open a pipeline or run to see it here. + + ) : ( +
+ {recentlyViewed.map((item) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/hooks/useRecentlyViewed.ts b/src/hooks/useRecentlyViewed.ts new file mode 100644 index 000000000..c33318539 --- /dev/null +++ b/src/hooks/useRecentlyViewed.ts @@ -0,0 +1,70 @@ +import { useCallback, useSyncExternalStore } from "react"; + +import { getStorage } from "@/utils/typedStorage"; + +const RECENTLY_VIEWED_KEY = "Home/recently_viewed"; +const MAX_ITEMS = 5; + +export type RecentlyViewedType = "pipeline" | "run" | "component"; + +export interface RecentlyViewedItem { + type: RecentlyViewedType; + id: string; + name: string; + viewedAt: number; +} + +type RecentlyViewedStorageMapping = { + [RECENTLY_VIEWED_KEY]: RecentlyViewedItem[]; +}; + +const storage = getStorage< + typeof RECENTLY_VIEWED_KEY, + RecentlyViewedStorageMapping +>(); + +// useSyncExternalStore requires getSnapshot to return a stable reference. +let cachedJson: string | null = null; +let cachedItems: RecentlyViewedItem[] = []; + +function readRecentlyViewed(): RecentlyViewedItem[] { + const json = localStorage.getItem(RECENTLY_VIEWED_KEY); + if (json === cachedJson) return cachedItems; + cachedJson = json; + cachedItems = json ? (JSON.parse(json) as RecentlyViewedItem[]) : []; + return cachedItems; +} + +function subscribe(callback: () => void) { + const handler = (event: StorageEvent) => { + if (event.key === RECENTLY_VIEWED_KEY) callback(); + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); +} + +export function useRecentlyViewed() { + const recentlyViewed = useSyncExternalStore( + subscribe, + readRecentlyViewed, + () => [], + ); + + const addRecentlyViewed = useCallback( + (item: Omit) => { + const current = readRecentlyViewed(); + // Remove any existing entry for the same item, then prepend the fresh one + const deduped = current.filter( + (i) => !(i.type === item.type && i.id === item.id), + ); + const updated = [{ ...item, viewedAt: Date.now() }, ...deduped].slice( + 0, + MAX_ITEMS, + ); + storage.setItem(RECENTLY_VIEWED_KEY, updated); + }, + [], + ); + + return { recentlyViewed, addRecentlyViewed }; +} diff --git a/src/routes/Dashboard/Dashboard.tsx b/src/routes/Dashboard/Dashboard.tsx index e7d490a59..313cfd7c2 100644 --- a/src/routes/Dashboard/Dashboard.tsx +++ b/src/routes/Dashboard/Dashboard.tsx @@ -1,4 +1,5 @@ import { FavoritesSection } from "@/components/Home/FavoritesSection/FavoritesSection"; +import { RecentlyViewedSection } from "@/components/Home/RecentlyViewedSection/RecentlyViewedSection"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; @@ -18,6 +19,7 @@ export const Dashboard = () => { Beta + ); diff --git a/src/routes/Editor/Editor.tsx b/src/routes/Editor/Editor.tsx index 82bdf67ff..12873226f 100644 --- a/src/routes/Editor/Editor.tsx +++ b/src/routes/Editor/Editor.tsx @@ -2,6 +2,7 @@ import "@/styles/editor.css"; import { DndContext } from "@dnd-kit/core"; import { ReactFlowProvider } from "@xyflow/react"; +import { useEffect } from "react"; import PipelineEditor from "@/components/Editor/PipelineEditor"; import { InfoBox } from "@/components/shared/InfoBox"; @@ -9,9 +10,20 @@ import { LoadingScreen } from "@/components/shared/LoadingScreen"; import { BlockStack } from "@/components/ui/layout"; import { Paragraph } from "@/components/ui/typography"; import { useLoadComponentSpecFromPath } from "@/hooks/useLoadComponentSpecFromPath"; +import { useRecentlyViewed } from "@/hooks/useRecentlyViewed"; const Editor = () => { const { componentSpec, error } = useLoadComponentSpecFromPath(); + const { addRecentlyViewed } = useRecentlyViewed(); + + useEffect(() => { + if (!componentSpec?.name) return; + addRecentlyViewed({ + type: "pipeline", + id: componentSpec.name, + name: componentSpec.name, + }); + }, [componentSpec?.name, addRecentlyViewed]); if (error) { return ( diff --git a/src/routes/PipelineRun/PipelineRun.tsx b/src/routes/PipelineRun/PipelineRun.tsx index 65255e16c..b111cf640 100644 --- a/src/routes/PipelineRun/PipelineRun.tsx +++ b/src/routes/PipelineRun/PipelineRun.tsx @@ -11,6 +11,7 @@ import { BlockStack } from "@/components/ui/layout"; import { Paragraph } from "@/components/ui/typography"; import { faviconManager } from "@/favicon"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { useRecentlyViewed } from "@/hooks/useRecentlyViewed"; import { useBackend } from "@/providers/BackendProvider"; import { useComponentSpec } from "@/providers/ComponentSpecProvider"; import { @@ -28,6 +29,8 @@ const PipelineRunContent = () => { const { setComponentSpec, clearComponentSpec, componentSpec } = useComponentSpec(); const { configured, available, ready } = useBackend(); + const { addRecentlyViewed } = useRecentlyViewed(); + const params = useParams({ strict: false }); const { details, @@ -76,6 +79,13 @@ const PipelineRunContent = () => { `Tangle - ${componentSpec?.name || ""} - ${params.id}`, }); + useEffect(() => { + const id = + "id" in params && typeof params.id === "string" ? params.id : null; + if (!componentSpec?.name || !id) return; + addRecentlyViewed({ type: "run", id, name: componentSpec.name }); + }, [componentSpec?.name, params, addRecentlyViewed]); + if (isLoading || !ready) { return ; }