From 37aaa7b155a05b58267a22fa027ba025bc4c2ecb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 23 Mar 2026 12:03:02 +0700 Subject: [PATCH] feat(app): keep workspace tabs alive in panes --- .../app/src/components/split-container.tsx | 107 ++++++++++++-- .../screens/workspace/use-mounted-tab-set.ts | 64 ++++++++ .../screens/workspace/workspace-screen.tsx | 138 ++++++++++++++---- .../runtime/terminal-emulator-runtime.ts | 31 ++-- 4 files changed, 287 insertions(+), 53 deletions(-) create mode 100644 packages/app/src/screens/workspace/use-mounted-tab-set.ts diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index f8034f223..bed3dc1b3 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,5 +1,6 @@ import { Fragment, + memo, useCallback, useEffect, useMemo, @@ -43,6 +44,7 @@ import { deriveWorkspacePaneState, getWorkspacePaneDescriptors, } from "@/screens/workspace/workspace-pane-state"; +import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; import { WorkspacePaneContent, type WorkspacePaneContentModel, @@ -63,6 +65,7 @@ import { type WorkspaceLayout, } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity"; interface SplitContainerProps { layout: WorkspaceLayout; @@ -150,6 +153,68 @@ interface SplitPaneViewProps tabDropPreview: TabDropPreview | null; } +interface MountedTabSlotProps { + tabDescriptor: WorkspaceTabDescriptor; + isVisible: boolean; + isPaneFocused: boolean; + paneId: string; + buildPaneContentModel: (input: { + paneId: string; + isPaneFocused: boolean; + tab: WorkspaceTabDescriptor; + }) => WorkspacePaneContentModel; +} + +const MountedTabSlot = memo(function MountedTabSlot({ + tabDescriptor, + isVisible, + isPaneFocused, + paneId, + buildPaneContentModel, +}: MountedTabSlotProps) { + const content = useMemo( + () => + buildPaneContentModel({ + paneId, + isPaneFocused, + tab: tabDescriptor, + }), + [buildPaneContentModel, isPaneFocused, paneId, tabDescriptor], + ); + + return ( + + + + ); +}); + +function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) { + const cacheRef = useRef(new Map()); + const tabDescriptorMap = useMemo(() => { + const next = new Map(); + for (const tabDescriptor of tabDescriptors) { + const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId); + if ( + cachedDescriptor && + cachedDescriptor.key === tabDescriptor.key && + cachedDescriptor.kind === tabDescriptor.kind && + workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target) + ) { + next.set(tabDescriptor.tabId, cachedDescriptor); + continue; + } + next.set(tabDescriptor.tabId, tabDescriptor); + } + return next; + }, [tabDescriptors]); + useEffect(() => { + cacheRef.current = tabDescriptorMap; + }, [tabDescriptorMap]); + + return tabDescriptorMap; +} + const dropCollisionDetection: CollisionDetection = (args) => { const pointerHits = pointerWithin(args); const tabHits = pointerHits.filter( @@ -725,7 +790,18 @@ function SplitPaneView({ [pane, uiTabs], ); const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]); + const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]); + const tabDescriptorMap = useStableTabDescriptorMap(paneTabs); const activeTabDescriptor = paneState.activeTab?.descriptor ?? null; + const { mountedTabIds } = useMountedTabSet({ + activeTabId: activeTabDescriptor?.tabId ?? null, + allTabIds: paneTabIds, + cap: 3, + }); + const mountedPaneTabIds = useMemo( + () => paneTabIds.filter((tabId) => mountedTabIds.has(tabId)), + [mountedTabIds, paneTabIds], + ); const desktopTabRowItems = useMemo( () => paneTabs.map((tab) => ({ @@ -736,17 +812,6 @@ function SplitPaneView({ })), [activeTabDescriptor?.key, closingTabIds, hoveredCloseTabKey, paneTabs], ); - const paneContent = useMemo( - () => - activeTabDescriptor - ? buildPaneContentModel({ - paneId: pane.id, - isPaneFocused: isFocused, - tab: activeTabDescriptor, - }) - : null, - [activeTabDescriptor, buildPaneContentModel, isFocused, pane.id], - ); useEffect(() => { if (Platform.OS !== "web") { @@ -823,8 +888,24 @@ function SplitPaneView({ - {paneContent ? ( - + {mountedPaneTabIds.length > 0 ? ( + mountedPaneTabIds.map((tabId) => { + const tabDescriptor = tabDescriptorMap.get(tabId); + if (!tabDescriptor) { + return null; + } + + return ( + + ); + }) ) : ( (renderPaneEmptyState?.() ?? null) )} diff --git a/packages/app/src/screens/workspace/use-mounted-tab-set.ts b/packages/app/src/screens/workspace/use-mounted-tab-set.ts new file mode 100644 index 000000000..35eeaddbb --- /dev/null +++ b/packages/app/src/screens/workspace/use-mounted-tab-set.ts @@ -0,0 +1,64 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; + +interface UseMountedTabSetInput { + activeTabId: string | null; + allTabIds: string[]; + cap: number; +} + +interface UseMountedTabSetResult { + mountedTabIds: Set; +} + +function createInitialMountedTabIds(input: UseMountedTabSetInput): Set { + if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) { + return new Set(); + } + return new Set([input.activeTabId]); +} + +function setsEqual(left: Set, right: Set): boolean { + if (left.size !== right.size) { + return false; + } + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +} + +export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult { + const { activeTabId, allTabIds, cap } = input; + const allTabIdsKey = allTabIds.join("\u0000"); + const availableTabIds = useMemo(() => new Set(allTabIds), [allTabIdsKey]); + const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input)); + const lruRef = useRef( + activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : [], + ); + + useLayoutEffect(() => { + const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId)); + if (activeTabId && availableTabIds.has(activeTabId)) { + const existingIndex = nextLru.indexOf(activeTabId); + if (existingIndex >= 0) { + nextLru.splice(existingIndex, 1); + } + nextLru.unshift(activeTabId); + } + if (nextLru.length > cap) { + nextLru.length = cap; + } + + lruRef.current = nextLru; + setMountedTabIds((previousMountedTabIds) => { + const nextMountedTabIds = new Set(nextLru); + return setsEqual(previousMountedTabIds, nextMountedTabIds) + ? previousMountedTabIds + : nextMountedTabIds; + }); + }, [activeTabId, availableTabIds, cap]); + + return { mountedTabIds }; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index afd8ea21a..9b547b23a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -97,7 +97,9 @@ import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-sta import { buildWorkspacePaneContentModel, WorkspacePaneContent, + type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; +import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; import { buildBulkCloseConfirmationMessage, classifyBulkClosableTabs, @@ -438,6 +440,68 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ ); }); +interface MobileMountedTabSlotProps { + tabDescriptor: WorkspaceTabDescriptor; + isVisible: boolean; + isPaneFocused: boolean; + paneId: string | null; + buildPaneContentModel: (input: { + paneId: string | null; + isPaneFocused: boolean; + tab: WorkspaceTabDescriptor; + }) => WorkspacePaneContentModel; +} + +const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ + tabDescriptor, + isVisible, + isPaneFocused, + paneId, + buildPaneContentModel, +}: MobileMountedTabSlotProps) { + const content = useMemo( + () => + buildPaneContentModel({ + paneId, + isPaneFocused, + tab: tabDescriptor, + }), + [buildPaneContentModel, isPaneFocused, paneId, tabDescriptor], + ); + + return ( + + + + ); +}); + +function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) { + const cacheRef = useRef(new Map()); + const tabDescriptorMap = useMemo(() => { + const next = new Map(); + for (const tabDescriptor of tabDescriptors) { + const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId); + if ( + cachedDescriptor && + cachedDescriptor.key === tabDescriptor.key && + cachedDescriptor.kind === tabDescriptor.kind && + workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target) + ) { + next.set(tabDescriptor.tabId, cachedDescriptor); + continue; + } + next.set(tabDescriptor.tabId, tabDescriptor); + } + return next; + }, [tabDescriptors]); + useEffect(() => { + cacheRef.current = tabDescriptorMap; + }, [tabDescriptorMap]); + + return tabDescriptorMap; +} + export function WorkspaceScreen({ serverId, workspaceId }: WorkspaceScreenProps) { const isFocused = useIsFocused(); @@ -1726,37 +1790,33 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) retargetWorkspaceTab, }; }); - const activePaneContent = useMemo( - () => - activeTabDescriptor - ? buildPaneContentModel({ - tab: activeTabDescriptor, - paneId: focusedPaneTabState.pane?.id ?? null, - isPaneFocused: true, - focusPaneBeforeOpen: false, - }) - : null, - [activeTabDescriptor, buildPaneContentModel, focusedPaneTabState.pane?.id], + const focusedPaneId = focusedPaneTabState.pane?.id ?? null; + const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]); + const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs); + const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({ + activeTabId: activeTabDescriptor?.tabId ?? null, + allTabIds: focusedPaneTabIds, + cap: 3, + }); + const mountedFocusedPaneTabIds = useMemo( + () => focusedPaneTabIds.filter((tabId) => mountedFocusedPaneTabIdsSet.has(tabId)), + [focusedPaneTabIds, mountedFocusedPaneTabIdsSet], + ); + const buildMobilePaneContentModel = useCallback( + function buildMobilePaneContentModel(input: { + paneId: string | null; + tab: WorkspaceTabDescriptor; + isPaneFocused: boolean; + }) { + return buildPaneContentModel({ + tab: input.tab, + paneId: input.paneId, + isPaneFocused: input.isPaneFocused, + focusPaneBeforeOpen: false, + }); + }, + [buildPaneContentModel], ); - const prevActivePaneDeps = useRef({ - activeTabDescriptor, - buildPaneContentModel, - paneId: focusedPaneTabState.pane?.id, - }); - useEffect(() => { - const prev = prevActivePaneDeps.current; - if (prev.activeTabDescriptor !== activeTabDescriptor) - console.log("[activePaneContent] activeTabDescriptor changed"); - if (prev.buildPaneContentModel !== buildPaneContentModel) - console.log("[activePaneContent] buildPaneContentModel changed"); - if (prev.paneId !== focusedPaneTabState.pane?.id) - console.log("[activePaneContent] paneId changed"); - prevActivePaneDeps.current = { - activeTabDescriptor, - buildPaneContentModel, - paneId: focusedPaneTabState.pane?.id, - }; - }); const content = shouldRenderMissingWorkspaceDescriptor({ workspace: workspaceDescriptor, hasHydratedWorkspaces, @@ -1777,7 +1837,23 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) ) ) : ( - + mountedFocusedPaneTabIds.map((tabId) => { + const tabDescriptor = focusedPaneTabDescriptorMap.get(tabId); + if (!tabDescriptor) { + return null; + } + + return ( + + ); + }) ); const buildDesktopPaneContentModel = useCallback( diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 368fa4c9c..f25c420c5 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -1,5 +1,10 @@ +import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; +import { ImageAddon } from "@xterm/addon-image"; +import { LigaturesAddon } from "@xterm/addon-ligatures"; +import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal, type ITheme } from "@xterm/xterm"; import type { TerminalState } from "@server/shared/messages"; @@ -149,6 +154,9 @@ export class TerminalEmulatorRuntime { fontFamily: DEFAULT_TERMINAL_FONT_FAMILY, fontSize: 13, lineHeight: 1.0, + macOptionIsMeta: true, + minimumContrastRatio: 1, + rescaleOverlappingGlyphs: true, overviewRuler: { width: 8, }, @@ -160,6 +168,14 @@ export class TerminalEmulatorRuntime { let webglAddon: WebglAddon | null = null; terminal.loadAddon(fitAddon); terminal.loadAddon(unicode11Addon); + terminal.loadAddon(new WebLinksAddon()); + terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 })); + terminal.loadAddon(new ClipboardAddon()); + try { + terminal.loadAddon(new LigaturesAddon()); + } catch { + // Ligatures require Font Access API or compatible environment + } terminal.open(input.host); try { terminal.unicode.activeVersion = "11"; @@ -193,6 +209,8 @@ export class TerminalEmulatorRuntime { } }); + terminal.loadAddon(new ImageAddon()); + const restoreDocumentStyles = this.applyDocumentBoundsStyles({ root: input.root, }); @@ -211,6 +229,10 @@ export class TerminalEmulatorRuntime { return; } + if (input.root.offsetWidth === 0 || input.root.offsetHeight === 0) { + return; + } + try { currentFitAddon.fit(); } catch { @@ -657,7 +679,6 @@ export class TerminalEmulatorRuntime { private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void { const viewportElement = input.host.querySelector(".xterm-viewport"); - const screenElement = input.host.querySelector(".xterm-screen"); const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? ""; const previousViewportTouchAction = viewportElement?.style.touchAction ?? ""; @@ -666,8 +687,6 @@ export class TerminalEmulatorRuntime { const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? ""; const previousViewportWebkitOverflowScrolling = viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? ""; - const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? ""; - if (viewportElement) { viewportElement.style.overscrollBehavior = "none"; viewportElement.style.touchAction = "pan-y"; @@ -676,9 +695,6 @@ export class TerminalEmulatorRuntime { viewportElement.style.pointerEvents = "auto"; viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch"); } - if (screenElement) { - screenElement.style.pointerEvents = "none"; - } return () => { if (viewportElement) { @@ -692,9 +708,6 @@ export class TerminalEmulatorRuntime { previousViewportWebkitOverflowScrolling, ); } - if (screenElement) { - screenElement.style.pointerEvents = previousScreenPointerEvents; - } }; }