mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: add split group UI with tab-bar context menus and resize handles (#646)
This commit is contained in:
parent
42e04268fb
commit
3b897d255d
15 changed files with 934 additions and 301 deletions
22
docs/split-groups-rollout-pr4.md
Normal file
22
docs/split-groups-rollout-pr4.md
Normal 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
|
||||
216
docs/tab-group-model-follow-up.md
Normal file
216
docs/tab-group-model-follow-up.md
Normal 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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
162
src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx
Normal file
162
src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
296
src/renderer/src/components/tab-group/useTabGroupController.ts
Normal file
296
src/renderer/src/components/tab-group/useTabGroupController.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue