| ... | @@ -14,6 +14,17 @@ import type { | ... | @@ -14,6 +14,17 @@ import type { |
| 14 | ResolvedKeybindingsConfig, | 14 | ResolvedKeybindingsConfig, |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | 16 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| | 17 | import { |
| | 18 | closestCenter, |
| | 19 | DndContext, |
| | 20 | type DragEndEvent, |
| | 21 | PointerSensor, |
| | 22 | useSensor, |
| | 23 | useSensors, |
| | 24 | } from "@dnd-kit/core"; |
| | 25 | import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; |
| | 26 | import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; |
| | 27 | import { CSS } from "@dnd-kit/utilities"; |
| 17 | import { | 28 | import { |
| 18 | Smartphone, | 29 | Smartphone, |
| 19 | ChevronDown, | 30 | ChevronDown, |
| ... | @@ -27,6 +38,7 @@ import { | ... | @@ -27,6 +38,7 @@ import { |
| 27 | } from "lucide-react"; | 38 | } from "lucide-react"; |
| 28 | import { Volume2, VolumeOff } from "lucide"; | 39 | import { Volume2, VolumeOff } from "lucide"; |
| 29 | import { | 40 | import { |
| | 41 | type ComponentProps, |
| 30 | type KeyboardEvent as ReactKeyboardEvent, | 42 | type KeyboardEvent as ReactKeyboardEvent, |
| 31 | type MouseEvent as ReactMouseEvent, | 43 | type MouseEvent as ReactMouseEvent, |
| 32 | type ReactElement, | 44 | type ReactElement, |
| ... | @@ -115,6 +127,8 @@ interface RightPanelTabsProps { | ... | @@ -115,6 +127,8 @@ interface RightPanelTabsProps { |
| 115 | onCloseOtherSurfaces: (surface: RightPanelSurface) => void; | 127 | onCloseOtherSurfaces: (surface: RightPanelSurface) => void; |
| 116 | onCloseSurfacesToRight: (surface: RightPanelSurface) => void; | 128 | onCloseSurfacesToRight: (surface: RightPanelSurface) => void; |
| 117 | onCloseAllSurfaces: () => void; | 129 | onCloseAllSurfaces: () => void; |
| | 130 | /** Tabs are draggable only when the owner can persist the new order. */ |
| | 131 | onMoveSurface?: (surfaceId: string, toIndex: number) => void; |
| 118 | onCopyFilePath: (relativePath: string) => void; | 132 | onCopyFilePath: (relativePath: string) => void; |
| 119 | onAddBrowser: () => void; | 133 | onAddBrowser: () => void; |
| 120 | /** | 134 | /** |
| ... | @@ -199,6 +213,7 @@ type TabContextMenuAction = | ... | @@ -199,6 +213,7 @@ type TabContextMenuAction = |
| 199 | | "close-all"; | 213 | | "close-all"; |
| 200 | | 214 | |
| 201 | const TAB_SCROLL_EDGE_TOLERANCE = 1; | 215 | const TAB_SCROLL_EDGE_TOLERANCE = 1; |
| | 216 | const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; |
| 202 | | 217 | |
| 203 | function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { | 218 | function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { |
| 204 | return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null; | 219 | return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null; |
| ... | @@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({ | ... | @@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({ |
| 795 | return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />; | 810 | return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />; |
| 796 | } | 811 | } |
| 797 | | 812 | |
| | 813 | /** |
| | 814 | * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging, |
| | 815 | * and the order commits on drop. |
| | 816 | */ |
| | 817 | function SortableTabList(props: { |
| | 818 | surfaces: readonly RightPanelSurface[]; |
| | 819 | onMove: ((surfaceId: string, toIndex: number) => void) | undefined; |
| | 820 | children: ReactNode; |
| | 821 | }) { |
| | 822 | const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); |
| | 823 | const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]); |
| | 824 | const { onMove, surfaces } = props; |
| | 825 | const handleDragEnd = useCallback( |
| | 826 | ({ active, over }: DragEndEvent) => { |
| | 827 | if (!over || active.id === over.id) return; |
| | 828 | const toIndex = surfaces.findIndex((surface) => surface.id === over.id); |
| | 829 | if (toIndex >= 0) onMove?.(String(active.id), toIndex); |
| | 830 | }, |
| | 831 | [onMove, surfaces], |
| | 832 | ); |
| | 833 | return ( |
| | 834 | <DndContext |
| | 835 | sensors={sensors} |
| | 836 | collisionDetection={closestCenter} |
| | 837 | modifiers={TAB_DRAG_MODIFIERS} |
| | 838 | onDragEnd={handleDragEnd} |
| | 839 | > |
| | 840 | <SortableContext items={surfaceIds} strategy={horizontalListSortingStrategy}> |
| | 841 | {props.children} |
| | 842 | </SortableContext> |
| | 843 | </DndContext> |
| | 844 | ); |
| | 845 | } |
| | 846 | |
| | 847 | function SortableTab({ |
| | 848 | surfaceId, |
| | 849 | disabled, |
| | 850 | className, |
| | 851 | ...props |
| | 852 | }: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { |
| | 853 | const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ |
| | 854 | id: surfaceId, |
| | 855 | disabled, |
| | 856 | }); |
| | 857 | return ( |
| | 858 | <div |
| | 859 | {...props} |
| | 860 | {...listeners} |
| | 861 | ref={setNodeRef} |
| | 862 | style={{ transform: CSS.Translate.toString(transform), transition }} |
| | 863 | className={cn(className, isDragging && "relative z-10")} |
| | 864 | /> |
| | 865 | ); |
| | 866 | } |
| | 867 | |
| 798 | export function RightPanelTabs(props: RightPanelTabsProps) { | 868 | export function RightPanelTabs(props: RightPanelTabsProps) { |
| 799 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; | 869 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; |
| 800 | const browserProfiles = useBrowserDefaults().profiles; | 870 | const browserProfiles = useBrowserDefaults().profiles; |
| ... | @@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 1118 | data-right-panel-tab-list | 1188 | data-right-panel-tab-list |
| 1119 | > | 1189 | > |
| 1120 | <div className="flex h-full w-max min-w-full items-center gap-1"> | 1190 | <div className="flex h-full w-max min-w-full items-center gap-1"> |
| 1121 | {props.surfaces.map((surface) => { | 1191 | <SortableTabList surfaces={props.surfaces} onMove={props.onMoveSurface}> |
| 1122 | const active = surface.id === props.activeSurfaceId; | 1192 | {props.surfaces.map((surface) => { |
| 1123 | const pending = props.pendingSurfaceIds.has(surface.id); | 1193 | const active = surface.id === props.activeSurfaceId; |
| 1124 | const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); | 1194 | const pending = props.pendingSurfaceIds.has(surface.id); |
| 1125 | const previewTabId = previewTabIdOf(surface, props.previewSessions); | 1195 | const title = surfaceTitle( |
| 1126 | // Desktop state is keyed by the session id, but desktop actions | 1196 | surface, |
| 1127 | // must be addressed with the runtime id. | 1197 | props.previewSessions, |
| 1128 | const audio = tabAudioState( | 1198 | props.terminalLabelsById, |
| 1129 | previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, | 1199 | ); |
| 1130 | ); | 1200 | const previewTabId = previewTabIdOf(surface, props.previewSessions); |
| 1131 | const audioRuntimeTabId = previewTabId | 1201 | // Desktop state is keyed by the session id, but desktop actions |
| 1132 | ? (props.previewRuntimeTabId?.(previewTabId) ?? null) | 1202 | // must be addressed with the runtime id. |
| 1133 | : null; | 1203 | const audio = tabAudioState( |
| 1134 | return ( | 1204 | previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, |
| 1135 | <div | 1205 | ); |
| 1136 | key={surface.id} | 1206 | const audioRuntimeTabId = previewTabId |
| 1137 | data-active-tab={active} | 1207 | ? (props.previewRuntimeTabId?.(previewTabId) ?? null) |
| 1138 | onMouseDown={handleTabMouseDown} | 1208 | : null; |
| 1139 | onAuxClick={(event) => handleTabAuxClick(event, surface)} | 1209 | return ( |
| 1140 | onContextMenu={(event) => void handleTabContextMenu(event, surface)} | 1210 | <SortableTab |
| 1141 | className={cn( | 1211 | key={surface.id} |
| 1142 | "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", | 1212 | surfaceId={surface.id} |
| 1143 | ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", | 1213 | disabled={!props.onMoveSurface || renamingDevice === surface.id} |
| 1144 | active | 1214 | data-active-tab={active} |
| 1145 | ? "bg-accent text-foreground" | 1215 | onMouseDown={handleTabMouseDown} |
| 1146 | : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", | 1216 | onAuxClick={(event) => handleTabAuxClick(event, surface)} |
| 1147 | )} | 1217 | onContextMenu={(event) => void handleTabContextMenu(event, surface)} |
| 1148 | > | 1218 | className={cn( |
| 1149 | <PanelTabCloseButton | 1219 | "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", |
| 1150 | label={`Close ${title}`} | 1220 | ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", |
| 1151 | onClick={() => props.onCloseSurface(surface)} | 1221 | active |
| | 1222 | ? "bg-accent text-foreground" |
| | 1223 | : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", |
| | 1224 | )} |
| 1152 | > | 1225 | > |
| 1153 | <SurfaceIcon | 1226 | <PanelTabCloseButton |
| 1154 | surface={surface} | 1227 | label={`Close ${title}`} |
| 1155 | sessions={props.previewSessions} | 1228 | onClick={() => props.onCloseSurface(surface)} |
| 1156 | desktopByTabId={props.desktopByTabId} | 1229 | > |
| 1157 | theme={resolvedTheme} | 1230 | <SurfaceIcon |
| 1158 | environmentId={props.environmentId} | 1231 | surface={surface} |
| 1159 | pullRequestStatusSeeds={props.pullRequestStatusSeeds} | 1232 | sessions={props.previewSessions} |
| 1160 | /> | 1233 | desktopByTabId={props.desktopByTabId} |
| 1161 | {pending ? ( | 1234 | theme={resolvedTheme} |
| 1162 | <span | 1235 | environmentId={props.environmentId} |
| 1163 | className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current" | 1236 | pullRequestStatusSeeds={props.pullRequestStatusSeeds} |
| 1164 | aria-hidden | | |
| 1165 | /> | | |
| 1166 | ) : null} | | |
| 1167 | </PanelTabCloseButton> | | |
| 1168 | {audio === "none" || !audioRuntimeTabId ? null : ( | | |
| 1169 | <Tooltip> | | |
| 1170 | <TooltipTrigger | | |
| 1171 | render={ | | |
| 1172 | <button | | |
| 1173 | type="button" | | |
| 1174 | className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted" | | |
| 1175 | aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`} | | |
| 1176 | onClick={(event) => { | | |
| 1177 | // Sibling of the close button, inside a tab that | | |
| 1178 | // activates on click: keep this to the toggle. | | |
| 1179 | event.stopPropagation(); | | |
| 1180 | void previewBridge | | |
| 1181 | ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") | | |
| 1182 | .catch(() => undefined); | | |
| 1183 | }} | | |
| 1184 | > | | |
| 1185 | <MorphIcon | | |
| 1186 | className="size-3" | | |
| 1187 | icon={audio === "muted" ? VolumeOff : Volume2} | | |
| 1188 | /> | | |
| 1189 | </button> | | |
| 1190 | } | | |
| 1191 | /> | 1237 | /> |
| 1192 | <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup> | 1238 | {pending ? ( |
| 1193 | </Tooltip> | 1239 | <span |
| 1194 | )} | 1240 | className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current" |
| 1195 | {renamingDevice === surface.id ? ( | 1241 | aria-hidden |
| 1196 | <input | 1242 | /> |
| 1197 | aria-label="Device tab name" | 1243 | ) : null} |
| 1198 | className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-inset ring-ring" | 1244 | </PanelTabCloseButton> |
| 1199 | defaultValue={title} | 1245 | {audio === "none" || !audioRuntimeTabId ? null : ( |
| 1200 | ref={(element) => { | 1246 | <Tooltip> |
| 1201 | element?.focus(); | 1247 | <TooltipTrigger |
| 1202 | element?.select(); | 1248 | render={ |
| 1203 | }} | 1249 | <button |
| 1204 | onBlur={(event) => { | 1250 | type="button" |
| 1205 | props.onRenameDevice?.(surface.id, event.currentTarget.value); | 1251 | className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted" |
| 1206 | setRenamingDevice(null); | 1252 | aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`} |
| 1207 | }} | 1253 | onClick={(event) => { |
| 1208 | onKeyDown={(event) => { | 1254 | // Sibling of the close button, inside a tab that |
| 1209 | event.stopPropagation(); | 1255 | // activates on click: keep this to the toggle. |
| 1210 | if (event.key === "Enter") event.currentTarget.blur(); | 1256 | event.stopPropagation(); |
| 1211 | if (event.key === "Escape") { | 1257 | void previewBridge |
| 1212 | event.currentTarget.value = title; | 1258 | ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") |
| 1213 | event.currentTarget.blur(); | 1259 | .catch(() => undefined); |
| 1214 | } | 1260 | }} |
| 1215 | }} | 1261 | > |
| 1216 | /> | 1262 | <MorphIcon |
| 1217 | ) : ( | 1263 | className="size-3" |
| 1218 | <Tooltip> | 1264 | icon={audio === "muted" ? VolumeOff : Volume2} |
| 1219 | <TooltipTrigger | 1265 | /> |
| 1220 | render={ | 1266 | </button> |
| 1221 | <button | 1267 | } |
| 1222 | type="button" | 1268 | /> |
| 1223 | onDoubleClick={() => { | 1269 | <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup> |
| 1224 | if (surface.kind === "device" && props.onRenameDevice) | 1270 | </Tooltip> |
| 1225 | setRenamingDevice(surface.id); | 1271 | )} |
| 1226 | }} | 1272 | {renamingDevice === surface.id ? ( |
| 1227 | className="cursor-pointer flex min-w-0 items-center" | 1273 | <input |
| 1228 | onClick={() => props.onActivate(surface)} | 1274 | aria-label="Device tab name" |
| 1229 | > | 1275 | className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-inset ring-ring" |
| 1230 | <span className="truncate">{title}</span> | 1276 | defaultValue={title} |
| 1231 | </button> | 1277 | ref={(element) => { |
| 1232 | } | 1278 | element?.focus(); |
| | 1279 | element?.select(); |
| | 1280 | }} |
| | 1281 | onBlur={(event) => { |
| | 1282 | props.onRenameDevice?.(surface.id, event.currentTarget.value); |
| | 1283 | setRenamingDevice(null); |
| | 1284 | }} |
| | 1285 | onKeyDown={(event) => { |
| | 1286 | event.stopPropagation(); |
| | 1287 | if (event.key === "Enter") event.currentTarget.blur(); |
| | 1288 | if (event.key === "Escape") { |
| | 1289 | event.currentTarget.value = title; |
| | 1290 | event.currentTarget.blur(); |
| | 1291 | } |
| | 1292 | }} |
| 1233 | /> | 1293 | /> |
| 1234 | <TooltipPopup> | 1294 | ) : ( |
| 1235 | {surface.kind === "device" ? ( | 1295 | <Tooltip> |
| 1236 | <DeviceTabTooltip | 1296 | <TooltipTrigger |
| 1237 | surface={surface} | 1297 | render={ |
| 1238 | environmentId={props.environmentId} | 1298 | <button |
| 1239 | title={title} | 1299 | type="button" |
| 1240 | /> | 1300 | onDoubleClick={() => { |
| 1241 | ) : ( | 1301 | if (surface.kind === "device" && props.onRenameDevice) |
| 1242 | title | 1302 | setRenamingDevice(surface.id); |
| 1243 | )} | 1303 | }} |
| 1244 | </TooltipPopup> | 1304 | className="cursor-pointer flex min-w-0 items-center" |
| 1245 | </Tooltip> | 1305 | onClick={() => props.onActivate(surface)} |
| 1246 | )} | 1306 | > |
| 1247 | </div> | 1307 | <span className="truncate">{title}</span> |
| 1248 | ); | 1308 | </button> |
| 1249 | })} | 1309 | } |
| | 1310 | /> |
| | 1311 | <TooltipPopup> |
| | 1312 | {surface.kind === "device" ? ( |
| | 1313 | <DeviceTabTooltip |
| | 1314 | surface={surface} |
| | 1315 | environmentId={props.environmentId} |
| | 1316 | title={title} |
| | 1317 | /> |
| | 1318 | ) : ( |
| | 1319 | title |
| | 1320 | )} |
| | 1321 | </TooltipPopup> |
| | 1322 | </Tooltip> |
| | 1323 | )} |
| | 1324 | </SortableTab> |
| | 1325 | ); |
| | 1326 | })} |
| | 1327 | </SortableTabList> |
| 1250 | {props.open !== false ? ( | 1328 | {props.open !== false ? ( |
| 1251 | <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}> | 1329 | <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}> |
| 1252 | <MenuTrigger | 1330 | <MenuTrigger |