From 7b5aeb6f4b41fa51754d7158211530836c051462 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 11 Apr 2026 13:35:39 -0400 Subject: [PATCH] Misc improvements: tab context menu, optimistic mutations, branch deletion, user auth for writes - Add right-click context menu on tabs (close, close others, close right) with single shared instance - Add horizontal wheel scroll on tab bar - Fix optimistic mutation flash by skipping immediate invalidation when optimistic updates are applied - Switch all write operations to use user token instead of app installation token - Add branch deletion/restoration to PR activity timeline, sorted by date - Move merge event into sorted timeline instead of hardcoded at bottom - Track headRefDeleted in page data so delete CTA persists across refresh - Bust pull detail caches after branch deletion - Handle webhook delete events for revalidation - Use 13px font size for context menu items --- .../src/components/layouts/dashboard-tabs.tsx | 174 ++++++++++++++---- .../pulls/detail/pull-detail-activity.tsx | 142 ++++++++++---- .../pulls/detail/pull-detail-page.tsx | 2 + apps/dashboard/src/lib/github-revalidation.ts | 4 + apps/dashboard/src/lib/github.functions.ts | 39 ++-- apps/dashboard/src/lib/github.types.ts | 1 + apps/dashboard/src/lib/tab-store.ts | 12 ++ .../src/lib/use-optimistic-mutation.ts | 15 +- packages/ui/src/components/context-menu.tsx | 2 +- 9 files changed, 300 insertions(+), 91 deletions(-) diff --git a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx index 2423a4e..1f1924a 100644 --- a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx @@ -1,13 +1,28 @@ import { + ChevronRightIcon, CloseIcon, GitPullRequestIcon, IssuesIcon, + Remove01Icon, ReviewsIcon, } from "@diffkit/icons"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@diffkit/ui/components/context-menu"; +import { cn } from "@diffkit/ui/lib/utils"; import { Link, type useRouter, useRouterState } from "@tanstack/react-router"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { preloadRouteOnce } from "#/lib/route-preload"; -import { removeTab, type Tab, useTabs } from "#/lib/tab-store"; +import { + removeOtherTabs, + removeTab, + removeTabsToRight, + type Tab, + useTabs, +} from "#/lib/tab-store"; const tabIconMap = { pull: GitPullRequestIcon, @@ -45,7 +60,25 @@ function useScrollShadows(tabCount: number) { return () => ro.disconnect(); }, [tabCount, updateScrollState]); - return { scrollRef, canScrollLeft, canScrollRight, updateScrollState }; + const handleWheel = useCallback( + (e: React.WheelEvent) => { + const el = scrollRef.current; + if (!el || el.scrollWidth <= el.clientWidth) return; + if (e.deltaY === 0) return; + e.preventDefault(); + el.scrollLeft += e.deltaY; + updateScrollState(); + }, + [updateScrollState], + ); + + return { + scrollRef, + canScrollLeft, + canScrollRight, + updateScrollState, + handleWheel, + }; } interface DashboardTabsProps { @@ -56,8 +89,14 @@ interface DashboardTabsProps { export function DashboardTabs({ tabsReady, routerRef }: DashboardTabsProps) { const openTabs = useTabs(); const pathname = useRouterState({ select: (s) => s.location.pathname }); - const { scrollRef, canScrollLeft, canScrollRight, updateScrollState } = - useScrollShadows(openTabs.length); + const contextTabRef = useRef<{ tab: Tab; index: number } | null>(null); + const { + scrollRef, + canScrollLeft, + canScrollRight, + updateScrollState, + handleWheel, + } = useScrollShadows(openTabs.length); // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is intentionally used as a trigger to re-run when the route changes useEffect(() => { @@ -94,46 +133,102 @@ export function DashboardTabs({ tabsReady, routerRef }: DashboardTabsProps) { [pathname, routerRef], ); + const handleContextClose = useCallback(() => { + const ctx = contextTabRef.current; + if (!ctx) return; + handleCloseTab(ctx.tab.id, ctx.tab.url); + }, [handleCloseTab]); + + const handleContextCloseOthers = useCallback(() => { + const ctx = contextTabRef.current; + if (!ctx) return; + if (pathname !== ctx.tab.url) { + void routerRef.current.navigate({ to: ctx.tab.url }); + } + removeOtherTabs(ctx.tab.id); + }, [pathname, routerRef]); + + const handleContextCloseRight = useCallback(() => { + const ctx = contextTabRef.current; + if (!ctx) return; + removeTabsToRight(ctx.tab.id); + }, []); + if (openTabs.length === 0) return null; return (
-
-
-
- {/* biome-ignore lint/a11y/noStaticElementInteractions: scroll container needs onScroll for gradient visibility */} -
- {openTabs.map((tab) => { - const Icon = tabIconMap[tab.type]; - return ( - - ); - })} -
-
+ + +
+
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: scroll container needs onScroll for gradient visibility */} +
+ {openTabs.map((tab, index) => { + const Icon = tabIconMap[tab.type]; + return ( + { + contextTabRef.current = { tab, index }; + }} + routerRef={routerRef} + /> + ); + })} +
+
+ + + + + Close + + + + Close other tabs + + + + Close tabs to the right + + +
); } @@ -142,11 +237,13 @@ const DetailTab = memo(function DetailTab({ tab, icon: Icon, onClose, + onContextMenu, routerRef, }: { tab: Tab; icon: typeof GitPullRequestIcon; onClose: (id: string, tabUrl: string) => void; + onContextMenu: () => void; routerRef: React.RefObject>; }) { const preloadTab = () => { @@ -160,11 +257,16 @@ const DetailTab = memo(function DetailTab({ onMouseEnter={preloadTab} onFocus={preloadTab} onTouchStart={preloadTab} + onContextMenu={onContextMenu} activeOptions={{ exact: true }} activeProps={{ className: "active" }} className="group relative flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-surface-1 hover:text-foreground [&.active]:bg-surface-1 [&.active]:text-foreground" > - + {tab.title} {tab.type === "review" ? ( diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index fce3572..f5ce918 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -5,6 +5,7 @@ import { CircleIcon, Delete01Icon, EditIcon, + GitBranchIcon, GitCommitIcon, GitMergeIcon, GitPullRequestIcon, @@ -79,6 +80,7 @@ export function PullDetailActivitySection({ repo, pullNumber, scope, + headRefDeleted, }: { comments?: PullComment[]; commits?: PullCommit[]; @@ -92,6 +94,7 @@ export function PullDetailActivitySection({ repo: string; pullNumber: number; scope: GitHubQueryScope; + headRefDeleted: boolean; }) { return (
@@ -167,7 +170,9 @@ export function PullDetailActivitySection({
)} @@ -214,20 +219,26 @@ function MergeStatusSection({ function MergedBranchBanner({ owner, repo, + pullNumber, branchName, + headRefDeleted, }: { owner: string; repo: string; + pullNumber: number; branchName: string; + headRefDeleted: boolean; }) { const [isDeleting, setIsDeleting] = useState(false); const [isDeleted, setIsDeleted] = useState(false); + const deleted = headRefDeleted || isDeleted; + const handleDelete = async () => { setIsDeleting(true); try { const result = await deleteBranch({ - data: { owner, repo, branch: branchName }, + data: { owner, repo, branch: branchName, pullNumber }, }); if (result.ok) { setIsDeleted(true); @@ -248,7 +259,7 @@ function MergedBranchBanner({

- {isDeleted ? ( + {deleted ? ( <> Branch{" "} @@ -266,7 +277,7 @@ function MergedBranchBanner({ )}

- {!isDeleted && ( + {!deleted && (