| ... | @@ -9,6 +9,8 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ... | @@ -9,6 +9,8 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 9 | import { | 9 | import { |
| 10 | Bot, | 10 | Bot, |
| 11 | ChevronDown, | 11 | ChevronDown, |
| | 12 | ChevronLeft, |
| | 13 | ChevronRight, |
| 12 | FileDiff, | 14 | FileDiff, |
| 13 | Files, | 15 | Files, |
| 14 | GitPullRequest, | 16 | GitPullRequest, |
| ... | @@ -170,6 +172,12 @@ type TabContextMenuAction = | ... | @@ -170,6 +172,12 @@ type TabContextMenuAction = |
| 170 | | "close-to-right" | 172 | | "close-to-right" |
| 171 | | "close-all"; | 173 | | "close-all"; |
| 172 | | 174 | |
| | 175 | const TAB_SCROLL_EDGE_TOLERANCE = 1; |
| | 176 | |
| | 177 | function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { |
| | 178 | return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null; |
| | 179 | } |
| | 180 | |
| 173 | /** | 181 | /** |
| 174 | * Desktop preview tab backing a surface, or null for non-preview surfaces, the | 182 | * Desktop preview tab backing a surface, or null for non-preview surfaces, the |
| 175 | * "new browser tab" placeholder, and the web build where no desktop tab exists. | 183 | * "new browser tab" placeholder, and the web build where no desktop tab exists. |
| ... | @@ -720,6 +728,42 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -720,6 +728,42 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 720 | const { resolvedTheme } = useTheme(); | 728 | const { resolvedTheme } = useTheme(); |
| 721 | const tabListRef = useRef<HTMLDivElement>(null); | 729 | const tabListRef = useRef<HTMLDivElement>(null); |
| 722 | const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); | 730 | const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); |
| | 731 | const [tabScrollState, setTabScrollState] = useState({ |
| | 732 | hasOverflow: false, |
| | 733 | canScrollLeft: false, |
| | 734 | canScrollRight: false, |
| | 735 | }); |
| | 736 | |
| | 737 | const updateTabScrollState = useCallback(() => { |
| | 738 | const viewport = tabScrollViewport(tabListRef.current); |
| | 739 | if (!viewport) return; |
| | 740 | |
| | 741 | const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; |
| | 742 | const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; |
| | 743 | const canScrollRight = |
| | 744 | hasOverflow && |
| | 745 | viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE; |
| | 746 | setTabScrollState((current) => { |
| | 747 | if ( |
| | 748 | current.hasOverflow === hasOverflow && |
| | 749 | current.canScrollLeft === canScrollLeft && |
| | 750 | current.canScrollRight === canScrollRight |
| | 751 | ) { |
| | 752 | return current; |
| | 753 | } |
| | 754 | return { hasOverflow, canScrollLeft, canScrollRight }; |
| | 755 | }); |
| | 756 | }, []); |
| | 757 | |
| | 758 | const scrollTabs = useCallback((direction: -1 | 1) => { |
| | 759 | const viewport = tabScrollViewport(tabListRef.current); |
| | 760 | if (!viewport) return; |
| | 761 | const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| | 762 | viewport.scrollBy({ |
| | 763 | left: direction * Math.max(120, viewport.clientWidth * 0.75), |
| | 764 | behavior: reduceMotion ? "auto" : "smooth", |
| | 765 | }); |
| | 766 | }, []); |
| 723 | | 767 | |
| 724 | const addSurfaceActions = [ | 768 | const addSurfaceActions = [ |
| 725 | { | 769 | { |
| ... | @@ -886,9 +930,49 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -886,9 +930,49 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 886 | ); | 930 | ); |
| 887 | | 931 | |
| 888 | useEffect(() => { | 932 | useEffect(() => { |
| | 933 | if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; |
| 889 | const activeTab = tabListRef.current?.querySelector<HTMLElement>("[data-active-tab='true']"); | 934 | const activeTab = tabListRef.current?.querySelector<HTMLElement>("[data-active-tab='true']"); |
| 890 | activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); | 935 | activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); |
| 891 | }, [props.activeSurfaceId]); | 936 | }, [props.activeSurfaceId, tabScrollState.hasOverflow]); |
| | 937 | |
| | 938 | useEffect(() => { |
| | 939 | const viewport = tabScrollViewport(tabListRef.current); |
| | 940 | if (!viewport) return; |
| | 941 | |
| | 942 | const content = viewport.firstElementChild; |
| | 943 | const resizeObserver = new ResizeObserver(updateTabScrollState); |
| | 944 | resizeObserver.observe(viewport); |
| | 945 | if (content) resizeObserver.observe(content); |
| | 946 | viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); |
| | 947 | updateTabScrollState(); |
| | 948 | |
| | 949 | return () => { |
| | 950 | resizeObserver.disconnect(); |
| | 951 | viewport.removeEventListener("scroll", updateTabScrollState); |
| | 952 | }; |
| | 953 | }, [updateTabScrollState]); |
| | 954 | |
| | 955 | useEffect(() => { |
| | 956 | const viewport = tabScrollViewport(tabListRef.current); |
| | 957 | if (!viewport) return; |
| | 958 | |
| | 959 | const handleWheel = (event: WheelEvent) => { |
| | 960 | if (event.ctrlKey) return; |
| | 961 | let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; |
| | 962 | if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16; |
| | 963 | if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth; |
| | 964 | if (delta === 0) return; |
| | 965 | |
| | 966 | const previousScrollLeft = viewport.scrollLeft; |
| | 967 | viewport.scrollLeft += delta; |
| | 968 | if (viewport.scrollLeft === previousScrollLeft) return; |
| | 969 | event.preventDefault(); |
| | 970 | updateTabScrollState(); |
| | 971 | }; |
| | 972 | |
| | 973 | viewport.addEventListener("wheel", handleWheel, { passive: false }); |
| | 974 | return () => viewport.removeEventListener("wheel", handleWheel); |
| | 975 | }, [updateTabScrollState]); |
| 892 | | 976 | |
| 893 | return ( | 977 | return ( |
| 894 | <PreviewPanelShell | 978 | <PreviewPanelShell |
| ... | @@ -905,6 +989,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -905,6 +989,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 905 | // the titlebar's height: a compact row re-centers the layout | 989 | // the titlebar's height: a compact row re-centers the layout |
| 906 | // controls a few pixels higher and the cluster jumps on open. | 990 | // controls a few pixels higher and the cluster jumps on open. |
| 907 | props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3", | 991 | props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3", |
| | 992 | ownsDesktopTitleBar && "drag-region", |
| 908 | ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", | 993 | ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", |
| 909 | props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | 994 | props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, |
| 910 | )} | 995 | )} |
| ... | @@ -914,7 +999,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -914,7 +999,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 914 | ref={tabListRef} | 999 | ref={tabListRef} |
| 915 | hideScrollbars | 1000 | hideScrollbars |
| 916 | scrollFade | 1001 | scrollFade |
| 917 | className={cn("min-w-0 flex-1 rounded-none", ownsDesktopTitleBar && "drag-region")} | 1002 | className={cn( |
| | 1003 | "min-w-0 flex-1 rounded-none", |
| | 1004 | ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", |
| | 1005 | )} |
| 918 | data-right-panel-tab-list | 1006 | data-right-panel-tab-list |
| 919 | > | 1007 | > |
| 920 | <div className="flex h-full w-max min-w-full items-center gap-1"> | 1008 | <div className="flex h-full w-max min-w-full items-center gap-1"> |
| ... | @@ -1099,6 +1187,50 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -1099,6 +1187,50 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 1099 | ) : null} | 1187 | ) : null} |
| 1100 | </div> | 1188 | </div> |
| 1101 | </ScrollArea> | 1189 | </ScrollArea> |
| | 1190 | {tabScrollState.hasOverflow ? ( |
| | 1191 | <div |
| | 1192 | className="flex shrink-0 items-center gap-0.5 [-webkit-app-region:no-drag]" |
| | 1193 | role="group" |
| | 1194 | aria-label="Scroll panel tabs" |
| | 1195 | > |
| | 1196 | <Tooltip> |
| | 1197 | <TooltipTrigger |
| | 1198 | render={ |
| | 1199 | <span className="inline-flex"> |
| | 1200 | <Button |
| | 1201 | aria-label="Scroll tabs left" |
| | 1202 | disabled={!tabScrollState.canScrollLeft} |
| | 1203 | onClick={() => scrollTabs(-1)} |
| | 1204 | size="icon-xs" |
| | 1205 | variant="ghost" |
| | 1206 | > |
| | 1207 | <ChevronLeft /> |
| | 1208 | </Button> |
| | 1209 | </span> |
| | 1210 | } |
| | 1211 | /> |
| | 1212 | <TooltipPopup>Scroll tabs left</TooltipPopup> |
| | 1213 | </Tooltip> |
| | 1214 | <Tooltip> |
| | 1215 | <TooltipTrigger |
| | 1216 | render={ |
| | 1217 | <span className="inline-flex"> |
| | 1218 | <Button |
| | 1219 | aria-label="Scroll tabs right" |
| | 1220 | disabled={!tabScrollState.canScrollRight} |
| | 1221 | onClick={() => scrollTabs(1)} |
| | 1222 | size="icon-xs" |
| | 1223 | variant="ghost" |
| | 1224 | > |
| | 1225 | <ChevronRight /> |
| | 1226 | </Button> |
| | 1227 | </span> |
| | 1228 | } |
| | 1229 | /> |
| | 1230 | <TooltipPopup>Scroll tabs right</TooltipPopup> |
| | 1231 | </Tooltip> |
| | 1232 | </div> |
| | 1233 | ) : null} |
| 1102 | {props.layoutControls} | 1234 | {props.layoutControls} |
| 1103 | </div> | 1235 | </div> |
| 1104 | <div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content> | 1236 | <div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content> |