feat: add split group UI with tab-bar context menus and resize handles (#646)

This commit is contained in:
Brennan Benson 2026-04-14 19:01:11 -07:00 committed by GitHub
parent 42e04268fb
commit 3b897d255d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 934 additions and 301 deletions

View file

@ -0,0 +1,22 @@
# Split Groups PR 4: Split-Group UI Scaffolding
This branch adds the split-group UI pieces, but does not mount them in the
main workspace host yet.
Scope:
- add `TabGroupPanel`, `TabGroupSplitLayout`, and `useTabGroupController`
- add split-group actions to tab menus and tab-bar affordances
- add the follow-up design note for the architecture
What Is Actually Hooked Up In This PR:
- the new split-group components compile and exist in the tree
- tab-bar level split-group affordances are present in the component layer
What Is Not Hooked Up Yet:
- `Terminal.tsx` does not mount `TabGroupSplitLayout` in this branch
- users still see the legacy single-surface renderer
- no feature switch exists here because the code path is not wired in yet
Non-goals:
- no rollout to users
- no main-renderer ownership change yet

View file

@ -0,0 +1,216 @@
# Tab Group Model Follow-Up
## Goal
Move Orca's split tab-group implementation from a store-plus-controller shape to a true model/service boundary that is closer to VS Code's editor-group architecture, without porting VS Code wholesale.
This follow-up is intentionally **not** part of the current PR. The current PR already moved the feature in the right direction by:
- making split ratios persisted model state
- centralizing move/copy/merge group operations in the tabs store
- thinning `TabGroupPanel` into more of a view
The next step is to make tab-group behavior a first-class model instead of a set of store records plus helper/controller logic.
## Why
Split tab groups are no longer just a rendering concern. They now carry:
- layout structure
- persisted split ratios
- active group state
- cross-group move/copy semantics
- close/merge behavior
- mixed content types per group
As this grows, keeping behavior split across Zustand records, controller hooks, and React components will become harder to reason about and easier to regress.
VS Code handles this by making editor groups a first-class model/service. Orca does not need the full VS Code abstraction surface, but it should adopt the same direction:
- model owns behavior
- views render model state
- imperative operations go through one boundary
## Current Gaps
Even after the current PR, these gaps remain:
1. Group activation is still minimal.
Orca tracks `activeGroupIdByWorktree`, but not true MRU group ordering or activation reasons.
2. Group operations are still store-action centric, not model-object centric.
The store now owns the mutations, but callers still think in terms of raw IDs and records.
3. There are no group lifecycle events.
React consumers read state snapshots, but there is no explicit event surface for add/remove/move/merge/activate.
4. Hydration and runtime behavior are still tightly coupled to raw store shape.
This makes it harder to evolve the model without touching many callers.
5. `TabGroupPanel` is thinner, but still knows too much about worktree/group coordination.
## Target Shape
Introduce a per-worktree tab-group model/controller layer, for example:
- `TabGroupWorkspaceModel`
- `TabGroupModel`
- `TabGroupLayoutModel`
This layer should:
- wrap the normalized store state for a single worktree
- expose typed operations instead of raw state surgery
- centralize MRU group activation
- centralize group lifecycle transitions
- provide derived read models for rendering
React components should consume:
- derived selectors for render state
- a small command surface for mutations
They should not need to understand layout tree mutation details.
## Proposed Responsibilities
### `TabGroupWorkspaceModel`
Owns all tab-group state for one worktree:
- groups
- layout tree
- active group
- MRU group order
Exposes commands like:
- `splitGroup(groupId, direction)`
- `closeGroup(groupId)`
- `mergeGroup(groupId, targetGroupId?)`
- `activateGroup(groupId, reason)`
- `moveTab(tabId, targetGroupId, options?)`
- `copyTab(tabId, targetGroupId, options?)`
- `reorderGroupTabs(groupId, orderedTabIds)`
- `resizeSplit(nodePath, ratio)`
### `TabGroupModel`
Represents one group and exposes:
- `id`
- `tabs`
- `activeTab`
- `tabOrder`
- `isActive`
- `isEmpty`
This can be a thin wrapper over store state rather than a heavy OO abstraction.
### `TabGroupLayoutModel`
Encapsulates layout operations:
- replace leaf with split
- remove leaf and collapse tree
- find sibling group
- update split ratio
- validate layout against live groups
This logic is currently spread across `tabs.ts` helpers and should move into one focused module.
## Migration Plan
### Phase 1: Extract Pure Model Utilities
Create a new module for pure tab-group model operations:
- layout mutation
- group merge/collapse rules
- MRU group bookkeeping
- validation helpers
This phase should not change runtime behavior.
### Phase 2: Add Workspace Model Facade
Introduce a facade over the Zustand store for one worktree:
- input: `worktreeId`
- output: commands + derived state
This can begin as a hook-backed facade, but the logic should live outside React as much as possible.
### Phase 3: Move Components To Render-Only
Reduce `TabGroupPanel` and `TabGroupSplitLayout` to:
- render derived state
- dispatch commands
They should no longer assemble group/tab mutation behavior themselves.
### Phase 4: Add MRU Group Semantics
Track:
- active group
- most recently active group order
Use this for:
- close-group merge target selection
- focus restoration after group removal
- more VS Code-like group activation behavior
### Phase 5: Hydration Boundary Cleanup
Move hydration/restore validation through the model layer so layout and groups are repaired in one place.
## Non-Goals
- Porting VS Code's editor-group implementation directly
- Replacing Zustand
- Introducing a large class hierarchy for its own sake
- Refactoring terminal pane internals as part of the same follow-up
## Risks
1. Terminal/editor/browser tabs currently share the unified tab model.
Refactors must preserve mixed-content behavior across groups.
2. Hydration and worktree switching depend on current store shape.
The migration should preserve persisted session compatibility.
3. Closing and merging groups can easily regress active-tab restoration.
MRU rules need explicit tests.
## Test Plan For Follow-Up
Add focused tests around:
- split + resize + restore
- close empty group
- close non-empty group merges into MRU/sibling target
- move tab between groups
- copy tab between groups
- active group restoration after merge
- hydration repairing invalid layout/group combinations
- worktree switch preserving active group and active tab
## Suggested PR Breakdown
1. `refactor: extract tab group layout model helpers`
2. `refactor: add worktree tab group model facade`
3. `refactor: move tab group components to render-only`
4. `feat: add MRU group activation model`
5. `refactor: route hydration through tab group model`
## Recommendation
Do this as a dedicated follow-up PR sequence, not as an extension of the current PR.
The current PR is already the right stopping point:
- enough model centralization to stabilize the feature
- not so much architectural churn that review and regression risk explode

View file

@ -42,7 +42,13 @@ type CachedCombinedDiffViewState = {
const combinedDiffViewStateCache = new Map<string, CachedCombinedDiffViewState>()
const combinedDiffScrollTopCache = new Map<string, number>()
export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.JSX.Element {
export default function CombinedDiffViewer({
file,
viewStateKey
}: {
file: OpenFile
viewStateKey: string
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const gitBranchChangesByWorktree = useAppStore((s) => s.gitBranchChangesByWorktree)
@ -139,11 +145,12 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
)
// Why: switching tabs or worktrees unmounts this viewer through the shared
// editor surface above it. Cache the rendered combined-diff state by tab id
// so remounting can restore loaded sections and scroll position instead of
// flashing back to "Loading..." and forcing the user to find their place again.
// editor surface above it. Cache the rendered combined-diff state by the
// visible pane key so remounting can restore loaded sections and scroll
// position instead of flashing back to "Loading..." and forcing the user to
// find their place again.
useEffect(() => {
const cached = combinedDiffViewStateCache.get(file.id)
const cached = combinedDiffViewStateCache.get(viewStateKey)
const canRestoreCachedSections =
cached &&
cached.entrySignature === entrySignature &&
@ -154,11 +161,11 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
setSideBySide(cached.sideBySide)
loadedIndicesRef.current = new Set(cached.loadedIndices)
pendingRestoreScrollTopRef.current =
combinedDiffScrollTopCache.get(file.id) ?? cached.scrollTop
combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
return
}
pendingRestoreScrollTopRef.current = combinedDiffScrollTopCache.get(file.id) ?? null
pendingRestoreScrollTopRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? null
setSections(
entries.map((entry) => ({
key: `${'area' in entry ? entry.area : 'branch'}:${entry.path}`,
@ -178,7 +185,7 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
loadedIndicesRef.current.clear()
generationRef.current += 1
setGeneration((prev) => prev + 1)
}, [entries, entrySignature, file.id])
}, [entries, entrySignature, viewStateKey])
// Progressive loading: load diff content when a section becomes visible
const loadedIndicesRef = useRef<Set<number>>(new Set())
@ -299,8 +306,8 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
return
}
const preservedScrollTop =
combinedDiffScrollTopCache.get(file.id) ?? scrollContainerRef.current?.scrollTop ?? 0
setWithLRU(combinedDiffViewStateCache, file.id, {
combinedDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0
setWithLRU(combinedDiffViewStateCache, viewStateKey, {
entrySignature,
sections,
sectionHeights,
@ -308,7 +315,7 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
scrollTop: preservedScrollTop,
sideBySide
})
}, [entries.length, entrySignature, file.id, sectionHeights, sections, sideBySide])
}, [entries.length, entrySignature, sectionHeights, sections, sideBySide, viewStateKey])
useLayoutEffect(() => {
const container = scrollContainerRef.current
@ -316,19 +323,19 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
return
}
const cached = combinedDiffViewStateCache.get(file.id)
const cached = combinedDiffViewStateCache.get(viewStateKey)
if (cached && cached.entrySignature === entrySignature) {
pendingRestoreScrollTopRef.current =
combinedDiffScrollTopCache.get(file.id) ?? cached.scrollTop
combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
}
const updateCachedScrollPosition = (): void => {
const existing = combinedDiffViewStateCache.get(file.id)
setWithLRU(combinedDiffScrollTopCache, file.id, container.scrollTop)
const existing = combinedDiffViewStateCache.get(viewStateKey)
setWithLRU(combinedDiffScrollTopCache, viewStateKey, container.scrollTop)
if (!existing || existing.entrySignature !== entrySignature) {
return
}
setWithLRU(combinedDiffViewStateCache, file.id, {
setWithLRU(combinedDiffViewStateCache, viewStateKey, {
...existing,
scrollTop: container.scrollTop
})
@ -343,7 +350,7 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
updateCachedScrollPosition()
container.removeEventListener('scroll', updateCachedScrollPosition)
}
}, [entrySignature, file.id, sections.length])
}, [entrySignature, sections.length, viewStateKey])
useLayoutEffect(() => {
const container = scrollContainerRef.current
@ -365,7 +372,7 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
const maxScrollTop = Math.max(0, liveContainer.scrollHeight - liveContainer.clientHeight)
const nextScrollTop = Math.min(liveTarget, maxScrollTop)
liveContainer.scrollTop = nextScrollTop
setWithLRU(combinedDiffScrollTopCache, file.id, nextScrollTop)
setWithLRU(combinedDiffScrollTopCache, viewStateKey, nextScrollTop)
if (Math.abs(liveContainer.scrollTop - liveTarget) <= 1 || maxScrollTop >= liveTarget) {
pendingRestoreScrollTopRef.current = null
@ -380,7 +387,7 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
restoreScrollPosition()
return () => window.cancelAnimationFrame(frameId)
}, [file.id, sectionHeights, sections])
}, [sectionHeights, sections, viewStateKey])
const openAlternateDiff = useCallback(() => {
if (!file.combinedAlternate) {

View file

@ -33,6 +33,7 @@ type MarkdownViewMode = 'source' | 'rich'
export function EditorContent({
activeFile,
viewStateScopeId,
fileContents,
diffContents,
editBuffers,
@ -47,6 +48,7 @@ export function EditorContent({
handleSave
}: {
activeFile: OpenFile
viewStateScopeId: string
fileContents: Record<string, FileContent>
diffContents: Record<string, GitDiffResult>
editBuffers: Record<string, string>
@ -65,6 +67,13 @@ export function EditorContent({
handleDirtyStateHint: (dirty: boolean) => void
handleSave: (content: string) => Promise<void>
}): React.JSX.Element {
const editorViewStateKey =
viewStateScopeId === activeFile.id
? activeFile.filePath
: `${activeFile.filePath}::${viewStateScopeId}`
const diffViewStateKey =
viewStateScopeId === activeFile.id ? activeFile.id : `${activeFile.id}::${viewStateScopeId}`
const openConflictFile = useAppStore((s) => s.openConflictFile)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const closeFile = useAppStore((s) => s.closeFile)
@ -80,12 +89,14 @@ export function EditorContent({
const renderMonacoEditor = (fc: FileContent): React.JSX.Element => (
// Why: Without a key, React reuses the same MonacoEditor instance when
// switching tabs, just updating props. That means useLayoutEffect cleanup
// (which snapshots scroll position) never fires. Keying on activeFile.id
// forces unmount/remount so the scroll cache captures the outgoing position.
// switching tabs or split panes, just updating props. That means
// useLayoutEffect cleanup (which snapshots scroll position) never fires.
// Keying on the visible pane identity forces unmount/remount so each split
// tab keeps its own viewport state even when the underlying file is shared.
<MonacoEditor
key={activeFile.id}
key={viewStateScopeId}
filePath={activeFile.filePath}
viewStateKey={editorViewStateKey}
relativePath={activeFile.relativePath}
content={editBuffers[activeFile.id] ?? fc.content}
language={resolvedLanguage}
@ -158,10 +169,11 @@ export function EditorContent({
<div className="min-h-0 flex-1">
{/* Why: same remount reasoning as MonacoEditor — see renderMonacoEditor. */}
<RichMarkdownEditor
key={activeFile.id}
key={viewStateScopeId}
fileId={activeFile.id}
content={editorContent}
filePath={activeFile.filePath}
scrollCacheKey={`${editorViewStateKey}:rich`}
onContentChange={onContentChangeWithFm}
onDirtyStateHint={handleDirtyStateHint}
onSave={onSaveWithFm}
@ -183,9 +195,10 @@ export function EditorContent({
user out of preview entirely. Source mode remains available for edits. */}
<div className="min-h-0 flex-1">
<MarkdownPreview
key={activeFile.id}
key={viewStateScopeId}
content={currentContent}
filePath={activeFile.filePath}
scrollCacheKey={`${editorViewStateKey}:preview`}
/>
</div>
</div>
@ -232,7 +245,13 @@ export function EditorContent({
}
if (isCombinedDiff) {
return <CombinedDiffViewer key={activeFile.id} file={activeFile} />
return (
<CombinedDiffViewer
key={viewStateScopeId}
file={activeFile}
viewStateKey={diffViewStateKey}
/>
)
}
if (activeFile.mode === 'edit') {
@ -306,8 +325,8 @@ export function EditorContent({
}
return (
<DiffViewer
key={activeFile.id}
modelKey={activeFile.id}
key={viewStateScopeId}
modelKey={diffViewStateKey}
originalContent={dc.originalContent}
modifiedContent={editBuffers[activeFile.id] ?? dc.modifiedContent}
language={resolvedLanguage}

View file

@ -57,9 +57,11 @@ type FileContent = {
type DiffContent = GitDiffResult
function EditorPanelInner({
activeFileId: activeFileIdProp
activeFileId: activeFileIdProp,
activeViewStateId: activeViewStateIdProp
}: {
activeFileId?: string | null
activeViewStateId?: string | null
} = {}): React.JSX.Element | null {
const openFiles = useAppStore((s) => s.openFiles)
const globalActiveFileId = useAppStore((s) => s.activeFileId)
@ -78,6 +80,7 @@ function EditorPanelInner({
const settings = useAppStore((s) => s.settings)
const activeFile = openFiles.find((f) => f.id === activeFileId) ?? null
const activeViewStateId = activeViewStateIdProp ?? activeFileId
const [fileContents, setFileContents] = useState<Record<string, FileContent>>({})
const [diffContents, setDiffContents] = useState<Record<string, DiffContent>>({})
@ -93,6 +96,14 @@ function EditorPanelInner({
const [pathMenuOpen, setPathMenuOpen] = useState(false)
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
const deleteCacheEntriesByPrefix = useCallback(<T,>(cache: Map<string, T>, prefix: string) => {
for (const key of cache.keys()) {
if (key.startsWith(prefix)) {
cache.delete(key)
}
}
}, [])
// Why: When the user changes their global diff-view preference in Settings,
// sync the local toggle to match during render (avoids flash of stale diff mode).
if (settings?.diffDefaultView !== prevDiffView) {
@ -133,6 +144,7 @@ function EditorPanelInner({
// prop is provided. This convention is version-dependent.
monaco.editor.getModel(monaco.Uri.parse(prevFile.filePath))?.dispose()
scrollTopCache.delete(prevFile.filePath)
deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.filePath}::`)
// Why: markdown edit tabs cycle through three view modes (source, rich,
// preview), each caching scroll under a mode-scoped key. All must be
// evicted so a reopened file starts fresh regardless of which mode was
@ -140,6 +152,7 @@ function EditorPanelInner({
scrollTopCache.delete(`${prevFile.filePath}:rich`)
scrollTopCache.delete(`${prevFile.filePath}:preview`)
cursorPositionCache.delete(prevFile.filePath)
deleteCacheEntriesByPrefix(cursorPositionCache, `${prevFile.filePath}::`)
break
case 'diff':
// Why: kept diff models are keyed by tab id, not file path, because the
@ -147,6 +160,7 @@ function EditorPanelInner({
monaco.editor.getModel(monaco.Uri.parse(`diff:original:${prevId}`))?.dispose()
monaco.editor.getModel(monaco.Uri.parse(`diff:modified:${prevId}`))?.dispose()
diffViewStateCache.delete(prevId)
deleteCacheEntriesByPrefix(diffViewStateCache, `${prevId}::`)
break
case 'conflict-review':
break
@ -154,7 +168,7 @@ function EditorPanelInner({
}
}
prevOpenFilesRef.current = currentFilesById
}, [openFiles])
}, [deleteCacheEntriesByPrefix, openFiles])
// Load file content when active file changes
useEffect(() => {
@ -728,6 +742,7 @@ function EditorPanelInner({
<Suspense fallback={loadingFallback}>
<EditorContent
activeFile={activeFile}
viewStateScopeId={activeViewStateId ?? activeFile.id}
fileContents={fileContents}
diffContents={diffContents}
editBuffers={editorDrafts}

View file

@ -25,11 +25,13 @@ import {
type MarkdownPreviewProps = {
content: string
filePath: string
scrollCacheKey: string
}
export default function MarkdownPreview({
content,
filePath
filePath,
scrollCacheKey
}: MarkdownPreviewProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
@ -57,11 +59,9 @@ export default function MarkdownPreview({
.trim()
}, [frontMatter])
// Why: Each markdown viewing mode (source/rich/preview) produces different
// DOM structures and content heights. A scroll position saved in source mode
// has no meaningful correspondence in preview mode. Using mode-scoped keys
// means each mode remembers its own position independently.
const scrollCacheKey = `${filePath}:preview`
// Why: each split pane needs its own markdown preview viewport even when the
// underlying file is shared. The caller passes a pane-scoped cache key so
// duplicate tabs do not overwrite each other's preview scroll state.
// Save scroll position with trailing throttle and synchronous unmount snapshot.
useLayoutEffect(() => {

View file

@ -19,6 +19,7 @@ import { computeMonacoRevealRange } from './monaco-reveal-range'
type MonacoEditorProps = {
filePath: string
viewStateKey: string
relativePath: string
content: string
language: string
@ -31,6 +32,7 @@ type MonacoEditorProps = {
export default function MonacoEditor({
filePath,
viewStateKey,
relativePath,
content,
language,
@ -147,7 +149,7 @@ export default function MonacoEditor({
}
editorInstance.onDidChangeCursorPosition((e) => {
setEditorCursorLine(filePath, e.position.lineNumber)
setWithLRU(cursorPositionCache, filePath, {
setWithLRU(cursorPositionCache, viewStateKey, {
lineNumber: e.position.lineNumber,
column: e.position.column
})
@ -162,7 +164,7 @@ export default function MonacoEditor({
clearTimeout(scrollThrottleTimerRef.current)
}
scrollThrottleTimerRef.current = setTimeout(() => {
setWithLRU(scrollTopCache, filePath, e.scrollTop)
setWithLRU(scrollTopCache, viewStateKey, e.scrollTop)
scrollThrottleTimerRef.current = null
}, 150)
})
@ -194,8 +196,8 @@ export default function MonacoEditor({
useAppStore.getState().setPendingEditorReveal(null)
})
} else {
const savedCursor = cursorPositionCache.get(filePath)
const savedScrollTop = scrollTopCache.get(filePath)
const savedCursor = cursorPositionCache.get(viewStateKey)
const savedScrollTop = scrollTopCache.get(viewStateKey)
if (savedScrollTop !== undefined || savedCursor) {
// Why: Monaco renders synchronously, so a single RAF is sufficient to
// wait for the layout pass. Unlike react-markdown or Tiptap, there is
@ -243,10 +245,10 @@ export default function MonacoEditor({
}
const ed = editorRef.current
if (ed) {
setWithLRU(scrollTopCache, filePath, ed.getScrollTop())
setWithLRU(scrollTopCache, viewStateKey, ed.getScrollTop())
const pos = ed.getPosition()
if (pos) {
setWithLRU(cursorPositionCache, filePath, {
setWithLRU(cursorPositionCache, viewStateKey, {
lineNumber: pos.lineNumber,
column: pos.column
})
@ -255,7 +257,7 @@ export default function MonacoEditor({
cancelScheduledReveal()
clearTransientRevealHighlight()
}
}, [cancelScheduledReveal, clearTransientRevealHighlight, filePath])
}, [cancelScheduledReveal, clearTransientRevealHighlight, viewStateKey])
// Update editor options when settings change
useEffect(() => {

View file

@ -30,6 +30,7 @@ type RichMarkdownEditorProps = {
fileId: string
content: string
filePath: string
scrollCacheKey: string
onContentChange: (content: string) => void
onDirtyStateHint: (dirty: boolean) => void
onSave: (content: string) => void
@ -43,6 +44,7 @@ export default function RichMarkdownEditor({
fileId,
content,
filePath,
scrollCacheKey,
onContentChange,
onDirtyStateHint,
onSave
@ -329,7 +331,7 @@ export default function RichMarkdownEditor({
return flushPendingSerialization
}, [flushPendingSerialization])
useEditorScrollRestore(scrollContainerRef, `${filePath}:rich`, editor)
useEditorScrollRestore(scrollContainerRef, scrollCacheKey, editor)
// Why: the custom Image extension reads filePath from editor.storage to resolve
// relative image src values to file:// URLs for display. After updating the

View file

@ -1,11 +1,12 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Globe, X, ExternalLink } from 'lucide-react'
import { Globe, X, ExternalLink, Columns2, Rows2 } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
@ -47,7 +48,8 @@ export default function BrowserTab({
hasTabsToRight,
onActivate,
onClose,
onCloseToRight
onCloseToRight,
onSplitGroup
}: {
tab: BrowserTabState
isActive: boolean
@ -55,6 +57,7 @@ export default function BrowserTab({
onActivate: () => void
onClose: () => void
onCloseToRight: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
}): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id
@ -163,6 +166,23 @@ export default function BrowserTab({
sideOffset={0}
align="start"
>
<DropdownMenuItem onSelect={() => onSplitGroup('up', tab.id)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Up
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('down', tab.id)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Down
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('left', tab.id)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('right', tab.id)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onClose}>Close</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
Close Tabs To The Right

View file

@ -1,7 +1,16 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { X, FileCode, GitCompareArrows, Copy, ShieldAlert, ExternalLink } from 'lucide-react'
import {
X,
FileCode,
GitCompareArrows,
Copy,
ShieldAlert,
ExternalLink,
Columns2,
Rows2
} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@ -35,9 +44,10 @@ export default function EditorFileTab({
onClose,
onCloseToRight,
onCloseAll,
onPin
onPin,
onSplitGroup
}: {
file: OpenFile
file: OpenFile & { tabId?: string }
isActive: boolean
hasTabsToRight: boolean
statusByRelativePath: Map<string, GitFileStatus>
@ -46,9 +56,13 @@ export default function EditorFileTab({
onCloseToRight: () => void
onCloseAll: () => void
onPin?: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
}): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: file.id
// Why: split groups can duplicate the same open file into multiple visible
// tabs. Using the unified tab ID keeps each rendered tab draggable as a
// distinct item instead of collapsing every copy onto the file entity ID.
id: file.tabId ?? file.id
})
const style = {
@ -186,6 +200,23 @@ export default function EditorFileTab({
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" sideOffset={0} align="start">
<DropdownMenuItem onSelect={() => onSplitGroup('up', file.tabId ?? file.id)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Up
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('down', file.tabId ?? file.id)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Down
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('left', file.tabId ?? file.id)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('right', file.tabId ?? file.id)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onClose}>Close</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { X, Terminal as TerminalIcon, Minimize2 } from 'lucide-react'
import { X, Terminal as TerminalIcon, Minimize2, Columns2, Rows2 } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@ -34,6 +34,7 @@ type SortableTabProps = {
onSetCustomTitle: (tabId: string, title: string | null) => void
onSetTabColor: (tabId: string, color: string | null) => void
onToggleExpand: (tabId: string) => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
}
export const TAB_COLORS = [
@ -63,7 +64,8 @@ export default function SortableTab({
onCloseToRight,
onSetCustomTitle,
onSetTabColor,
onToggleExpand
onToggleExpand,
onSplitGroup
}: SortableTabProps): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id
@ -208,6 +210,23 @@ export default function SortableTab({
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" sideOffset={0} align="start">
<DropdownMenuItem onSelect={() => onSplitGroup('up', tab.id)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Up
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('down', tab.id)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Down
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('left', tab.id)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('right', tab.id)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onClose(tab.id)}>Close</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onCloseOthers(tab.id)} disabled={tabCount <= 1}>
Close Others

View file

@ -62,7 +62,10 @@ type TabBarProps = {
onCloseAllFiles?: () => void
onPinFile?: (fileId: string, tabId?: string) => void
tabBarOrder?: string[]
onCreateSplitGroup?: (direction: 'right' | 'down') => void
onCreateSplitGroup?: (
direction: 'left' | 'right' | 'up' | 'down',
sourceVisibleTabId?: string
) => void
}
type TabItem =
@ -245,6 +248,9 @@ function TabBarInner({
onSetCustomTitle={onSetCustomTitle}
onSetTabColor={onSetTabColor}
onToggleExpand={onTogglePaneExpand}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
/>
)
}
@ -258,6 +264,9 @@ function TabBarInner({
onActivate={() => onActivateBrowserTab?.(item.id)}
onClose={() => onCloseBrowserTab?.(item.id)}
onCloseToRight={() => onCloseToRight(item.id)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
/>
)
}
@ -273,6 +282,9 @@ function TabBarInner({
onCloseToRight={() => onCloseToRight(item.id)}
onCloseAll={() => onCloseAllFiles?.()}
onPin={() => onPinFile?.(item.data.id, item.data.tabId)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
/>
)
})}

View file

@ -7,7 +7,8 @@ import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
import { useAppStore } from '../../store'
import TabBar from '../tab-bar/TabBar'
import TerminalPane from '../terminal-pane/TerminalPane'
import BrowserPane, { destroyPersistentWebview } from '../browser-pane/BrowserPane'
import BrowserPane from '../browser-pane/BrowserPane'
import { useTabGroupController } from './useTabGroupController'
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
@ -44,32 +45,14 @@ export default function TabGroupPanel({
)
)
const focusGroup = useAppStore((state) => state.focusGroup)
const activateTab = useAppStore((state) => state.activateTab)
const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab)
const closeOtherTabs = useAppStore((state) => state.closeOtherTabs)
const closeTabsToRight = useAppStore((state) => state.closeTabsToRight)
const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs)
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
const createTab = useAppStore((state) => state.createTab)
const closeTab = useAppStore((state) => state.closeTab)
const setActiveTab = useAppStore((state) => state.setActiveTab)
const setActiveFile = useAppStore((state) => state.setActiveFile)
const setActiveTabType = useAppStore((state) => state.setActiveTabType)
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
const setTabColor = useAppStore((state) => state.setTabColor)
const consumeSuppressedPtyExit = useAppStore((state) => state.consumeSuppressedPtyExit)
const createBrowserTab = useAppStore((state) => state.createBrowserTab)
const closeFile = useAppStore((state) => state.closeFile)
const closeAllFiles = useAppStore((state) => state.closeAllFiles)
const pinFile = useAppStore((state) => state.pinFile)
const expandedPaneByTabId = useAppStore((state) => state.expandedPaneByTabId)
const browserTabsByWorktree = useAppStore((state) => state.browserTabsByWorktree)
const runtimeTerminalTabs = useAppStore(
(state) => state.tabsByWorktree[worktreeId] ?? EMPTY_RUNTIME_TERMINALS
)
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
const group = useMemo(
() => worktreeGroups.find((item) => item.id === groupId) ?? null,
@ -147,165 +130,37 @@ export default function TabGroupPanel({
[runtimeTerminalTabs]
)
const closeEditorIfUnreferenced = useCallback(
(entityId: string, closingTabId: string) => {
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
(item) =>
item.id !== closingTabId &&
item.entityId === entityId &&
(item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review')
)
if (!otherReference) {
closeFile(entityId)
}
},
[closeFile, worktreeId]
)
const controller = useTabGroupController({
groupId,
worktreeId,
group,
groupTabs,
activeTab,
worktreeBrowserTabs
})
const handleActivateTerminal = useCallback(
const handleTerminalClose = useCallback(
(terminalId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (!item) {
return
if (item) {
controller.closeItem(item.id)
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveTab(terminalId)
setActiveTabType('terminal')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveTab, setActiveTabType, worktreeId]
[controller, groupTabs]
)
const handleActivateEditor = useCallback(
(tabId: string) => {
const item = groupTabs.find((candidate) => candidate.id === tabId)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveFile(item.entityId)
setActiveTabType('editor')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveFile, setActiveTabType, worktreeId]
)
const handleActivateBrowser = useCallback(
const handleBrowserClose = useCallback(
(browserTabId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveBrowserTab(browserTabId)
setActiveTabType('browser')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId]
)
const handleClose = useCallback(
(itemId: string) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return
}
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
closeUnifiedTab(item.id)
if (item) {
controller.closeItem(item.id)
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeTab, closeUnifiedTab, groupTabs]
)
const handleCloseGroup = useCallback(() => {
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
(item) => item.groupId === groupId
)
for (const item of items) {
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
closeUnifiedTab(item.id)
}
}
// Why: split creation can leave intentionally empty groups behind. Closing
// the group chrome must collapse those placeholders too, not just groups
// that still own tabs.
closeEmptyGroup(worktreeId, groupId)
}, [
closeBrowserTab,
closeEditorIfUnreferenced,
closeEmptyGroup,
closeTab,
closeUnifiedTab,
groupId,
worktreeId
])
const handleCreateSplitGroup = useCallback(
(direction: 'right' | 'down') => {
focusGroup(worktreeId, groupId)
createEmptySplitGroup(worktreeId, groupId, direction)
},
[createEmptySplitGroup, focusGroup, groupId, worktreeId]
)
const handleCloseOthers = useCallback(
(itemId: string) => {
const closedIds = closeOtherTabs(itemId)
for (const closedId of closedIds) {
const item = groupTabs.find((candidate) => candidate.id === closedId)
if (!item) {
continue
}
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
}
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeOtherTabs, closeTab, groupTabs]
)
const handleCloseToRight = useCallback(
(itemId: string) => {
const closedIds = closeTabsToRight(itemId)
for (const closedId of closedIds) {
const item = groupTabs.find((candidate) => candidate.id === closedId)
if (!item) {
continue
}
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
}
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeTabsToRight, closeTab, groupTabs]
[controller, groupTabs]
)
const tabBar = (
@ -314,21 +169,14 @@ export default function TabGroupPanel({
activeTabId={activeTab?.contentType === 'terminal' ? activeTab.entityId : null}
worktreeId={worktreeId}
expandedPaneByTabId={expandedPaneByTabId}
onActivate={handleActivateTerminal}
onClose={(terminalId) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
handleClose(item.id)
}
}}
onActivate={controller.activateTerminal}
onClose={handleTerminalClose}
onCloseOthers={(terminalId) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
handleCloseOthers(item.id)
controller.closeOthers(item.id)
}
}}
onCloseToRight={(terminalId) => {
@ -336,38 +184,12 @@ export default function TabGroupPanel({
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
handleCloseToRight(item.id)
controller.closeToRight(item.id)
}
}}
onReorder={(_, order) => {
if (!group) {
return
}
const itemOrder = order
.map(
(entityId) =>
groupTabs.find(
(item) => item.contentType === 'terminal' && item.entityId === entityId
)?.id
)
.filter((value): value is string => Boolean(value))
.concat(
group.tabOrder.filter(
(itemId) =>
!groupTabs.find((item) => item.contentType === 'terminal' && item.id === itemId)
)
)
reorderUnifiedTabs(groupId, itemOrder)
}}
onNewTerminalTab={() => {
const terminal = createTab(worktreeId)
setActiveTab(terminal.id)
setActiveTabType('terminal')
}}
onNewBrowserTab={() => {
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab' })
}}
onReorder={(_, order) => controller.reorderTabBar(order)}
onNewTerminalTab={controller.newTerminalTab}
onNewBrowserTab={controller.newBrowserTab}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
onTogglePaneExpand={() => {}}
@ -386,18 +208,11 @@ export default function TabGroupPanel({
? 'browser'
: 'editor'
}
onActivateFile={handleActivateEditor}
onCloseFile={handleClose}
onActivateBrowserTab={handleActivateBrowser}
onCloseBrowserTab={(browserTabId) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (item) {
handleClose(item.id)
}
}}
onCloseAllFiles={closeAllFiles}
onActivateFile={controller.activateEditor}
onCloseFile={controller.closeItem}
onActivateBrowserTab={controller.activateBrowser}
onCloseBrowserTab={handleBrowserClose}
onCloseAllFiles={controller.closeAllEditorTabsInGroup}
onPinFile={(_fileId, tabId) => {
if (!tabId) {
return
@ -406,16 +221,10 @@ export default function TabGroupPanel({
if (!item) {
return
}
pinFile(item.entityId, item.id)
controller.pinFile(item.entityId, item.id)
}}
tabBarOrder={(group?.tabOrder ?? []).map((itemId) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return itemId
}
return item.contentType === 'terminal' ? item.entityId : item.id
})}
onCreateSplitGroup={handleCreateSplitGroup}
tabBarOrder={controller.tabBarOrder}
onCreateSplitGroup={controller.createSplitGroup}
/>
)
@ -428,27 +237,28 @@ export default function TabGroupPanel({
}`}
onPointerDown={() => focusGroup(worktreeId, groupId)}
>
{/* Why: every group, including the initial unsplit root, must render its
chrome inside the same panel stack. Portaling the first group's tabs
into the window titlebar created a second vertical frame of reference,
so the first split appeared to "jump down" when later groups rendered
inline below it. */}
<div className="flex items-stretch h-9 shrink-0 border-b border-border bg-card">
{tabBar}
{hasSplitGroups && (
<button
type="button"
aria-label="Close tab group"
title="Close tab group"
onClick={(event) => {
event.stopPropagation()
handleCloseGroup()
}}
className="mr-1 my-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/50 hover:text-foreground group-hover/tab-group:opacity-100 focus:opacity-100"
>
<X className="size-4" />
</button>
)}
{/* Why: every split group must keep its own real tab row because the app
can show multiple groups at once, while the window titlebar only has
one shared center slot. Rendering true tab chrome here preserves
per-group titles without making groups fight over one portal target. */}
<div className="shrink-0 border-b border-border bg-card">
<div className="flex items-stretch">
<div className="min-w-0 flex-1">{tabBar}</div>
{hasSplitGroups && (
<button
type="button"
aria-label="Close Group"
title="Close Group"
onClick={(event) => {
event.stopPropagation()
controller.closeGroup()
}}
className="mx-1 my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
</div>
<div className="relative flex-1 min-h-0 overflow-hidden">
@ -472,9 +282,9 @@ export default function TabGroupPanel({
if (consumeSuppressedPtyExit(ptyId)) {
return
}
handleClose(item.id)
controller.closeItem(item.id)
}}
onCloseTab={() => handleClose(item.id)}
onCloseTab={() => controller.closeItem(item.id)}
/>
))}
@ -495,7 +305,7 @@ export default function TabGroupPanel({
</div>
}
>
<EditorPanel activeFileId={activeTab.entityId} />
<EditorPanel activeFileId={activeTab.entityId} activeViewStateId={activeTab.id} />
</Suspense>
</div>
)}

View file

@ -0,0 +1,162 @@
import { useCallback, useState } from 'react'
import type { TabGroupLayoutNode } from '../../../../shared/types'
import { useAppStore } from '../../store'
import TabGroupPanel from './TabGroupPanel'
const MIN_RATIO = 0.15
const MAX_RATIO = 0.85
function ResizeHandle({
direction,
onRatioChange
}: {
direction: 'horizontal' | 'vertical'
onRatioChange: (ratio: number) => void
}): React.JSX.Element {
const isHorizontal = direction === 'horizontal'
const [dragging, setDragging] = useState(false)
const onPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault()
const handle = event.currentTarget
const container = handle.parentElement
if (!container) {
return
}
setDragging(true)
handle.setPointerCapture(event.pointerId)
const onPointerMove = (moveEvent: PointerEvent): void => {
if (!handle.hasPointerCapture(event.pointerId)) {
return
}
const rect = container.getBoundingClientRect()
const ratio = isHorizontal
? (moveEvent.clientX - rect.left) / rect.width
: (moveEvent.clientY - rect.top) / rect.height
onRatioChange(Math.min(MAX_RATIO, Math.max(MIN_RATIO, ratio)))
}
const cleanup = (): void => {
setDragging(false)
if (handle.hasPointerCapture(event.pointerId)) {
handle.releasePointerCapture(event.pointerId)
}
handle.removeEventListener('pointermove', onPointerMove)
handle.removeEventListener('pointerup', onPointerUp)
handle.removeEventListener('pointercancel', onPointerCancel)
handle.removeEventListener('lostpointercapture', onLostPointerCapture)
}
const onPointerUp = (): void => {
cleanup()
}
const onPointerCancel = (): void => {
cleanup()
}
const onLostPointerCapture = (): void => {
cleanup()
}
handle.addEventListener('pointermove', onPointerMove)
handle.addEventListener('pointerup', onPointerUp)
handle.addEventListener('pointercancel', onPointerCancel)
handle.addEventListener('lostpointercapture', onLostPointerCapture)
},
[isHorizontal, onRatioChange]
)
return (
<div
className={`shrink-0 ${
isHorizontal ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'
} ${dragging ? 'bg-accent' : 'bg-border hover:bg-accent/50'}`}
onPointerDown={onPointerDown}
/>
)
}
function SplitNode({
node,
nodePath,
worktreeId,
focusedGroupId,
hasSplitGroups
}: {
node: TabGroupLayoutNode
nodePath: string
worktreeId: string
focusedGroupId?: string
hasSplitGroups: boolean
}): React.JSX.Element {
const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio)
if (node.type === 'leaf') {
return (
<TabGroupPanel
groupId={node.groupId}
worktreeId={worktreeId}
isFocused={node.groupId === focusedGroupId}
hasSplitGroups={hasSplitGroups}
/>
)
}
const isHorizontal = node.direction === 'horizontal'
const ratio = node.ratio ?? 0.5
return (
<div
className="flex flex-1 min-w-0 min-h-0 overflow-hidden"
style={{ flexDirection: isHorizontal ? 'row' : 'column' }}
>
<div className="flex min-w-0 min-h-0 overflow-hidden" style={{ flex: `${ratio} 1 0%` }}>
<SplitNode
node={node.first}
nodePath={nodePath.length > 0 ? `${nodePath}.first` : 'first'}
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
hasSplitGroups={hasSplitGroups}
/>
</div>
<ResizeHandle
direction={node.direction}
onRatioChange={(nextRatio) => setTabGroupSplitRatio(worktreeId, nodePath, nextRatio)}
/>
<div className="flex min-w-0 min-h-0 overflow-hidden" style={{ flex: `${1 - ratio} 1 0%` }}>
<SplitNode
node={node.second}
nodePath={nodePath.length > 0 ? `${nodePath}.second` : 'second'}
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
hasSplitGroups={hasSplitGroups}
/>
</div>
</div>
)
}
export default function TabGroupSplitLayout({
layout,
worktreeId,
focusedGroupId
}: {
layout: TabGroupLayoutNode
worktreeId: string
focusedGroupId?: string
}): React.JSX.Element {
return (
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
<SplitNode
node={layout}
nodePath=""
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
hasSplitGroups={layout.type === 'split'}
/>
</div>
)
}

View file

@ -0,0 +1,296 @@
import { useCallback, useMemo } from 'react'
import type { BrowserTab as BrowserTabState, Tab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { destroyPersistentWebview } from '../browser-pane/BrowserPane'
export function useTabGroupController({
groupId,
worktreeId,
group,
groupTabs,
activeTab,
worktreeBrowserTabs
}: {
groupId: string
worktreeId: string
group: { id: string; tabOrder: string[] } | null
groupTabs: Tab[]
activeTab: Tab | null
worktreeBrowserTabs: BrowserTabState[]
}) {
const focusGroup = useAppStore((state) => state.focusGroup)
const activateTab = useAppStore((state) => state.activateTab)
const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab)
const closeOtherTabs = useAppStore((state) => state.closeOtherTabs)
const closeTabsToRight = useAppStore((state) => state.closeTabsToRight)
const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs)
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
const createTab = useAppStore((state) => state.createTab)
const closeTab = useAppStore((state) => state.closeTab)
const setActiveTab = useAppStore((state) => state.setActiveTab)
const setActiveFile = useAppStore((state) => state.setActiveFile)
const setActiveTabType = useAppStore((state) => state.setActiveTabType)
const createBrowserTab = useAppStore((state) => state.createBrowserTab)
const closeFile = useAppStore((state) => state.closeFile)
const pinFile = useAppStore((state) => state.pinFile)
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
const copyUnifiedTabToGroup = useAppStore((state) => state.copyUnifiedTabToGroup)
const closeEditorIfUnreferenced = useCallback(
(entityId: string, closingTabId: string) => {
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
(item) =>
item.id !== closingTabId &&
item.entityId === entityId &&
(item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review')
)
if (!otherReference) {
closeFile(entityId)
}
},
[closeFile, worktreeId]
)
const closeItem = useCallback(
(itemId: string) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return
}
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
closeUnifiedTab(item.id)
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeTab, closeUnifiedTab, groupTabs]
)
const closeMany = useCallback(
(itemIds: string[]) => {
for (const itemId of itemIds) {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
continue
}
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
}
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeTab, groupTabs]
)
const activateTerminal = useCallback(
(terminalId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveTab(terminalId)
setActiveTabType('terminal')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveTab, setActiveTabType, worktreeId]
)
const activateEditor = useCallback(
(tabId: string) => {
const item = groupTabs.find((candidate) => candidate.id === tabId)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveFile(item.entityId)
setActiveTabType('editor')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveFile, setActiveTabType, worktreeId]
)
const activateBrowser = useCallback(
(browserTabId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveBrowserTab(browserTabId)
setActiveTabType('browser')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId]
)
const createSplitGroup = useCallback(
(direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId?: string) => {
const sourceTab =
groupTabs.find((candidate) =>
candidate.contentType === 'terminal' || candidate.contentType === 'browser'
? candidate.entityId === sourceVisibleTabId
: candidate.id === sourceVisibleTabId
) ?? activeTab
focusGroup(worktreeId, groupId)
const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction)
if (!newGroupId || !sourceTab) {
return
}
// Why: tab context-menu split actions are scoped to the tab that opened
// the menu, not whichever tab was already active in the group. Falling
// back to the active tab preserves the "+" menu behavior, which creates
// a split from the current surface without a tab-specific source ID.
// Why: VS Code-style split actions leave the original group untouched and
// seed the new group with equivalent visible content when possible.
if (sourceTab.contentType === 'terminal') {
const terminal = createTab(worktreeId, newGroupId)
setActiveTab(terminal.id)
setActiveTabType('terminal')
return
}
if (sourceTab.contentType === 'browser') {
const browserTab = worktreeBrowserTabs.find(
(candidate) => candidate.id === sourceTab.entityId
)
if (!browserTab) {
return
}
createBrowserTab(browserTab.worktreeId, browserTab.url, {
title: browserTab.title,
sessionProfileId: browserTab.sessionProfileId
})
return
}
copyUnifiedTabToGroup(sourceTab.id, newGroupId, {
entityId: sourceTab.entityId,
label: sourceTab.label,
customLabel: sourceTab.customLabel,
color: sourceTab.color,
isPinned: sourceTab.isPinned
})
setActiveFile(sourceTab.entityId)
setActiveTabType('editor')
},
[
createBrowserTab,
createEmptySplitGroup,
createTab,
copyUnifiedTabToGroup,
focusGroup,
groupId,
groupTabs,
activeTab,
setActiveFile,
setActiveTab,
setActiveTabType,
worktreeBrowserTabs,
worktreeId
]
)
const tabBarOrder = useMemo(
() =>
(group?.tabOrder ?? []).map((itemId) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return itemId
}
// Why: the tab bar renders terminals and browser workspaces by their
// backing runtime IDs, while editor tabs render by their unified tab
// IDs. Reorder callbacks must round-trip through the same visible IDs
// or dnd-kit cannot map the dragged tab back to the stored group order.
return item.contentType === 'terminal' || item.contentType === 'browser'
? item.entityId
: item.id
}),
[group, groupTabs]
)
return {
activateTerminal,
activateEditor,
activateBrowser,
closeItem,
closeGroup: () => {
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
(item) => item.groupId === groupId
)
for (const item of items) {
closeItem(item.id)
}
// Why: split creation can intentionally leave empty placeholder groups
// behind. Closing the group chrome must collapse those panes even when
// no tabs remain to trigger `closeUnifiedTab` cleanup.
closeEmptyGroup(worktreeId, groupId)
},
closeOthers: (itemId: string) => closeMany(closeOtherTabs(itemId)),
closeToRight: (itemId: string) => closeMany(closeTabsToRight(itemId)),
closeAllEditorTabsInGroup: () => {
// Why: this action is launched from one split group's editor tab menu.
// In split layouts it must only close editor surfaces owned by that
// group, not every editor tab in the worktree.
for (const item of groupTabs) {
if (
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review'
) {
closeItem(item.id)
}
}
},
reorderTabBar: (order: string[]) => {
if (!group) {
return
}
const itemOrder = order
.map(
(visibleId) =>
groupTabs.find((item) =>
item.contentType === 'terminal' || item.contentType === 'browser'
? item.entityId === visibleId
: item.id === visibleId
)?.id
)
.filter((value): value is string => Boolean(value))
const orderedIds = new Set(itemOrder)
const remainingIds = group.tabOrder.filter((itemId) => !orderedIds.has(itemId))
reorderUnifiedTabs(groupId, itemOrder.concat(remainingIds))
},
newTerminalTab: () => {
const terminal = createTab(worktreeId, groupId)
setActiveTab(terminal.id)
setActiveTabType('terminal')
},
newBrowserTab: () => {
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab' })
},
pinFile,
copyUnifiedTabToGroup,
tabBarOrder,
createSplitGroup
}
}