diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index d1a9234..22f47f4 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -1,4 +1,5 @@ import { + CloseIcon, GitPullRequestIcon, HomeIcon, IssuesIcon, @@ -20,10 +21,11 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from "@quickhub/ui/components/dropdown-menu"; -import { Link } from "@tanstack/react-router"; +import { Link, useRouter } from "@tanstack/react-router"; import { useTheme } from "next-themes"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { signOutToLogin } from "#/lib/auth-actions"; +import { removeTab, type Tab, useTabs } from "#/lib/tab-store"; interface DashboardTopbarProps { user: { @@ -52,6 +54,11 @@ const themeOptions = [ { value: "system", icon: SystemIcon, label: "System" }, ] as const; +const tabIconMap = { + pull: GitPullRequestIcon, + issue: IssuesIcon, +} as const; + export function DashboardTopbar({ user, tabsReady, @@ -59,6 +66,34 @@ export function DashboardTopbar({ }: DashboardTopbarProps) { const { theme, setTheme } = useTheme(); const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); + const openTabs = useTabs(); + const router = useRouter(); + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateScrollState = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 0); + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }, []); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const ro = new ResizeObserver(updateScrollState); + ro.observe(el); + return () => ro.disconnect(); + }, [updateScrollState]); + + useEffect(() => { + const el = scrollRef.current; + if (!el || openTabs.length === 0) return; + el.scrollLeft = el.scrollWidth; + updateScrollState(); + }, [openTabs.length, updateScrollState]); + const displayName = user.name ?? user.email; const initials = displayName .split(" ") @@ -197,6 +232,53 @@ export function DashboardTopbar({ ))} + {openTabs.length > 0 && ( +
+
+
+
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: scroll container needs onScroll for gradient visibility */} +
+ {openTabs.map((tab) => { + const Icon = tabIconMap[tab.type]; + return ( + { + const isActive = + router.state.location.pathname === tab.url; + removeTab(id); + if (isActive) { + void router.navigate({ to: "/" }); + } + }} + /> + ); + })} +
+
+
+ )} +
+ + ); +} diff --git a/apps/dashboard/src/lib/tab-store.ts b/apps/dashboard/src/lib/tab-store.ts new file mode 100644 index 0000000..9d22ba9 --- /dev/null +++ b/apps/dashboard/src/lib/tab-store.ts @@ -0,0 +1,65 @@ +import { useSyncExternalStore } from "react"; + +export type TabType = "pull" | "issue"; + +export interface Tab { + id: string; + type: TabType; + title: string; + number: number; + url: string; + repo: string; + iconColor: string; +} + +const STORAGE_KEY = "quickhub:tabs"; + +function loadTabs(): Tab[] { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function persistTabs() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs)); + } catch {} +} + +let tabs: Tab[] = loadTabs(); +const listeners = new Set<() => void>(); + +function emitChange() { + persistTabs(); + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot() { + return tabs; +} + +export function addTab(tab: Tab) { + if (tabs.some((t) => t.id === tab.id)) return; + tabs = [...tabs, tab]; + emitChange(); +} + +export function removeTab(id: string) { + tabs = tabs.filter((t) => t.id !== id); + emitChange(); +} + +export function useTabs() { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/dashboard/src/lib/use-register-tab.ts b/apps/dashboard/src/lib/use-register-tab.ts new file mode 100644 index 0000000..c780c22 --- /dev/null +++ b/apps/dashboard/src/lib/use-register-tab.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { addTab, type TabType } from "./tab-store"; + +export function useRegisterTab( + tab: { + type: TabType; + title: string | undefined; + number: number; + url: string; + repo: string; + iconColor: string; + } | null, +) { + useEffect(() => { + if (!tab?.title) return; + addTab({ + id: `${tab.type}:${tab.repo}#${tab.number}`, + type: tab.type, + title: tab.title, + number: tab.number, + url: tab.url, + repo: tab.repo, + iconColor: tab.iconColor, + }); + }, [tab?.type, tab?.title, tab?.number, tab?.url, tab?.repo, tab?.iconColor]); +} diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx index 3c481a2..18b0277 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx @@ -17,6 +17,7 @@ import { } from "#/lib/github.query"; import type { GitHubActor, IssueDetail } from "#/lib/github.types"; import { useHasMounted } from "#/lib/use-has-mounted"; +import { useRegisterTab } from "#/lib/use-register-tab"; export const Route = createFileRoute( "/_protected/$owner/$repo/issues/$issueId", @@ -63,13 +64,27 @@ function IssueDetailPage() { enabled: hasMounted && detailQuery.data != null, }); + const issue = detailQuery.data; + + useRegisterTab( + issue + ? { + type: "issue", + title: issue.title, + number: issue.number, + url: `/${owner}/${repo}/issues/${issueId}`, + repo: `${owner}/${repo}`, + iconColor: getIssueStateConfig(issue).color, + } + : null, + ); + if (detailQuery.error) throw detailQuery.error; if (hasMounted && detailQuery.isPending) { return ; } - const issue = detailQuery.data; if (!issue) return null; const stateConfig = getIssueStateConfig(issue); diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx index 920d559..5f4abf7 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx @@ -26,6 +26,7 @@ import { } from "#/lib/github.query"; import type { GitHubActor, PullDetail, PullStatus } from "#/lib/github.types"; import { useHasMounted } from "#/lib/use-has-mounted"; +import { useRegisterTab } from "#/lib/use-register-tab"; export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({ component: PullDetailPage, @@ -86,13 +87,27 @@ function PullDetailPage() { enabled: hasMounted && detailQuery.data != null, }); + const pr = detailQuery.data; + + useRegisterTab( + pr + ? { + type: "pull", + title: pr.title, + number: pr.number, + url: `/${owner}/${repo}/pull/${pullId}`, + repo: `${owner}/${repo}`, + iconColor: getPrStateConfig(pr).color, + } + : null, + ); + if (detailQuery.error) throw detailQuery.error; if (hasMounted && detailQuery.isPending) { return ; } - const pr = detailQuery.data; if (!pr) return null; const stateConfig = getPrStateConfig(pr); diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 138915c..b5d6a50 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -5,6 +5,7 @@ export { AddCircleHalfDotIcon as IssuesIcon, BookOpen01Icon as BookOpenIcon, Bug01Icon as BugIcon, + Cancel01Icon as CloseIcon, CheckListIcon as ReviewsIcon, CodeIcon, Comment01Icon as CommentIcon, diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index bbcc24d..68f9c91 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -199,6 +199,13 @@ background: var(--ring); } +.no-scrollbar { + scrollbar-width: none; +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} + /* Stable scrollbar — auto-hides, no layout shift, inset thumb */ .overflow-stable { overflow-y: auto;