mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
refactor: add split group model foundations (#644)
This commit is contained in:
parent
901c7335a2
commit
741974c59c
8 changed files with 1075 additions and 276 deletions
26
docs/split-groups-rollout-pr1.md
Normal file
26
docs/split-groups-rollout-pr1.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,7 +258,34 @@ export type EditorSlice = {
|
|||
hydrateEditorSession: (session: WorkspaceSessionState) => void
|
||||
}
|
||||
|
||||
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (set, get) => ({
|
||||
editorDrafts: {},
|
||||
setEditorDraft: (fileId, content) =>
|
||||
set((s) => ({
|
||||
|
|
@ -346,7 +373,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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: {},
|
||||
|
|
|
|||
|
|
@ -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<string, Tab[]>,
|
||||
|
|
@ -22,16 +22,50 @@ export function findGroupForTab(
|
|||
return groups.find((g) => g.id === groupId) ?? null
|
||||
}
|
||||
|
||||
export function findGroupAndWorktree(
|
||||
groupsByWorktree: Record<string, TabGroup[]>,
|
||||
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<string, Tab[]>,
|
||||
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<string, TabGroup[]>,
|
||||
activeGroupIdByWorktree: Record<string, string>,
|
||||
worktreeId: string
|
||||
worktreeId: string,
|
||||
preferredGroupId?: string
|
||||
): {
|
||||
group: TabGroup
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
activeGroupIdByWorktree: Record<string, string>
|
||||
} {
|
||||
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<string, Set<string>> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, Tab[]>
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
activeGroupIdByWorktree: Record<string, string>
|
||||
layoutByWorktree: Record<string, TabGroupLayoutNode>
|
||||
}
|
||||
|
||||
function hydrateUnifiedFormat(
|
||||
|
|
@ -13,6 +24,8 @@ function hydrateUnifiedFormat(
|
|||
const tabsByWorktree: Record<string, Tab[]> = {}
|
||||
const groupsByWorktree: Record<string, TabGroup[]> = {}
|
||||
const activeGroupIdByWorktree: Record<string, string> = {}
|
||||
const layoutByWorktree: Record<string, TabGroupLayoutNode> = {}
|
||||
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<string>()
|
||||
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<string, Tab[]> = {}
|
||||
const groupsByWorktree: Record<string, TabGroup[]> = {}
|
||||
const activeGroupIdByWorktree: Record<string, string> = {}
|
||||
const layoutByWorktree: Record<string, TabGroupLayoutNode> = {}
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string, string>
|
||||
/** Serialized terminal buffers per leaf for scrollback restoration on restart. */
|
||||
buffersByLeafId?: Record<string, string>
|
||||
/** User-assigned pane titles, keyed by leafId (e.g. "pane:3").
|
||||
|
|
@ -246,6 +263,10 @@ export type WorkspaceSessionState = {
|
|||
unifiedTabs?: Record<string, Tab[]>
|
||||
/** Tab group model — present alongside unifiedTabs. */
|
||||
tabGroups?: Record<string, TabGroup[]>
|
||||
/** Persisted split layout tree per worktree. */
|
||||
tabGroupLayouts?: Record<string, TabGroupLayoutNode>
|
||||
/** Per-worktree focused group at shutdown. */
|
||||
activeGroupIdByWorktree?: Record<string, string>
|
||||
}
|
||||
|
||||
// ─── GitHub ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue