Skip to content

Commit 7551c2c

Browse files
committed
Feat recently viewed
1 parent dabe28b commit 7551c2c

5 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useNavigate } from "@tanstack/react-router";
2+
import { Clock, GitBranch, Play } from "lucide-react";
3+
4+
import { InlineStack } from "@/components/ui/layout";
5+
import {
6+
Tooltip,
7+
TooltipContent,
8+
TooltipTrigger,
9+
} from "@/components/ui/tooltip";
10+
import { Paragraph, Text } from "@/components/ui/typography";
11+
import {
12+
type RecentlyViewedItem,
13+
useRecentlyViewed,
14+
} from "@/hooks/useRecentlyViewed";
15+
import { EDITOR_PATH, RUNS_BASE_PATH } from "@/routes/router";
16+
17+
function getRecentlyViewedUrl(item: RecentlyViewedItem): string {
18+
if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`;
19+
if (item.type === "run") return `${RUNS_BASE_PATH}/${item.id}`;
20+
// component support to be added later
21+
return "/";
22+
}
23+
24+
const RecentlyViewedChip = ({ item }: { item: RecentlyViewedItem }) => {
25+
const navigate = useNavigate();
26+
27+
const tooltipContent =
28+
item.type === "run" ? `${item.name} #${item.id}` : item.name;
29+
30+
return (
31+
<Tooltip>
32+
<TooltipTrigger asChild>
33+
<div
34+
onClick={() => navigate({ to: getRecentlyViewedUrl(item) })}
35+
className={`flex items-center gap-1.5 pl-2 pr-2 py-1 border rounded-md cursor-pointer w-48 ${
36+
item.type === "pipeline"
37+
? "bg-violet-50/50 hover:bg-violet-50 border-violet-100"
38+
: "bg-emerald-50/50 hover:bg-emerald-50 border-emerald-100"
39+
}`}
40+
>
41+
{item.type === "pipeline" ? (
42+
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
43+
) : (
44+
<Play className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
45+
)}
46+
<span className="text-sm truncate">{item.name}</span>
47+
</div>
48+
</TooltipTrigger>
49+
<TooltipContent>{tooltipContent}</TooltipContent>
50+
</Tooltip>
51+
);
52+
};
53+
54+
export const RecentlyViewedSection = () => {
55+
const { recentlyViewed } = useRecentlyViewed();
56+
57+
return (
58+
<div className="flex flex-col gap-2">
59+
<InlineStack blockAlign="center" gap="1">
60+
<Clock className="h-4 w-4 text-muted-foreground" />
61+
<Text as="h2" size="sm" weight="semibold">
62+
Recently Viewed
63+
</Text>
64+
</InlineStack>
65+
66+
{recentlyViewed.length === 0 ? (
67+
<Paragraph tone="subdued" size="sm">
68+
Nothing viewed yet. Open a pipeline or run to see it here.
69+
</Paragraph>
70+
) : (
71+
<div className="flex gap-2">
72+
{recentlyViewed.map((item) => (
73+
<RecentlyViewedChip key={`${item.type}-${item.id}`} item={item} />
74+
))}
75+
</div>
76+
)}
77+
</div>
78+
);
79+
};

src/hooks/useRecentlyViewed.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useCallback, useSyncExternalStore } from "react";
2+
3+
import { getStorage } from "@/utils/typedStorage";
4+
5+
const RECENTLY_VIEWED_KEY = "Home/recently_viewed";
6+
const MAX_ITEMS = 5;
7+
8+
export type RecentlyViewedType = "pipeline" | "run" | "component";
9+
10+
export interface RecentlyViewedItem {
11+
type: RecentlyViewedType;
12+
id: string;
13+
name: string;
14+
viewedAt: number;
15+
}
16+
17+
type RecentlyViewedStorageMapping = {
18+
[RECENTLY_VIEWED_KEY]: RecentlyViewedItem[];
19+
};
20+
21+
const storage = getStorage<
22+
typeof RECENTLY_VIEWED_KEY,
23+
RecentlyViewedStorageMapping
24+
>();
25+
26+
// useSyncExternalStore requires getSnapshot to return a stable reference.
27+
let cachedJson: string | null = null;
28+
let cachedItems: RecentlyViewedItem[] = [];
29+
30+
function readRecentlyViewed(): RecentlyViewedItem[] {
31+
const json = localStorage.getItem(RECENTLY_VIEWED_KEY);
32+
if (json === cachedJson) return cachedItems;
33+
cachedJson = json;
34+
cachedItems = json ? (JSON.parse(json) as RecentlyViewedItem[]) : [];
35+
return cachedItems;
36+
}
37+
38+
function subscribe(callback: () => void) {
39+
const handler = (event: StorageEvent) => {
40+
if (event.key === RECENTLY_VIEWED_KEY) callback();
41+
};
42+
window.addEventListener("storage", handler);
43+
return () => window.removeEventListener("storage", handler);
44+
}
45+
46+
export function useRecentlyViewed() {
47+
const recentlyViewed = useSyncExternalStore(
48+
subscribe,
49+
readRecentlyViewed,
50+
() => [],
51+
);
52+
53+
const addRecentlyViewed = useCallback(
54+
(item: Omit<RecentlyViewedItem, "viewedAt">) => {
55+
const current = readRecentlyViewed();
56+
// Remove any existing entry for the same item, then prepend the fresh one
57+
const deduped = current.filter(
58+
(i) => !(i.type === item.type && i.id === item.id),
59+
);
60+
const updated = [{ ...item, viewedAt: Date.now() }, ...deduped].slice(
61+
0,
62+
MAX_ITEMS,
63+
);
64+
storage.setItem(RECENTLY_VIEWED_KEY, updated);
65+
},
66+
[],
67+
);
68+
69+
return { recentlyViewed, addRecentlyViewed };
70+
}

