From 3b897d255db74b624aa097900ea71695efc83b54 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:01:11 -0700 Subject: [PATCH] feat: add split group UI with tab-bar context menus and resize handles (#646) --- docs/split-groups-rollout-pr4.md | 22 ++ docs/tab-group-model-follow-up.md | 216 +++++++++++++ .../components/editor/CombinedDiffViewer.tsx | 45 +-- .../src/components/editor/EditorContent.tsx | 37 ++- .../src/components/editor/EditorPanel.tsx | 19 +- .../src/components/editor/MarkdownPreview.tsx | 12 +- .../src/components/editor/MonacoEditor.tsx | 16 +- .../components/editor/RichMarkdownEditor.tsx | 4 +- .../src/components/tab-bar/BrowserTab.tsx | 24 +- .../src/components/tab-bar/EditorFileTab.tsx | 39 ++- .../src/components/tab-bar/SortableTab.tsx | 23 +- .../src/components/tab-bar/TabBar.tsx | 14 +- .../components/tab-group/TabGroupPanel.tsx | 306 ++++-------------- .../tab-group/TabGroupSplitLayout.tsx | 162 ++++++++++ .../tab-group/useTabGroupController.ts | 296 +++++++++++++++++ 15 files changed, 934 insertions(+), 301 deletions(-) create mode 100644 docs/split-groups-rollout-pr4.md create mode 100644 docs/tab-group-model-follow-up.md create mode 100644 src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx create mode 100644 src/renderer/src/components/tab-group/useTabGroupController.ts diff --git a/docs/split-groups-rollout-pr4.md b/docs/split-groups-rollout-pr4.md new file mode 100644 index 000000000..1a660d7f4 --- /dev/null +++ b/docs/split-groups-rollout-pr4.md @@ -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 diff --git a/docs/tab-group-model-follow-up.md b/docs/tab-group-model-follow-up.md new file mode 100644 index 000000000..a569a8a73 --- /dev/null +++ b/docs/tab-group-model-follow-up.md @@ -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 diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index f3c1f7384..83d4b6181 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -42,7 +42,13 @@ type CachedCombinedDiffViewState = { const combinedDiffViewStateCache = new Map() const combinedDiffScrollTopCache = new Map() -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>(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) { diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 23b3c79fe..f094de96c 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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 diffContents: Record editBuffers: Record @@ -65,6 +67,13 @@ export function EditorContent({ handleDirtyStateHint: (dirty: boolean) => void handleSave: (content: string) => Promise }): 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. {/* Why: same remount reasoning as MonacoEditor — see renderMonacoEditor. */} @@ -232,7 +245,13 @@ export function EditorContent({ } if (isCombinedDiff) { - return + return ( + + ) } if (activeFile.mode === 'edit') { @@ -306,8 +325,8 @@ export function EditorContent({ } return ( 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>({}) const [diffContents, setDiffContents] = useState>({}) @@ -93,6 +96,14 @@ function EditorPanelInner({ const [pathMenuOpen, setPathMenuOpen] = useState(false) const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 }) + const deleteCacheEntriesByPrefix = useCallback((cache: Map, 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({ (null) const bodyRef = useRef(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(() => { diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 9cb829f9c..142650830 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -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(() => { diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index efad157a2..8b6745a24 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -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 diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 15e87f2e6..a42c57228 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -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" > + onSplitGroup('up', tab.id)}> + + Split Up + + onSplitGroup('down', tab.id)}> + + Split Down + + onSplitGroup('left', tab.id)}> + + Split Left + + onSplitGroup('right', tab.id)}> + + Split Right + + Close Close Tabs To The Right diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index b4b517bc5..3ef9f0a5f 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -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 @@ -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({ /> + onSplitGroup('up', file.tabId ?? file.id)}> + + Split Up + + onSplitGroup('down', file.tabId ?? file.id)}> + + Split Down + + onSplitGroup('left', file.tabId ?? file.id)}> + + Split Left + + onSplitGroup('right', file.tabId ?? file.id)}> + + Split Right + + Close Close All Editor Tabs diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 89b324f42..cf177beef 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -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({ /> + onSplitGroup('up', tab.id)}> + + Split Up + + onSplitGroup('down', tab.id)}> + + Split Down + + onSplitGroup('left', tab.id)}> + + Split Left + + onSplitGroup('right', tab.id)}> + + Split Right + + onClose(tab.id)}>Close onCloseOthers(tab.id)} disabled={tabCount <= 1}> Close Others diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 8b020055e..7a66b5ff9 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -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) + } /> ) })} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 96c69d305..08450c465 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -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. */} -
- {tabBar} - {hasSplitGroups && ( - - )} + {/* 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. */} +
+
+
{tabBar}
+ {hasSplitGroups && ( + + )} +
@@ -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({
} > - +
)} diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx new file mode 100644 index 000000000..f19ed4300 --- /dev/null +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -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) => { + 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 ( +
+ ) +} + +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 ( + + ) + } + + const isHorizontal = node.direction === 'horizontal' + const ratio = node.ratio ?? 0.5 + + return ( +
+
+ 0 ? `${nodePath}.first` : 'first'} + worktreeId={worktreeId} + focusedGroupId={focusedGroupId} + hasSplitGroups={hasSplitGroups} + /> +
+ setTabGroupSplitRatio(worktreeId, nodePath, nextRatio)} + /> +
+ 0 ? `${nodePath}.second` : 'second'} + worktreeId={worktreeId} + focusedGroupId={focusedGroupId} + hasSplitGroups={hasSplitGroups} + /> +
+
+ ) +} + +export default function TabGroupSplitLayout({ + layout, + worktreeId, + focusedGroupId +}: { + layout: TabGroupLayoutNode + worktreeId: string + focusedGroupId?: string +}): React.JSX.Element { + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/tab-group/useTabGroupController.ts b/src/renderer/src/components/tab-group/useTabGroupController.ts new file mode 100644 index 000000000..20f41f72e --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupController.ts @@ -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 + } +}