feat(app): keep workspace tabs alive in panes
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface UseMountedTabSetInput {
|
||||
activeTabId: string | null;
|
||||
allTabIds: string[];
|
||||
cap: number;
|
||||
}
|
||||
|
||||
interface UseMountedTabSetResult {
|
||||
mountedTabIds: Set<string>;
|
||||
}
|
||||
|
||||
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
|
||||
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set<string>([input.activeTabId]);
|
||||
}
|
||||
|
||||
function setsEqual(left: Set<string>, right: Set<string>): 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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
||||
<WorkspacePaneContent content={content} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
|
||||
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
|
||||
const tabDescriptorMap = useMemo(() => {
|
||||
const next = new Map<string, WorkspaceTabDescriptor>();
|
||||
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)
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<WorkspacePaneContent content={activePaneContent!} />
|
||||
mountedFocusedPaneTabIds.map((tabId) => {
|
||||
const tabDescriptor = focusedPaneTabDescriptorMap.get(tabId);
|
||||
if (!tabDescriptor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileMountedTabSlot
|
||||
key={tabId}
|
||||
tabDescriptor={tabDescriptor}
|
||||
isVisible={tabId === activeTabDescriptor.tabId}
|
||||
isPaneFocused={tabId === activeTabDescriptor.tabId}
|
||||
paneId={focusedPaneId}
|
||||
buildPaneContentModel={buildMobilePaneContentModel}
|
||||
/>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const buildDesktopPaneContentModel = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user