src/routes/Dashboard/Dashboard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FavoritesSection } from "@/components/Home/FavoritesSection/FavoritesSection";
2+
import { RecentlyViewedSection } from "@/components/Home/RecentlyViewedSection/RecentlyViewedSection";
23
import { BlockStack, InlineStack } from "@/components/ui/layout";
34
import { Text } from "@/components/ui/typography";
45

@@ -18,6 +19,7 @@ export const Dashboard = () => {
1819
Beta
1920
</Text>
2021
</InlineStack>
22+
<RecentlyViewedSection />
2123
<FavoritesSection />
2224
</BlockStack>
2325
);

src/routes/Editor/Editor.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,28 @@ import "@/styles/editor.css";
22

33
import { DndContext } from "@dnd-kit/core";
44
import { ReactFlowProvider } from "@xyflow/react";
5+
import { useEffect } from "react";
56

67
import PipelineEditor from "@/components/Editor/PipelineEditor";
78
import { InfoBox } from "@/components/shared/InfoBox";
89
import { LoadingScreen } from "@/components/shared/LoadingScreen";
910
import { BlockStack } from "@/components/ui/layout";
1011
import { Paragraph } from "@/components/ui/typography";
1112
import { useLoadComponentSpecFromPath } from "@/hooks/useLoadComponentSpecFromPath";
13+
import { useRecentlyViewed } from "@/hooks/useRecentlyViewed";
1214

1315
const Editor = () => {
1416
const { componentSpec, error } = useLoadComponentSpecFromPath();
17+
const { addRecentlyViewed } = useRecentlyViewed();
18+
19+
useEffect(() => {
20+
if (!componentSpec?.name) return;
21+
addRecentlyViewed({
22+
type: "pipeline",
23+
id: componentSpec.name,
24+
name: componentSpec.name,
25+
});
26+
}, [componentSpec?.name, addRecentlyViewed]);
1527

1628
if (error) {
1729
return (

src/routes/PipelineRun/PipelineRun.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { BlockStack } from "@/components/ui/layout";
1111
import { Paragraph } from "@/components/ui/typography";
1212
import { faviconManager } from "@/favicon";
1313
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
14+
import { useRecentlyViewed } from "@/hooks/useRecentlyViewed";
1415
import { useBackend } from "@/providers/BackendProvider";
1516
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
1617
import {
@@ -28,6 +29,8 @@ const PipelineRunContent = () => {
2829
const { setComponentSpec, clearComponentSpec, componentSpec } =
2930
useComponentSpec();
3031
const { configured, available, ready } = useBackend();
32+
const { addRecentlyViewed } = useRecentlyViewed();
33+
const params = useParams({ strict: false });
3134

3235
const {
3336
details,
@@ -76,6 +79,13 @@ const PipelineRunContent = () => {
7679
`Tangle - ${componentSpec?.name || ""} - ${params.id}`,
7780
});
7881

82+
useEffect(() => {
83+
const id =
84+
"id" in params && typeof params.id === "string" ? params.id : null;
85+
if (!componentSpec?.name || !id) return;
86+
addRecentlyViewed({ type: "run", id, name: componentSpec.name });
87+
}, [componentSpec?.name, params, addRecentlyViewed]);
88+
7989
if (isLoading || !ready) {
8090
return <LoadingScreen message="Loading Pipeline Run" />;
8191
}

0 commit comments

Comments
 (0)