diff --git a/src/renderer/src/lib/emulator-right-split-target.ts b/src/renderer/src/lib/emulator-right-split-target.ts new file mode 100644 index 00000000000..5d19b0d4e9a --- /dev/null +++ b/src/renderer/src/lib/emulator-right-split-target.ts @@ -0,0 +1,52 @@ +import type { TabGroupLayoutNode } from '../../../shared/types' + +type SearchResult = { + containsSource: boolean + reusableGroupId: string | null +} + +function firstLeafGroupId(node: TabGroupLayoutNode): string { + if (node.type === 'leaf') { + return node.groupId + } + return firstLeafGroupId(node.first) +} + +function findReusableRightSplitTarget( + node: TabGroupLayoutNode, + sourceGroupId: string +): SearchResult { + if (node.type === 'leaf') { + return { + containsSource: node.groupId === sourceGroupId, + reusableGroupId: null + } + } + + const first = findReusableRightSplitTarget(node.first, sourceGroupId) + if (first.containsSource) { + return { + containsSource: true, + // Why: prefer the closest existing right pane; it preserves nested split intent. + reusableGroupId: + first.reusableGroupId ?? + (node.direction === 'horizontal' ? firstLeafGroupId(node.second) : null) + } + } + + const second = findReusableRightSplitTarget(node.second, sourceGroupId) + return { + containsSource: second.containsSource, + reusableGroupId: second.reusableGroupId + } +} + +export function findReusableRightSplitGroupId( + layout: TabGroupLayoutNode | undefined, + sourceGroupId: string +): string | null { + if (!layout) { + return null + } + return findReusableRightSplitTarget(layout, sourceGroupId).reusableGroupId +} diff --git a/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts b/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts index afde48f80a7..43ecb03e0f3 100644 --- a/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts +++ b/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts @@ -10,6 +10,7 @@ const mockStoreState = vi.hoisted(() => ({ dropUnifiedTab: vi.fn(), focusGroup: vi.fn(), groupsByWorktree: {} as Record, + layoutByWorktree: {} as Record, settings: { mobileEmulatorEnabled: true }, setActiveTab: vi.fn(), setActiveTabType: vi.fn(), @@ -34,6 +35,7 @@ describe('ensureSimulatorTab', () => { mockStoreState.activeGroupIdByWorktree = { 'wt-1': 'group-1' } mockStoreState.activeWorktreeId = 'wt-1' mockStoreState.groupsByWorktree = { 'wt-1': [{ id: 'group-1' }] } + mockStoreState.layoutByWorktree = { 'wt-1': { type: 'leaf', groupId: 'group-1' } } mockStoreState.settings = { mobileEmulatorEnabled: true } mockStoreState.unifiedTabsByWorktree = { 'wt-1': [{ id: 'sim-1', groupId: 'group-1', contentType: 'simulator' }] @@ -116,6 +118,37 @@ describe('ensureSimulatorTab', () => { expect(mockStoreState.setActiveTabType).not.toHaveBeenCalled() }) + it('reuses an existing right split when requested', async () => { + mockStoreState.groupsByWorktree = { 'wt-1': [{ id: 'group-1' }, { id: 'group-2' }] } + mockStoreState.layoutByWorktree = { + 'wt-1': { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-1' }, + second: { type: 'leaf', groupId: 'group-2' } + } + } + mockStoreState.unifiedTabsByWorktree = { 'wt-1': [] } + mockStoreState.createUnifiedTab.mockReturnValue({ + id: 'sim-2', + groupId: 'group-2', + contentType: 'simulator' + }) + const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + + expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe('sim-2') + + expect(mockStoreState.createUnifiedTabInSplit).not.toHaveBeenCalled() + expect(mockStoreState.createUnifiedTab).toHaveBeenCalledWith('wt-1', 'simulator', { + label: 'Mobile Emulator', + targetGroupId: 'group-2', + activate: true + }) + expect(mockStoreState.activateTab).toHaveBeenCalledWith('sim-2') + expect(mockStoreState.focusGroup).toHaveBeenCalledWith('wt-1', 'group-2') + expect(mockStoreState.setActiveTabType).toHaveBeenCalledWith('simulator') + }) + it('falls back to the source group when atomic right split creation fails', async () => { mockStoreState.unifiedTabsByWorktree = { 'wt-1': [] } mockStoreState.createUnifiedTabInSplit.mockReturnValue(null) diff --git a/src/renderer/src/lib/ensure-simulator-tab.ts b/src/renderer/src/lib/ensure-simulator-tab.ts index 4c226abb0fb..43f83fb695a 100644 --- a/src/renderer/src/lib/ensure-simulator-tab.ts +++ b/src/renderer/src/lib/ensure-simulator-tab.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { findReusableRightSplitGroupId } from './emulator-right-split-target' import { cancelPendingSimulatorPaneShutdown } from './simulator-pane-shutdown-scheduler' import { shouldShutdownSimulatorForPaneUnmountFromTabs } from './simulator-tab-shutdown' @@ -58,6 +59,22 @@ export function ensureSimulatorTab( } if (options?.placement === 'rightSplit' && shouldSurface) { + const reusableRightGroupId = findReusableRightSplitGroupId( + store.layoutByWorktree[worktreeId], + sourceGroupId + ) + if (reusableRightGroupId) { + const tab = store.createUnifiedTab(worktreeId, 'simulator', { + label: 'Mobile Emulator', + targetGroupId: reusableRightGroupId, + activate: true + }) + store.activateTab(tab.id) + store.setActiveTabType('simulator') + store.focusGroup(worktreeId, tab.groupId) + return tab.id + } + // Why: publish the simulator directly in its split group. A two-step // create-then-move can persist the midpoint during dev reload/HMR. const splitTab = store.createUnifiedTabInSplit(