| ... | ... | @@ -14,6 +14,17 @@ import type { |
| 14 | 14 | ResolvedKeybindingsConfig, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | 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 | 28 | import { |
| 18 | 29 | Smartphone, |
| 19 | 30 | ChevronDown, |
| ... | ... | @@ -27,6 +38,7 @@ import { |
| 27 | 38 | } from "lucide-react"; |
| 28 | 39 | import { Volume2, VolumeOff } from "lucide"; |
| 29 | 40 | import { |
| 41 | type ComponentProps, |
| 30 | 42 | type KeyboardEvent as ReactKeyboardEvent, |
| 31 | 43 | type MouseEvent as ReactMouseEvent, |
| 32 | 44 | type ReactElement, |
| ... | ... | @@ -115,6 +127,8 @@ interface RightPanelTabsProps { |
| 115 | 127 | onCloseOtherSurfaces: (surface: RightPanelSurface) => void; |
| 116 | 128 | onCloseSurfacesToRight: (surface: RightPanelSurface) => void; |
| 117 | 129 | onCloseAllSurfaces: () => void; |
| 130 | /** Tabs are draggable only when the owner can persist the new order. */ |
| 131 | onMoveSurface?: (surfaceId: string, toIndex: number) => void; |
| 118 | 132 | onCopyFilePath: (relativePath: string) => void; |
| 119 | 133 | onAddBrowser: () => void; |
| 120 | 134 | /** |
| ... | ... | @@ -199,6 +213,7 @@ type TabContextMenuAction = |
| 199 | 213 | | "close-all"; |
| 200 | 214 | |
| 201 | 215 | const TAB_SCROLL_EDGE_TOLERANCE = 1; |
| 216 | const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; |
| 202 | 217 | |
| 203 | 218 | function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { |
| 204 | 219 | return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null; |
| ... | ... | @@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({ |
| 795 | 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 | 868 | export function RightPanelTabs(props: RightPanelTabsProps) { |
| 799 | 869 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; |
| 800 | 870 | const browserProfiles = useBrowserDefaults().profiles; |
| ... | ... | @@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 1118 | 1188 | data-right-panel-tab-list |
| 1119 | 1189 | > |
| 1120 | 1190 | <div className="flex h-full w-max min-w-full items-center gap-1"> |
| 1121 | | {props.surfaces.map((surface) => { |
| 1122 | | const active = surface.id === props.activeSurfaceId; |
| 1123 | | const pending = props.pendingSurfaceIds.has(surface.id); |
| 1124 | | const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); |
| 1125 | | const previewTabId = previewTabIdOf(surface, props.previewSessions); |
| 1126 | | // Desktop state is keyed by the session id, but desktop actions |
| 1127 | | // must be addressed with the runtime id. |
| 1128 | | const audio = tabAudioState( |
| 1129 | | previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, |
| 1130 | | ); |
| 1131 | | const audioRuntimeTabId = previewTabId |
| 1132 | | ? (props.previewRuntimeTabId?.(previewTabId) ?? null) |
| 1133 | | : null; |
| 1134 | | return ( |
| 1135 | | <div |
| 1136 | | key={surface.id} |
| 1137 | | data-active-tab={active} |
| 1138 | | onMouseDown={handleTabMouseDown} |
| 1139 | | onAuxClick={(event) => handleTabAuxClick(event, surface)} |
| 1140 | | onContextMenu={(event) => void handleTabContextMenu(event, surface)} |
| 1141 | | className={cn( |
| 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", |
| 1143 | | ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", |
| 1144 | | active |
| 1145 | | ? "bg-accent text-foreground" |
| 1146 | | : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", |
| 1147 | | )} |
| 1148 | | > |
| 1149 | | <PanelTabCloseButton |
| 1150 | | label={`Close ${title}`} |
| 1151 | | onClick={() => props.onCloseSurface(surface)} |
| 1191 | <SortableTabList surfaces={props.surfaces} onMove={props.onMoveSurface}> |
| 1192 | {props.surfaces.map((surface) => { |
| 1193 | const active = surface.id === props.activeSurfaceId; |
| 1194 | const pending = props.pendingSurfaceIds.has(surface.id); |
| 1195 | const title = surfaceTitle( |
| 1196 | surface, |
| 1197 | props.previewSessions, |
| 1198 | props.terminalLabelsById, |
| 1199 | ); |
| 1200 | const previewTabId = previewTabIdOf(surface, props.previewSessions); |
| 1201 | // Desktop state is keyed by the session id, but desktop actions |
| 1202 | // must be addressed with the runtime id. |
| 1203 | const audio = tabAudioState( |
| 1204 | previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, |
| 1205 | ); |
| 1206 | const audioRuntimeTabId = previewTabId |
| 1207 | ? (props.previewRuntimeTabId?.(previewTabId) ?? null) |
| 1208 | : null; |
| 1209 | return ( |
| 1210 | <SortableTab |
| 1211 | key={surface.id} |
| 1212 | surfaceId={surface.id} |
| 1213 | disabled={!props.onMoveSurface || renamingDevice === surface.id} |
| 1214 | data-active-tab={active} |
| 1215 | onMouseDown={handleTabMouseDown} |
| 1216 | onAuxClick={(event) => handleTabAuxClick(event, surface)} |
| 1217 | onContextMenu={(event) => void handleTabContextMenu(event, surface)} |
| 1218 | className={cn( |
| 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", |
| 1220 | ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", |
| 1221 | active |
| 1222 | ? "bg-accent text-foreground" |
| 1223 | : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", |
| 1224 | )} |
| 1152 | 1225 | > |
| 1153 | | <SurfaceIcon |
| 1154 | | surface={surface} |
| 1155 | | sessions={props.previewSessions} |
| 1156 | | desktopByTabId={props.desktopByTabId} |
| 1157 | | theme={resolvedTheme} |
| 1158 | | environmentId={props.environmentId} |
| 1159 | | pullRequestStatusSeeds={props.pullRequestStatusSeeds} |
| 1160 | | /> |
| 1161 | | {pending ? ( |
| 1162 | | <span |
| 1163 | | className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current" |
| 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 | | } |
| 1226 | <PanelTabCloseButton |
| 1227 | label={`Close ${title}`} |
| 1228 | onClick={() => props.onCloseSurface(surface)} |
| 1229 | > |
| 1230 | <SurfaceIcon |
| 1231 | surface={surface} |
| 1232 | sessions={props.previewSessions} |
| 1233 | desktopByTabId={props.desktopByTabId} |
| 1234 | theme={resolvedTheme} |
| 1235 | environmentId={props.environmentId} |
| 1236 | pullRequestStatusSeeds={props.pullRequestStatusSeeds} |
| 1191 | 1237 | /> |
| 1192 | | <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup> |
| 1193 | | </Tooltip> |
| 1194 | | )} |
| 1195 | | {renamingDevice === surface.id ? ( |
| 1196 | | <input |
| 1197 | | aria-label="Device tab name" |
| 1198 | | className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-inset ring-ring" |
| 1199 | | defaultValue={title} |
| 1200 | | ref={(element) => { |
| 1201 | | element?.focus(); |
| 1202 | | element?.select(); |
| 1203 | | }} |
| 1204 | | onBlur={(event) => { |
| 1205 | | props.onRenameDevice?.(surface.id, event.currentTarget.value); |
| 1206 | | setRenamingDevice(null); |
| 1207 | | }} |
| 1208 | | onKeyDown={(event) => { |
| 1209 | | event.stopPropagation(); |
| 1210 | | if (event.key === "Enter") event.currentTarget.blur(); |
| 1211 | | if (event.key === "Escape") { |
| 1212 | | event.currentTarget.value = title; |
| 1213 | | event.currentTarget.blur(); |
| 1214 | | } |
| 1215 | | }} |
| 1216 | | /> |
| 1217 | | ) : ( |
| 1218 | | <Tooltip> |
| 1219 | | <TooltipTrigger |
| 1220 | | render={ |
| 1221 | | <button |
| 1222 | | type="button" |
| 1223 | | onDoubleClick={() => { |
| 1224 | | if (surface.kind === "device" && props.onRenameDevice) |
| 1225 | | setRenamingDevice(surface.id); |
| 1226 | | }} |
| 1227 | | className="cursor-pointer flex min-w-0 items-center" |
| 1228 | | onClick={() => props.onActivate(surface)} |
| 1229 | | > |
| 1230 | | <span className="truncate">{title}</span> |
| 1231 | | </button> |
| 1232 | | } |
| 1238 | {pending ? ( |
| 1239 | <span |
| 1240 | className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current" |
| 1241 | aria-hidden |
| 1242 | /> |
| 1243 | ) : null} |
| 1244 | </PanelTabCloseButton> |
| 1245 | {audio === "none" || !audioRuntimeTabId ? null : ( |
| 1246 | <Tooltip> |
| 1247 | <TooltipTrigger |
| 1248 | render={ |
| 1249 | <button |
| 1250 | type="button" |
| 1251 | className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted" |
| 1252 | aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`} |
| 1253 | onClick={(event) => { |
| 1254 | // Sibling of the close button, inside a tab that |
| 1255 | // activates on click: keep this to the toggle. |
| 1256 | event.stopPropagation(); |
| 1257 | void previewBridge |
| 1258 | ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") |
| 1259 | .catch(() => undefined); |
| 1260 | }} |
| 1261 | > |
| 1262 | <MorphIcon |
| 1263 | className="size-3" |
| 1264 | icon={audio === "muted" ? VolumeOff : Volume2} |
| 1265 | /> |
| 1266 | </button> |
| 1267 | } |
| 1268 | /> |
| 1269 | <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup> |
| 1270 | </Tooltip> |
| 1271 | )} |
| 1272 | {renamingDevice === surface.id ? ( |
| 1273 | <input |
| 1274 | aria-label="Device tab name" |
| 1275 | className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-inset ring-ring" |
| 1276 | defaultValue={title} |
| 1277 | ref={(element) => { |
| 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> |
| 1235 | | {surface.kind === "device" ? ( |
| 1236 | | <DeviceTabTooltip |
| 1237 | | surface={surface} |
| 1238 | | environmentId={props.environmentId} |
| 1239 | | title={title} |
| 1240 | | /> |
| 1241 | | ) : ( |
| 1242 | | title |
| 1243 | | )} |
| 1244 | | </TooltipPopup> |
| 1245 | | </Tooltip> |
| 1246 | | )} |
| 1247 | | </div> |
| 1248 | | ); |
| 1249 | | })} |
| 1294 | ) : ( |
| 1295 | <Tooltip> |
| 1296 | <TooltipTrigger |
| 1297 | render={ |
| 1298 | <button |
| 1299 | type="button" |
| 1300 | onDoubleClick={() => { |
| 1301 | if (surface.kind === "device" && props.onRenameDevice) |
| 1302 | setRenamingDevice(surface.id); |
| 1303 | }} |
| 1304 | className="cursor-pointer flex min-w-0 items-center" |
| 1305 | onClick={() => props.onActivate(surface)} |
| 1306 | > |
| 1307 | <span className="truncate">{title}</span> |
| 1308 | </button> |
| 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 | 1328 | {props.open !== false ? ( |
| 1251 | 1329 | <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}> |
| 1252 | 1330 | <MenuTrigger |