From 741974c59cacfbbea40afb8ea9484c4b519f83b6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:39:01 -0700 Subject: [PATCH] refactor: add split group model foundations (#644) --- docs/split-groups-rollout-pr1.md | 26 + src/renderer/src/lib/workspace-session.ts | 5 +- src/renderer/src/store/slices/editor.ts | 169 +++- src/renderer/src/store/slices/tabs-helpers.ts | 67 +- .../src/store/slices/tabs-hydration.ts | 75 +- src/renderer/src/store/slices/tabs.test.ts | 146 +++ src/renderer/src/store/slices/tabs.ts | 842 +++++++++++++----- src/shared/types.ts | 21 + 8 files changed, 1075 insertions(+), 276 deletions(-) create mode 100644 docs/split-groups-rollout-pr1.md diff --git a/docs/split-groups-rollout-pr1.md b/docs/split-groups-rollout-pr1.md new file mode 100644 index 00000000..44f2053b --- /dev/null +++ b/docs/split-groups-rollout-pr1.md @@ -0,0 +1,26 @@ +# Split Groups PR 1: Model Foundations + +This branch lands the behavior-neutral tab-group model groundwork. + +Scope: +- add persisted tab-group layout state +- add active-group persistence and hydration +- add group-aware unified-tab helpers in the store +- connect editor/open-file flows to the unified tab-group model + +What Is Actually Hooked Up In This PR: +- the store persists `groupsByWorktree`, `layoutByWorktree`, and `activeGroupIdByWorktree` +- workspace session save/hydration includes tab-group layouts +- editor actions create and activate unified tabs through the group model +- the visible workspace renderer is still the legacy single-surface path + +What Is Not Hooked Up Yet: +- `Terminal.tsx` does not render split groups +- no split-group UI components are mounted +- no PTY lifecycle changes land here +- no worktree activation fallback changes land here + +Non-goals: +- no split-group UI rollout +- no terminal PTY lifecycle changes +- no worktree activation changes diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 06f32359..ae803c61 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -24,6 +24,7 @@ type WorkspaceSessionSnapshot = Pick< | 'activeBrowserTabIdByWorktree' | 'unifiedTabsByWorktree' | 'groupsByWorktree' + | 'layoutByWorktree' | 'activeGroupIdByWorktree' > @@ -113,6 +114,8 @@ export function buildWorkspaceSessionPayload( snapshot.activeBrowserTabIdByWorktree ), unifiedTabs: snapshot.unifiedTabsByWorktree, - tabGroups: snapshot.groupsByWorktree + tabGroups: snapshot.groupsByWorktree, + tabGroupLayouts: snapshot.layoutByWorktree, + activeGroupIdByWorktree: snapshot.activeGroupIdByWorktree } } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 63d7dc56..2a2235bb 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -258,7 +258,34 @@ export type EditorSlice = { hydrateEditorSession: (session: WorkspaceSessionState) => void } -export const createEditorSlice: StateCreator = (set) => ({ +function openWorkspaceEditorItem( + state: AppState, + fileId: string, + worktreeId: string, + label: string, + contentType: 'editor' | 'diff' | 'conflict-review', + isPreview?: boolean +): string { + const targetGroupId = + state.activeGroupIdByWorktree?.[worktreeId] ?? state.groupsByWorktree?.[worktreeId]?.[0]?.id + if (!targetGroupId) { + return fileId + } + const existing = state.findTabForEntityInGroup?.(worktreeId, targetGroupId, fileId, contentType) + if (existing) { + state.activateTab?.(existing.id) + return existing.id + } + const created = state.createUnifiedTab?.(worktreeId, contentType, { + entityId: fileId, + label, + isPreview, + targetGroupId + }) + return created?.id ?? fileId +} + +export const createEditorSlice: StateCreator = (set, get) => ({ editorDrafts: {}, setEditorDraft: (fileId, content) => set((s) => ({ @@ -346,7 +373,7 @@ export const createEditorSlice: StateCreator = (s } }), - openFile: (file, options) => + openFile: (file, options) => { set((s) => { const id = file.filePath const existing = s.openFiles.find((f) => f.id === id) @@ -476,9 +503,22 @@ export const createEditorSlice: StateCreator = (s ...tabBarUpdate, ...activeResult } - }), + }) + void openWorkspaceEditorItem( + get(), + file.filePath, + file.worktreeId, + file.relativePath, + file.mode === 'conflict-review' + ? 'conflict-review' + : file.mode === 'diff' + ? 'diff' + : 'editor', + options?.preview ?? false + ) + }, - pinFile: (fileId, _tabId) => + pinFile: (fileId, tabId) => { set((s) => { const file = s.openFiles.find((f) => f.id === fileId) if (!file?.isPreview) { @@ -487,7 +527,16 @@ export const createEditorSlice: StateCreator = (s return { openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isPreview: undefined } : f)) } - }), + }) + const state = get() + for (const tabs of Object.values(state.unifiedTabsByWorktree ?? {})) { + for (const item of tabs) { + if (item.entityId === fileId && (!tabId || item.id === tabId)) { + state.pinTab?.(item.id) + } + } + } + }, // Why: closing a tab does NOT clear Resolved locally state. If the file is // still present in Changes or Staged Changes, the continuity badge should @@ -589,7 +638,19 @@ export const createEditorSlice: StateCreator = (s } }), - closeAllFiles: () => + closeAllFiles: () => { + const state = get() + const activeWorktreeId = state.activeWorktreeId + const closingItemIds = Object.values(state.unifiedTabsByWorktree ?? {}) + .flat() + .filter( + (item) => + (item.contentType === 'editor' || + item.contentType === 'diff' || + item.contentType === 'conflict-review') && + (!activeWorktreeId || item.worktreeId === activeWorktreeId) + ) + .map((item) => item.id) set((s) => { const activeWorktreeId = s.activeWorktreeId if (!activeWorktreeId) { @@ -653,9 +714,13 @@ export const createEditorSlice: StateCreator = (s // to an old match unexpectedly. pendingEditorReveal: null } - }), + }) + for (const itemId of closingItemIds) { + get().closeUnifiedTab?.(itemId) + } + }, - setActiveFile: (fileId) => + setActiveFile: (fileId) => { set((s) => { const file = s.openFiles.find((f) => f.id === fileId) const worktreeId = file?.worktreeId @@ -665,7 +730,25 @@ export const createEditorSlice: StateCreator = (s ? { ...s.activeFileIdByWorktree, [worktreeId]: fileId } : s.activeFileIdByWorktree } - }), + }) + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return + } + const groupId = + state.activeGroupIdByWorktree?.[worktreeId] ?? state.groupsByWorktree?.[worktreeId]?.[0]?.id + if (!groupId) { + return + } + const item = + state.findTabForEntityInGroup?.(worktreeId, groupId, fileId, 'editor') ?? + state.findTabForEntityInGroup?.(worktreeId, groupId, fileId, 'diff') ?? + state.findTabForEntityInGroup?.(worktreeId, groupId, fileId, 'conflict-review') + if (item) { + state.activateTab?.(item.id) + } + }, reorderFiles: (fileIds) => set((s) => { @@ -699,7 +782,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isUntitled: undefined } : f)) })), - openDiff: (worktreeId, filePath, relativePath, language, staged) => + openDiff: (worktreeId, filePath, relativePath, language, staged) => { set((s) => { const diffSource: DiffSource = staged ? 'staged' : 'unstaged' const id = `${worktreeId}::diff::${diffSource}::${relativePath}` @@ -747,12 +830,20 @@ export const createEditorSlice: StateCreator = (s activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } } - }), + }) + void openWorkspaceEditorItem( + get(), + `${worktreeId}::diff::${staged ? 'staged' : 'unstaged'}::${relativePath}`, + worktreeId, + relativePath, + 'diff' + ) + }, - openBranchDiff: (worktreeId, worktreePath, entry, compare, language) => + openBranchDiff: (worktreeId, worktreePath, entry, compare, language) => { + const branchCompare = toBranchCompareSnapshot(compare) + const id = `${worktreeId}::diff::branch::${compare.baseRef}::${branchCompare.compareVersion}::${entry.path}` set((s) => { - const branchCompare = toBranchCompareSnapshot(compare) - const id = `${worktreeId}::diff::branch::${compare.baseRef}::${branchCompare.compareVersion}::${entry.path}` const existing = s.openFiles.find((f) => f.id === id) if (existing) { return { @@ -798,9 +889,19 @@ export const createEditorSlice: StateCreator = (s activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } } - }), + }) + void openWorkspaceEditorItem(get(), id, worktreeId, entry.path, 'diff') + }, - openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) => + openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) => { + const id = areaFilter + ? `${worktreeId}::all-diffs::uncommitted::${areaFilter}` + : `${worktreeId}::all-diffs::uncommitted` + const label = areaFilter + ? ({ staged: 'Staged Changes', unstaged: 'Changes', untracked: 'Untracked Files' }[ + areaFilter + ] ?? 'All Changes') + : 'All Changes' set((s) => { const relevantEntries = (s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => { if (areaFilter) { @@ -868,11 +969,13 @@ export const createEditorSlice: StateCreator = (s activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } } - }), + }) + void openWorkspaceEditorItem(get(), id, worktreeId, label, 'diff') + }, - openConflictFile: (worktreeId, worktreePath, entry, language) => + openConflictFile: (worktreeId, worktreePath, entry, language) => { + const absolutePath = joinPath(worktreePath, entry.path) set((s) => { - const absolutePath = joinPath(worktreePath, entry.path) const id = absolutePath const conflict = toOpenConflictMetadata(entry) const existing = s.openFiles.find((f) => f.id === id) @@ -938,16 +1041,18 @@ export const createEditorSlice: StateCreator = (s ? s.trackedConflictPathsByWorktree : { ...s.trackedConflictPathsByWorktree, [worktreeId]: nextTracked } } - }), + }) + void openWorkspaceEditorItem(get(), absolutePath, worktreeId, entry.path, 'editor') + }, // Why: Review conflicts is launched from Source Control into the editor area, // not from Checks. Merge-conflict review is source-control work, not CI/PR // status. The tab renders from a stored snapshot (entries + timestamp), not // from live status on every paint, so the list is stable even if the live // unresolved set changes between polls. - openConflictReview: (worktreeId, worktreePath, entries, source) => + openConflictReview: (worktreeId, worktreePath, entries, source) => { + const id = `${worktreeId}::conflict-review` set((s) => { - const id = `${worktreeId}::conflict-review` const conflictReview: ConflictReviewState = { source, snapshotTimestamp: Date.now(), @@ -996,13 +1101,15 @@ export const createEditorSlice: StateCreator = (s activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } } - }), + }) + void openWorkspaceEditorItem(get(), id, worktreeId, 'Conflict Review', 'conflict-review') + }, - openBranchAllDiffs: (worktreeId, worktreePath, compare, alternate) => + openBranchAllDiffs: (worktreeId, worktreePath, compare, alternate) => { + const branchCompare = toBranchCompareSnapshot(compare) + const id = `${worktreeId}::all-diffs::branch::${compare.baseRef}::${branchCompare.compareVersion}` set((s) => { - const branchCompare = toBranchCompareSnapshot(compare) const branchEntriesSnapshot = s.gitBranchChangesByWorktree[worktreeId] ?? [] - const id = `${worktreeId}::all-diffs::branch::${compare.baseRef}::${branchCompare.compareVersion}` const existing = s.openFiles.find((f) => f.id === id) if (existing) { return { @@ -1048,7 +1155,15 @@ export const createEditorSlice: StateCreator = (s activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } } - }), + }) + void openWorkspaceEditorItem( + get(), + id, + worktreeId, + `Branch Changes (${compare.baseRef})`, + 'diff' + ) + }, // Cursor line tracking editorCursorLine: {}, diff --git a/src/renderer/src/store/slices/tabs-helpers.ts b/src/renderer/src/store/slices/tabs-helpers.ts index 59d14f4c..72de5442 100644 --- a/src/renderer/src/store/slices/tabs-helpers.ts +++ b/src/renderer/src/store/slices/tabs-helpers.ts @@ -1,4 +1,4 @@ -import type { Tab, TabGroup } from '../../../../shared/types' +import type { Tab, TabContentType, TabGroup, WorkspaceSessionState } from '../../../../shared/types' export function findTabAndWorktree( tabsByWorktree: Record, @@ -22,16 +22,50 @@ export function findGroupForTab( return groups.find((g) => g.id === groupId) ?? null } +export function findGroupAndWorktree( + groupsByWorktree: Record, + groupId: string +): { group: TabGroup; worktreeId: string } | null { + for (const [worktreeId, groups] of Object.entries(groupsByWorktree)) { + const group = groups.find((candidate) => candidate.id === groupId) + if (group) { + return { group, worktreeId } + } + } + return null +} + +export function findTabByEntityInGroup( + tabsByWorktree: Record, + worktreeId: string, + groupId: string, + entityId: string, + contentType?: Tab['contentType'] +): Tab | null { + const tabs = tabsByWorktree[worktreeId] ?? [] + return ( + tabs.find( + (tab) => + tab.groupId === groupId && + tab.entityId === entityId && + (contentType ? tab.contentType === contentType : true) + ) ?? null + ) +} + export function ensureGroup( groupsByWorktree: Record, activeGroupIdByWorktree: Record, - worktreeId: string + worktreeId: string, + preferredGroupId?: string ): { group: TabGroup groupsByWorktree: Record activeGroupIdByWorktree: Record } { - const existing = groupsByWorktree[worktreeId]?.[0] + const existing = + groupsByWorktree[worktreeId]?.find((group) => group.id === preferredGroupId) ?? + groupsByWorktree[worktreeId]?.[0] if (existing) { return { group: existing, groupsByWorktree, activeGroupIdByWorktree } } @@ -63,6 +97,33 @@ export function updateGroup(groups: TabGroup[], updated: TabGroup): TabGroup[] { return groups.map((g) => (g.id === updated.id ? updated : g)) } +export function isTransientEditorContentType(contentType: TabContentType): boolean { + return contentType === 'diff' || contentType === 'conflict-review' +} + +export function getPersistedEditFileIdsByWorktree( + session: WorkspaceSessionState +): Record> { + return Object.fromEntries( + Object.entries(session.openFilesByWorktree ?? {}).map(([worktreeId, files]) => [ + worktreeId, + new Set(files.map((file) => file.filePath)) + ]) + ) +} + +export function selectHydratedActiveGroupId( + groups: TabGroup[], + persistedActiveGroupId?: string +): string | undefined { + const preferredGroups = groups.filter((group) => group.tabOrder.length > 0) + const candidates = preferredGroups.length > 0 ? preferredGroups : groups + if (persistedActiveGroupId && candidates.some((group) => group.id === persistedActiveGroupId)) { + return persistedActiveGroupId + } + return candidates[0]?.id +} + /** * Apply a partial update to a single tab, returning the new `unifiedTabsByWorktree` * map. Returns `null` if the tab is not found (callers should return `{}` to the diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index 8159c23a..81bdf1d3 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -1,9 +1,20 @@ -import type { Tab, TabGroup, WorkspaceSessionState } from '../../../../shared/types' +import type { + Tab, + TabGroup, + TabGroupLayoutNode, + WorkspaceSessionState +} from '../../../../shared/types' +import { + getPersistedEditFileIdsByWorktree, + isTransientEditorContentType, + selectHydratedActiveGroupId +} from './tabs-helpers' type HydratedTabState = { unifiedTabsByWorktree: Record groupsByWorktree: Record activeGroupIdByWorktree: Record + layoutByWorktree: Record } function hydrateUnifiedFormat( @@ -13,6 +24,8 @@ function hydrateUnifiedFormat( const tabsByWorktree: Record = {} const groupsByWorktree: Record = {} const activeGroupIdByWorktree: Record = {} + const layoutByWorktree: Record = {} + const persistedEditFileIdsByWorktree = getPersistedEditFileIdsByWorktree(session) for (const [worktreeId, tabs] of Object.entries(session.unifiedTabs!)) { if (!validWorktreeIds.has(worktreeId)) { @@ -21,9 +34,22 @@ function hydrateUnifiedFormat( if (tabs.length === 0) { continue } - tabsByWorktree[worktreeId] = [...tabs].sort( - (a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt - ) + const persistedEditFileIds = persistedEditFileIdsByWorktree[worktreeId] ?? new Set() + tabsByWorktree[worktreeId] = [...tabs] + .map((tab) => ({ + ...tab, + entityId: tab.entityId ?? tab.id + })) + .filter((tab) => { + if (!isTransientEditorContentType(tab.contentType)) { + return true + } + // Why: restore skips backing editor state for transient diff/conflict + // items. Hydration must drop their tab chrome too or the split group + // comes back pointing at a document that no longer exists. + return persistedEditFileIds.has(tab.entityId) + }) + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) } for (const [worktreeId, groups] of Object.entries(session.tabGroups!)) { @@ -35,17 +61,35 @@ function hydrateUnifiedFormat( } const validTabIds = new Set((tabsByWorktree[worktreeId] ?? []).map((t) => t.id)) - const validatedGroups = groups.map((g) => ({ - ...g, - tabOrder: g.tabOrder.filter((tid) => validTabIds.has(tid)), - activeTabId: g.activeTabId && validTabIds.has(g.activeTabId) ? g.activeTabId : null - })) + const validatedGroups = groups.map((g) => { + const tabOrder = g.tabOrder.filter((tid) => validTabIds.has(tid)) + return { + ...g, + tabOrder, + activeTabId: g.activeTabId && validTabIds.has(g.activeTabId) ? g.activeTabId : null + } + }) groupsByWorktree[worktreeId] = validatedGroups - activeGroupIdByWorktree[worktreeId] = validatedGroups[0].id + const activeGroupId = selectHydratedActiveGroupId( + validatedGroups, + session.activeGroupIdByWorktree?.[worktreeId] + ) + if (activeGroupId) { + activeGroupIdByWorktree[worktreeId] = activeGroupId + } + layoutByWorktree[worktreeId] = session.tabGroupLayouts?.[worktreeId] ?? { + type: 'leaf', + groupId: validatedGroups[0].id + } } - return { unifiedTabsByWorktree: tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree } + return { + unifiedTabsByWorktree: tabsByWorktree, + groupsByWorktree, + activeGroupIdByWorktree, + layoutByWorktree + } } function hydrateLegacyFormat( @@ -55,6 +99,7 @@ function hydrateLegacyFormat( const tabsByWorktree: Record = {} const groupsByWorktree: Record = {} const activeGroupIdByWorktree: Record = {} + const layoutByWorktree: Record = {} for (const worktreeId of validWorktreeIds) { const terminalTabs = session.tabsByWorktree[worktreeId] ?? [] @@ -118,9 +163,15 @@ function hydrateLegacyFormat( tabsByWorktree[worktreeId] = tabs groupsByWorktree[worktreeId] = [{ id: groupId, worktreeId, activeTabId, tabOrder }] activeGroupIdByWorktree[worktreeId] = groupId + layoutByWorktree[worktreeId] = { type: 'leaf', groupId } } - return { unifiedTabsByWorktree: tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree } + return { + unifiedTabsByWorktree: tabsByWorktree, + groupsByWorktree, + activeGroupIdByWorktree, + layoutByWorktree + } } export function buildHydratedTabState( diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 95f61271..4f7f51e6 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -293,6 +293,108 @@ describe('TabsSlice', () => { }) }) + describe('setTabGroupSplitRatio', () => { + it('updates the persisted ratio for the targeted split node', () => { + store.setState({ + layoutByWorktree: { + [WT]: { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', groupId: 'g-1' }, + second: { + type: 'split', + direction: 'vertical', + ratio: 0.5, + first: { type: 'leaf', groupId: 'g-2' }, + second: { type: 'leaf', groupId: 'g-3' } + } + } + } + }) + + store.getState().setTabGroupSplitRatio(WT, 'second', 0.7) + + const layout = store.getState().layoutByWorktree[WT] + expect(layout.type).toBe('split') + if (layout.type !== 'split' || layout.second.type !== 'split') { + throw new Error('expected nested split layout') + } + expect(layout.ratio).toBe(0.5) + expect(layout.second.ratio).toBe(0.7) + }) + }) + + describe('move/copy/merge group operations', () => { + it('moves a unified tab into another group', () => { + const tab = store.getState().createUnifiedTab(WT, 'editor', { + id: 'file-a.ts', + label: 'file-a.ts' + }) + const sourceGroupId = store.getState().groupsByWorktree[WT][0].id + const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right') + expect(targetGroupId).toBeTruthy() + + store.getState().moveUnifiedTabToGroup(tab.id, targetGroupId!) + + const state = store.getState() + const moved = state.unifiedTabsByWorktree[WT].find((item) => item.id === tab.id) + expect(moved?.groupId).toBe(targetGroupId) + expect( + state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)?.tabOrder + ).toEqual([]) + expect( + state.groupsByWorktree[WT].find((group) => group.id === targetGroupId)?.tabOrder + ).toEqual([tab.id]) + }) + + it('copies a unified tab into another group', () => { + const tab = store.getState().createUnifiedTab(WT, 'editor', { + id: 'file-a.ts', + label: 'file-a.ts' + }) + const sourceGroupId = store.getState().groupsByWorktree[WT][0].id + const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right') + expect(targetGroupId).toBeTruthy() + + const copied = store.getState().copyUnifiedTabToGroup(tab.id, targetGroupId!) + + expect(copied).not.toBeNull() + const state = store.getState() + expect(state.unifiedTabsByWorktree[WT]).toHaveLength(2) + expect( + state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)?.tabOrder + ).toEqual([tab.id]) + expect( + state.groupsByWorktree[WT].find((group) => group.id === targetGroupId)?.tabOrder + ).toEqual([copied!.id]) + expect(copied?.entityId).toBe(tab.entityId) + }) + + it('merges a group into its sibling', () => { + const t1 = store.getState().createUnifiedTab(WT, 'editor', { + id: 'file-a.ts', + label: 'file-a.ts' + }) + const sourceGroupId = store.getState().groupsByWorktree[WT][0].id + const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right') + expect(targetGroupId).toBeTruthy() + store.getState().createUnifiedTab(WT, 'editor', { + id: 'file-b.ts', + label: 'file-b.ts', + targetGroupId: targetGroupId! + }) + + const mergedInto = store.getState().mergeGroupIntoSibling(WT, targetGroupId!) + + expect(mergedInto).toBe(sourceGroupId) + const state = store.getState() + expect(state.groupsByWorktree[WT]).toHaveLength(1) + expect(state.groupsByWorktree[WT][0].tabOrder).toEqual([t1.id, 'file-b.ts']) + expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: sourceGroupId }) + }) + }) + // ─── setTabLabel / setTabCustomLabel / setUnifiedTabColor ───────── describe('tab property setters', () => { @@ -661,4 +763,48 @@ describe('TabsSlice', () => { expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBe(term.id) }) }) + + describe('reconcileWorktreeTabModel', () => { + it('drops unified tabs whose backing content no longer exists', () => { + const groupId = 'g-1' + store.setState({ + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'stale-terminal', + entityId: 'stale-terminal', + groupId, + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [WT]: [ + { + id: groupId, + worktreeId: WT, + activeTabId: 'stale-terminal', + tabOrder: ['stale-terminal'] + } + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + tabsByWorktree: { [WT]: [] } + }) + + const result = store.getState().reconcileWorktreeTabModel(WT) + + expect(result.renderableTabCount).toBe(0) + expect(result.activeRenderableTabId).toBeNull() + expect(store.getState().unifiedTabsByWorktree[WT]).toEqual([]) + expect(store.getState().groupsByWorktree[WT][0].tabOrder).toEqual([]) + expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBeNull() + }) + }) }) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 64d6912f..b02fc4a1 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -1,36 +1,59 @@ -/* eslint-disable max-lines -- Why: tab slice co-locates group-scoped state, - * focus, and split-group lifecycle to keep state transitions atomic. */ +/* eslint-disable max-lines -- Why: split-tab group state has to update layout, + * per-group focus, and tab membership atomically. Keeping those transitions in + * one slice avoids split-brain behavior between the unified tab model and the + * legacy terminal/editor/browser content slices. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { Tab, TabGroup, TabContentType, WorkspaceSessionState } from '../../../../shared/types' +import type { + Tab, + TabContentType, + TabGroup, + TabGroupLayoutNode, + WorkspaceSessionState +} from '../../../../shared/types' import { - findTabAndWorktree, - findGroupForTab, ensureGroup, + findGroupAndWorktree, + findGroupForTab, + findTabAndWorktree, + findTabByEntityInGroup, + patchTab, pickNeighbor, - updateGroup, - patchTab + updateGroup } from './tabs-helpers' import { buildHydratedTabState } from './tabs-hydration' +export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' + export type TabsSlice = { - // ─── State ────────────────────────────────────────────────────────── unifiedTabsByWorktree: Record groupsByWorktree: Record activeGroupIdByWorktree: Record - - // ─── Actions ──────────────────────────────────────────────────────── + layoutByWorktree: Record createUnifiedTab: ( worktreeId: string, contentType: TabContentType, init?: Partial< - Pick + Pick< + Tab, + 'id' | 'entityId' | 'label' | 'customLabel' | 'color' | 'isPreview' | 'isPinned' + > & { + targetGroupId: string + } > ) => Tab + getTab: (tabId: string) => Tab | null + getActiveTab: (worktreeId: string) => Tab | null + findTabForEntityInGroup: ( + worktreeId: string, + groupId: string, + entityId: string, + contentType?: TabContentType + ) => Tab | null + activateTab: (tabId: string) => void closeUnifiedTab: ( tabId: string ) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null - activateTab: (tabId: string) => void reorderUnifiedTabs: (groupId: string, tabIds: string[]) => void setTabLabel: (tabId: string, label: string) => void setTabCustomLabel: (tabId: string, label: string | null) => void @@ -39,80 +62,273 @@ export type TabsSlice = { unpinTab: (tabId: string) => void closeOtherTabs: (tabId: string) => string[] closeTabsToRight: (tabId: string) => string[] - getActiveTab: (worktreeId: string) => Tab | null - getTab: (tabId: string) => Tab | null + ensureWorktreeRootGroup: (worktreeId: string) => string focusGroup: (worktreeId: string, groupId: string) => void closeEmptyGroup: (worktreeId: string, groupId: string) => boolean createEmptySplitGroup: ( worktreeId: string, sourceGroupId: string, - direction: 'right' | 'down' + direction: TabSplitDirection ) => string | null + moveUnifiedTabToGroup: ( + tabId: string, + targetGroupId: string, + opts?: { index?: number; activate?: boolean } + ) => boolean + copyUnifiedTabToGroup: ( + tabId: string, + targetGroupId: string, + init?: Partial> + ) => Tab | null + mergeGroupIntoSibling: (worktreeId: string, groupId: string) => string | null + setTabGroupSplitRatio: (worktreeId: string, nodePath: string, ratio: number) => void + reconcileWorktreeTabModel: (worktreeId: string) => { + renderableTabCount: number + activeRenderableTabId: string | null + } hydrateTabsSession: (session: WorkspaceSessionState) => void } +function buildSplitNode( + existingGroupId: string, + newGroupId: string, + direction: 'horizontal' | 'vertical', + position: 'first' | 'second' +): TabGroupLayoutNode { + const existingLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: existingGroupId } + const newLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: newGroupId } + return { + type: 'split', + direction, + first: position === 'first' ? newLeaf : existingLeaf, + second: position === 'second' ? newLeaf : existingLeaf, + ratio: 0.5 + } +} + +function replaceLeaf( + root: TabGroupLayoutNode, + targetGroupId: string, + replacement: TabGroupLayoutNode +): TabGroupLayoutNode { + if (root.type === 'leaf') { + return root.groupId === targetGroupId ? replacement : root + } + return { + ...root, + first: replaceLeaf(root.first, targetGroupId, replacement), + second: replaceLeaf(root.second, targetGroupId, replacement) + } +} + +function updateSplitRatio( + root: TabGroupLayoutNode, + path: string[], + ratio: number +): TabGroupLayoutNode { + if (path.length === 0) { + return root.type === 'split' ? { ...root, ratio } : root + } + if (root.type !== 'split') { + return root + } + const [segment, ...rest] = path + if (segment === 'first') { + return { ...root, first: updateSplitRatio(root.first, rest, ratio) } + } + if (segment === 'second') { + return { ...root, second: updateSplitRatio(root.second, rest, ratio) } + } + return root +} + +function findFirstLeaf(root: TabGroupLayoutNode): string { + return root.type === 'leaf' ? root.groupId : findFirstLeaf(root.first) +} + +function findSiblingGroupId(root: TabGroupLayoutNode, targetGroupId: string): string | null { + if (root.type === 'leaf') { + return null + } + if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { + return root.second.type === 'leaf' ? root.second.groupId : findFirstLeaf(root.second) + } + if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { + return root.first.type === 'leaf' ? root.first.groupId : findFirstLeaf(root.first) + } + return ( + findSiblingGroupId(root.first, targetGroupId) ?? findSiblingGroupId(root.second, targetGroupId) + ) +} + +function removeLeaf(root: TabGroupLayoutNode, targetGroupId: string): TabGroupLayoutNode | null { + if (root.type === 'leaf') { + return root.groupId === targetGroupId ? null : root + } + if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { + return root.second + } + if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { + return root.first + } + const first = removeLeaf(root.first, targetGroupId) + const second = removeLeaf(root.second, targetGroupId) + if (first === null) { + return second + } + if (second === null) { + return first + } + return { ...root, first, second } +} + +function collapseGroupLayout( + layoutByWorktree: Record, + activeGroupIdByWorktree: Record, + worktreeId: string, + groupId: string, + fallbackGroupId?: string | null +): { + layoutByWorktree: Record + activeGroupIdByWorktree: Record +} { + const currentLayout = layoutByWorktree[worktreeId] + if (!currentLayout) { + return { layoutByWorktree, activeGroupIdByWorktree } + } + const siblingId = findSiblingGroupId(currentLayout, groupId) + const collapsed = removeLeaf(currentLayout, groupId) + const nextLayoutByWorktree = { ...layoutByWorktree } + if (collapsed) { + nextLayoutByWorktree[worktreeId] = collapsed + } else { + delete nextLayoutByWorktree[worktreeId] + } + return { + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: { + ...activeGroupIdByWorktree, + [worktreeId]: siblingId ?? fallbackGroupId ?? activeGroupIdByWorktree[worktreeId] + } + } +} + export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, groupsByWorktree: {}, activeGroupIdByWorktree: {}, + layoutByWorktree: {}, createUnifiedTab: (worktreeId, contentType, init) => { const id = init?.id ?? globalThis.crypto.randomUUID() - let tab!: Tab + let created!: Tab + set((state) => { + const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + init?.targetGroupId ?? state.activeGroupIdByWorktree[worktreeId] + ) + const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - set((s) => { - const { - group, - groupsByWorktree: nextGroups, - activeGroupIdByWorktree: nextActiveGroups - } = ensureGroup(s.groupsByWorktree, s.activeGroupIdByWorktree, worktreeId) - - const existing = s.unifiedTabsByWorktree[worktreeId] ?? [] - - // If opening a preview tab, replace any existing preview in the same group - let filtered = existing - let removedPreviewId: string | null = null + let nextTabs = existingTabs + let nextOrder = [...group.tabOrder] if (init?.isPreview) { - const existingPreview = existing.find((t) => t.isPreview && t.groupId === group.id) + const existingPreview = existingTabs.find( + (tab) => tab.groupId === group.id && tab.isPreview && tab.contentType === contentType + ) if (existingPreview) { - filtered = existing.filter((t) => t.id !== existingPreview.id) - removedPreviewId = existingPreview.id + nextTabs = existingTabs.filter((tab) => tab.id !== existingPreview.id) + nextOrder = nextOrder.filter((tabId) => tabId !== existingPreview.id) } } - tab = { + created = { id, entityId: init?.entityId ?? id, groupId: group.id, worktreeId, contentType, - label: init?.label ?? (contentType === 'terminal' ? `Terminal ${existing.length + 1}` : id), + label: + init?.label ?? (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), customLabel: init?.customLabel ?? null, color: init?.color ?? null, - sortOrder: filtered.length, + sortOrder: nextOrder.length, createdAt: Date.now(), isPreview: init?.isPreview, isPinned: init?.isPinned } - const newTabOrder = removedPreviewId - ? group.tabOrder.filter((tid) => tid !== removedPreviewId) - : [...group.tabOrder] - newTabOrder.push(tab.id) - - const updatedGroupObj: TabGroup = { ...group, activeTabId: tab.id, tabOrder: newTabOrder } - + nextOrder.push(created.id) return { - unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: [...filtered, tab] }, - groupsByWorktree: { - ...nextGroups, - [worktreeId]: updateGroup(nextGroups[worktreeId] ?? [], updatedGroupObj) + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: [...nextTabs, created] }, - activeGroupIdByWorktree: nextActiveGroups + groupsByWorktree: { + ...groupsByWorktree, + [worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], { + ...group, + activeTabId: created.id, + tabOrder: nextOrder + }) + }, + activeGroupIdByWorktree, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } + } } }) + return created + }, - return tab + getTab: (tabId) => findTabAndWorktree(get().unifiedTabsByWorktree, tabId)?.tab ?? null, + + getActiveTab: (worktreeId) => { + const state = get() + const groupId = state.activeGroupIdByWorktree[worktreeId] + const group = (state.groupsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === groupId + ) + if (!group?.activeTabId) { + return null + } + return ( + (state.unifiedTabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === group.activeTabId) ?? + null + ) + }, + + findTabForEntityInGroup: (worktreeId, groupId, entityId, contentType) => + findTabByEntityInGroup(get().unifiedTabsByWorktree, worktreeId, groupId, entityId, contentType), + + activateTab: (tabId) => { + set((state) => { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return {} + } + const { tab, worktreeId } = found + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => + item.id === tabId ? { ...item, isPreview: false } : item + ) + }, + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: (state.groupsByWorktree[worktreeId] ?? []).map((group) => + group.id === tab.groupId ? { ...group, activeTabId: tabId } : group + ) + }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: tab.groupId + } + } + }) }, closeUnifiedTab: (tabId) => { @@ -121,114 +337,101 @@ export const createTabsSlice: StateCreator = (set, if (!found) { return null } - const { tab, worktreeId } = found const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) if (!group) { return null } - const remainingOrder = group.tabOrder.filter((tid) => tid !== tabId) + const remainingOrder = group.tabOrder.filter((id) => id !== tabId) const wasLastTab = remainingOrder.length === 0 + const nextActiveTabId = + group.activeTabId === tabId + ? wasLastTab + ? null + : pickNeighbor(group.tabOrder, tabId) + : group.activeTabId - let newActiveTabId = group.activeTabId - if (group.activeTabId === tabId) { - newActiveTabId = wasLastTab ? null : pickNeighbor(group.tabOrder, tabId) - } - - set((s) => { - const tabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const nextTabs = tabs.filter((t) => t.id !== tabId) - const updatedGroupObj: TabGroup = { - ...group, - activeTabId: newActiveTabId, - tabOrder: remainingOrder + set((current) => { + const nextTabs = (current.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (item) => item.id !== tabId + ) + let nextGroups = (current.groupsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === group.id + ? { ...candidate, activeTabId: nextActiveTabId, tabOrder: remainingOrder } + : candidate + ) + let nextLayoutByWorktree = current.layoutByWorktree + let nextActiveGroupIdByWorktree = current.activeGroupIdByWorktree + if (wasLastTab && current.layoutByWorktree[worktreeId] && nextGroups.length > 1) { + nextGroups = nextGroups.filter((candidate) => candidate.id !== group.id) + const collapsedState = collapseGroupLayout( + current.layoutByWorktree, + current.activeGroupIdByWorktree, + worktreeId, + group.id, + nextGroups[0]?.id ?? null + ) + nextLayoutByWorktree = collapsedState.layoutByWorktree + nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree } - return { - unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: nextTabs }, + unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, groupsByWorktree: { - ...s.groupsByWorktree, - [worktreeId]: updateGroup(s.groupsByWorktree[worktreeId] ?? [], updatedGroupObj) - } + ...current.groupsByWorktree, + [worktreeId]: nextGroups + }, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree } }) return { closedTabId: tabId, wasLastTab, worktreeId } }, - activateTab: (tabId) => { - set((s) => { - const found = findTabAndWorktree(s.unifiedTabsByWorktree, tabId) - if (!found) { - return {} - } - - const { tab, worktreeId } = found - const groups = s.groupsByWorktree[worktreeId] ?? [] - const updatedGroups = groups.map((g) => - g.id === tab.groupId ? { ...g, activeTabId: tabId } : g - ) - - let updatedTabs = s.unifiedTabsByWorktree[worktreeId] - if (tab.isPreview) { - updatedTabs = updatedTabs.map((t) => (t.id === tabId ? { ...t, isPreview: false } : t)) - } - - return { - unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: updatedTabs }, - groupsByWorktree: { ...s.groupsByWorktree, [worktreeId]: updatedGroups } - } - }) - }, - reorderUnifiedTabs: (groupId, tabIds) => { - set((s) => { - for (const [worktreeId, groups] of Object.entries(s.groupsByWorktree)) { - const group = groups.find((g) => g.id === groupId) + set((state) => { + for (const [worktreeId, groups] of Object.entries(state.groupsByWorktree)) { + const group = groups.find((candidate) => candidate.id === groupId) if (!group) { continue } - - const updatedGroupObj: TabGroup = { ...group, tabOrder: tabIds } - const tabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const orderMap = new Map(tabIds.map((id, i) => [id, i])) - const updatedTabs = tabs.map((t) => { - const newOrder = orderMap.get(t.id) - return newOrder !== undefined ? { ...t, sortOrder: newOrder } : t - }) - + const orderMap = new Map(tabIds.map((id, index) => [id, index])) return { groupsByWorktree: { - ...s.groupsByWorktree, - [worktreeId]: updateGroup(groups, updatedGroupObj) + ...state.groupsByWorktree, + [worktreeId]: updateGroup(groups, { ...group, tabOrder: tabIds }) }, - unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: updatedTabs } + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => { + const sortOrder = orderMap.get(tab.id) + return sortOrder === undefined ? tab : { ...tab, sortOrder } + }) + } } } return {} }) }, - setTabLabel: (tabId, label) => { - set((s) => patchTab(s.unifiedTabsByWorktree, tabId, { label }) ?? {}) - }, + setTabLabel: (tabId, label) => + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}), - setTabCustomLabel: (tabId, label) => { - set((s) => patchTab(s.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) - }, + setTabCustomLabel: (tabId, label) => + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}), - setUnifiedTabColor: (tabId, color) => { - set((s) => patchTab(s.unifiedTabsByWorktree, tabId, { color }) ?? {}) - }, + setUnifiedTabColor: (tabId, color) => + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}), - pinTab: (tabId) => { - set((s) => patchTab(s.unifiedTabsByWorktree, tabId, { isPinned: true, isPreview: false }) ?? {}) - }, + pinTab: (tabId) => + set( + (state) => + patchTab(state.unifiedTabsByWorktree, tabId, { isPinned: true, isPreview: false }) ?? {} + ), - unpinTab: (tabId) => { - set((s) => patchTab(s.unifiedTabsByWorktree, tabId, { isPinned: false }) ?? {}) - }, + unpinTab: (tabId) => + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { isPinned: false }) ?? {}), closeOtherTabs: (tabId) => { const state = get() @@ -236,39 +439,17 @@ export const createTabsSlice: StateCreator = (set, if (!found) { return [] } - const { tab, worktreeId } = found const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) if (!group) { return [] } - - const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const closedIds = tabs - .filter((t) => t.id !== tabId && !t.isPinned && t.groupId === group.id) - .map((t) => t.id) - - if (closedIds.length === 0) { - return [] + const closedIds = (state.unifiedTabsByWorktree[worktreeId] ?? []) + .filter((item) => item.groupId === group.id && item.id !== tabId && !item.isPinned) + .map((item) => item.id) + for (const id of closedIds) { + get().closeUnifiedTab(id) } - - const closedSet = new Set(closedIds) - - set((s) => { - const currentTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const remainingTabs = currentTabs.filter((t) => !closedSet.has(t.id)) - const remainingOrder = group.tabOrder.filter((tid) => !closedSet.has(tid)) - const updatedGroupObj: TabGroup = { ...group, activeTabId: tabId, tabOrder: remainingOrder } - - return { - unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: remainingTabs }, - groupsByWorktree: { - ...s.groupsByWorktree, - [worktreeId]: updateGroup(s.groupsByWorktree[worktreeId] ?? [], updatedGroupObj) - } - } - }) - return closedIds }, @@ -278,110 +459,89 @@ export const createTabsSlice: StateCreator = (set, if (!found) { return [] } - const { tab, worktreeId } = found const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) if (!group) { return [] } - - const idx = group.tabOrder.indexOf(tabId) - if (idx === -1) { + const index = group.tabOrder.indexOf(tabId) + if (index === -1) { return [] } + const closableIds = group.tabOrder + .slice(index + 1) + .filter( + (id) => + !(state.unifiedTabsByWorktree[worktreeId] ?? []).find((candidate) => candidate.id === id) + ?.isPinned + ) + for (const id of closableIds) { + get().closeUnifiedTab(id) + } + return closableIds + }, - const idsToRight = group.tabOrder.slice(idx + 1) - const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const tabMap = new Map(tabs.map((t) => [t.id, t])) - - const closedIds = idsToRight.filter((tid) => { - const t = tabMap.get(tid) - return t && !t.isPinned - }) - - if (closedIds.length === 0) { - return [] + ensureWorktreeRootGroup: (worktreeId) => { + const existingGroups = get().groupsByWorktree[worktreeId] ?? [] + if (existingGroups.length > 0) { + return get().activeGroupIdByWorktree[worktreeId] ?? existingGroups[0].id } - const closedSet = new Set(closedIds) - - set((s) => { - const currentTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const remainingTabs = currentTabs.filter((t) => !closedSet.has(t.id)) - const remainingOrder = group.tabOrder.filter((tid) => !closedSet.has(tid)) - - const newActiveTabId = closedSet.has(group.activeTabId ?? '') ? tabId : group.activeTabId - const updatedGroupObj: TabGroup = { - ...group, - activeTabId: newActiveTabId, - tabOrder: remainingOrder + const groupId = globalThis.crypto.randomUUID() + set((state) => ({ + // Why: a freshly selected worktree can legitimately have zero tabs, but + // split-group affordances still need a canonical root group so new tabs + // and splits land in a deterministic place like VS Code's editor area. + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: [{ id: groupId, worktreeId, activeTabId: null, tabOrder: [] }] + }, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: { type: 'leaf', groupId } + }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: groupId } - - return { - unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: remainingTabs }, - groupsByWorktree: { - ...s.groupsByWorktree, - [worktreeId]: updateGroup(s.groupsByWorktree[worktreeId] ?? [], updatedGroupObj) - } - } - }) - - return closedIds - }, - - getActiveTab: (worktreeId) => { - const state = get() - const activeGroupId = state.activeGroupIdByWorktree[worktreeId] - if (!activeGroupId) { - return null - } - - const groups = state.groupsByWorktree[worktreeId] ?? [] - const group = groups.find((g) => g.id === activeGroupId) - if (!group?.activeTabId) { - return null - } - - const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - return tabs.find((t) => t.id === group.activeTabId) ?? null - }, - - getTab: (tabId) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - return found?.tab ?? null - }, - - focusGroup: (worktreeId, groupId) => { - set((s) => ({ - activeGroupIdByWorktree: { ...s.activeGroupIdByWorktree, [worktreeId]: groupId } })) + return groupId }, + focusGroup: (worktreeId, groupId) => + set((state) => ({ + activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: groupId } + })), + closeEmptyGroup: (worktreeId, groupId) => { const state = get() - const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter( - (t) => t.groupId === groupId + const group = (state.groupsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === groupId ) - if (tabs.length > 0) { + if (!group || group.tabOrder.length > 0) { return false } - const groups = state.groupsByWorktree[worktreeId] ?? [] - const remaining = groups.filter((g) => g.id !== groupId) - if (remaining.length === 0) { - return false - } - set((s) => ({ - groupsByWorktree: { ...s.groupsByWorktree, [worktreeId]: remaining }, - activeGroupIdByWorktree: { - ...s.activeGroupIdByWorktree, - [worktreeId]: remaining[0].id + set((current) => { + const remainingGroups = (current.groupsByWorktree[worktreeId] ?? []).filter( + (candidate) => candidate.id !== groupId + ) + const collapsedState = collapseGroupLayout( + current.layoutByWorktree, + current.activeGroupIdByWorktree, + worktreeId, + groupId, + remainingGroups[0]?.id ?? null + ) + return { + groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: remainingGroups }, + layoutByWorktree: collapsedState.layoutByWorktree, + activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree } - })) + }) return true }, - createEmptySplitGroup: (worktreeId, _sourceGroupId, _direction) => { + createEmptySplitGroup: (worktreeId, sourceGroupId, direction) => { const newGroupId = globalThis.crypto.randomUUID() const newGroup: TabGroup = { id: newGroupId, @@ -389,16 +549,232 @@ export const createTabsSlice: StateCreator = (set, activeTabId: null, tabOrder: [] } - set((s) => { - const existing = s.groupsByWorktree[worktreeId] ?? [] + set((state) => { + const existing = state.groupsByWorktree[worktreeId] ?? [] + const currentLayout = + state.layoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: sourceGroupId } as const) + const replacement = buildSplitNode( + sourceGroupId, + newGroupId, + direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical', + direction === 'left' || direction === 'up' ? 'first' : 'second' + ) return { - groupsByWorktree: { ...s.groupsByWorktree, [worktreeId]: [...existing, newGroup] }, - activeGroupIdByWorktree: { ...s.activeGroupIdByWorktree, [worktreeId]: newGroupId } + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: [...existing, newGroup] }, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: replaceLeaf(currentLayout, sourceGroupId, replacement) + }, + activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId } } }) return newGroupId }, + moveUnifiedTabToGroup: (tabId, targetGroupId, opts) => { + let moved = false + set((state) => { + const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) + if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { + return {} + } + const { tab, worktreeId } = foundTab + if (tab.groupId === targetGroupId) { + return {} + } + const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + const targetGroup = foundTarget.group + if (!sourceGroup) { + return {} + } + moved = true + + const sourceOrder = sourceGroup.tabOrder.filter((id) => id !== tabId) + const targetOrder = [...targetGroup.tabOrder] + const targetIndex = Math.max( + 0, + Math.min(opts?.index ?? targetOrder.length, targetOrder.length) + ) + targetOrder.splice(targetIndex, 0, tabId) + const nextActiveGroupIdByWorktree = { + ...state.activeGroupIdByWorktree, + [worktreeId]: opts?.activate ? targetGroupId : state.activeGroupIdByWorktree[worktreeId] + } + const nextGroups = (state.groupsByWorktree[worktreeId] ?? []).map((group) => { + if (group.id === sourceGroup.id) { + return { + ...group, + activeTabId: + group.activeTabId === tabId ? pickNeighbor(group.tabOrder, tabId) : group.activeTabId, + tabOrder: sourceOrder + } + } + if (group.id === targetGroupId) { + return { + ...group, + activeTabId: opts?.activate ? tabId : group.activeTabId, + tabOrder: targetOrder + } + } + return group + }) + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate + ) + }, + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: nextGroups + }, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + } + }) + return moved + }, + + copyUnifiedTabToGroup: (tabId, targetGroupId, init) => { + const foundTab = findTabAndWorktree(get().unifiedTabsByWorktree, tabId) + const foundTarget = findGroupAndWorktree(get().groupsByWorktree, targetGroupId) + if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { + return null + } + const { tab, worktreeId } = foundTab + return get().createUnifiedTab(worktreeId, tab.contentType, { + entityId: init?.entityId ?? tab.entityId, + label: init?.label ?? tab.label, + customLabel: init?.customLabel ?? tab.customLabel, + color: init?.color ?? tab.color, + isPinned: init?.isPinned ?? tab.isPinned, + id: init?.id, + targetGroupId + }) + }, + + mergeGroupIntoSibling: (worktreeId, groupId) => { + const state = get() + const groups = state.groupsByWorktree[worktreeId] ?? [] + const sourceGroup = groups.find((candidate) => candidate.id === groupId) + const layout = state.layoutByWorktree[worktreeId] + if (!sourceGroup || !layout || groups.length <= 1) { + return null + } + const targetGroupId = findSiblingGroupId(layout, groupId) + if (!targetGroupId) { + return null + } + + const orderedSourceTabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.groupId === groupId + ) + for (const tabId of sourceGroup.tabOrder) { + const item = orderedSourceTabs.find((tab) => tab.id === tabId) + if (!item) { + continue + } + get().moveUnifiedTabToGroup(item.id, targetGroupId) + } + get().closeEmptyGroup(worktreeId, groupId) + return targetGroupId + }, + + setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => + set((state) => { + const currentLayout = state.layoutByWorktree[worktreeId] + if (!currentLayout) { + return {} + } + return { + layoutByWorktree: { + ...state.layoutByWorktree, + // Why: split sizing is part of the tab-group model, not transient UI + // state. Persisting ratios here keeps restores and multi-step group + // operations in sync with what the user actually resized. + [worktreeId]: updateSplitRatio( + currentLayout, + nodePath.length > 0 ? nodePath.split('.') : [], + ratio + ) + } + } + }), + + reconcileWorktreeTabModel: (worktreeId) => { + const state = get() + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const groups = state.groupsByWorktree[worktreeId] ?? [] + const liveTerminalIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + const liveEditorIds = new Set( + state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) + ) + const liveBrowserIds = new Set( + (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) + ) + + const isRenderableTab = (tab: Tab): boolean => { + if (tab.contentType === 'terminal') { + return liveTerminalIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return liveBrowserIds.has(tab.entityId) + } + return liveEditorIds.has(tab.entityId) + } + + const validTabs = unifiedTabs.filter(isRenderableTab) + const validTabIds = new Set(validTabs.map((tab) => tab.id)) + + const nextGroups = groups.map((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + const activeTabId = + group.activeTabId && validTabIds.has(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + const tabOrderUnchanged = + tabOrder.length === group.tabOrder.length && + tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) + return tabOrderUnchanged && activeTabId === group.activeTabId + ? group + : { ...group, tabOrder, activeTabId } + }) + + const currentActiveGroupId = state.activeGroupIdByWorktree[worktreeId] + const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) + const nextActiveGroupId = activeGroupStillExists + ? currentActiveGroupId + : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? + nextGroups[0]?.id ?? + currentActiveGroupId) + + const groupsChanged = nextGroups.some((group, index) => group !== groups[index]) + const tabsChanged = validTabs.length !== unifiedTabs.length + const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId + + if (tabsChanged || groupsChanged || activeGroupChanged) { + set((current) => ({ + unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: validTabs }, + groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: nextGroups }, + activeGroupIdByWorktree: { + ...current.activeGroupIdByWorktree, + [worktreeId]: nextActiveGroupId + } + })) + } + + const activeRenderableTabId = + nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? + nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? + null + + return { + renderableTabCount: validTabs.length, + activeRenderableTabId + } + }, + hydrateTabsSession: (session) => { const state = get() const validWorktreeIds = new Set( diff --git a/src/shared/types.ts b/src/shared/types.ts index ba45743c..4a73ec5e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -58,6 +58,20 @@ export type WorktreeMeta = { lastActivityAt: number } +// ─── Tab Group Layout ─────────────────────────────────────────────── +export type TabGroupSplitDirection = 'horizontal' | 'vertical' + +export type TabGroupLayoutNode = + | { type: 'leaf'; groupId: string } + | { + type: 'split' + direction: TabGroupSplitDirection + first: TabGroupLayoutNode + second: TabGroupLayoutNode + /** Flex ratio of the first child (0–1). Defaults to 0.5 if absent. */ + ratio?: number + } + // ─── Unified Tab ──────────────────────────────────────────────────── export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser' @@ -197,6 +211,9 @@ export type TerminalLayoutSnapshot = { root: TerminalPaneLayoutNode | null activeLeafId: string | null expandedLeafId: string | null + /** Live PTY IDs per leaf for in-session remounts such as tab-group moves. + * Not used for app restart because PTYs are transient processes. */ + ptyIdsByLeafId?: Record /** Serialized terminal buffers per leaf for scrollback restoration on restart. */ buffersByLeafId?: Record /** User-assigned pane titles, keyed by leafId (e.g. "pane:3"). @@ -246,6 +263,10 @@ export type WorkspaceSessionState = { unifiedTabs?: Record /** Tab group model — present alongside unifiedTabs. */ tabGroups?: Record + /** Persisted split layout tree per worktree. */ + tabGroupLayouts?: Record + /** Per-worktree focused group at shutdown. */ + activeGroupIdByWorktree?: Record } // ─── GitHub ──────────────────────────────────────────────────────────