mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): prevent dead terminal after split via WebGL lifecycle and worktree dedup (#1298)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -41,6 +41,12 @@ import { toPublicPane } from './pane-public-view'
|
|||||||
|
|
||||||
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
|
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
|
||||||
|
|
||||||
|
function reattachWebglIfNeeded(pane: ManagedPaneInternal): void {
|
||||||
|
if (pane.gpuRenderingEnabled && !pane.webglAddon && !pane.webglDisabledAfterContextLoss) {
|
||||||
|
attachWebgl(pane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class PaneManager {
|
export class PaneManager {
|
||||||
private root: HTMLElement
|
private root: HTMLElement
|
||||||
private panes: Map<number, ManagedPaneInternal> = new Map()
|
private panes: Map<number, ManagedPaneInternal> = new Map()
|
||||||
@@ -60,25 +66,16 @@ export class PaneManager {
|
|||||||
this.renderingSuspended = options.initialRenderingSuspended === true
|
this.renderingSuspended = options.initialRenderingSuspended === true
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
createInitialPane(opts?: { focus?: boolean }): ManagedPane {
|
createInitialPane(opts?: { focus?: boolean }): ManagedPane {
|
||||||
const pane = this.createPaneInternal()
|
const pane = this.createPaneInternal()
|
||||||
|
Object.assign(pane.container.style, {
|
||||||
// When the pane is the sole child of root (no splits), it must
|
width: '100%',
|
||||||
// fill the root container so FitAddon calculates correct dimensions.
|
height: '100%',
|
||||||
pane.container.style.width = '100%'
|
position: 'relative',
|
||||||
pane.container.style.height = '100%'
|
overflow: 'hidden'
|
||||||
pane.container.style.position = 'relative'
|
})
|
||||||
pane.container.style.overflow = 'hidden'
|
|
||||||
|
|
||||||
// Place directly into root
|
|
||||||
this.root.appendChild(pane.container)
|
this.root.appendChild(pane.container)
|
||||||
|
|
||||||
openTerminal(pane)
|
openTerminal(pane)
|
||||||
|
|
||||||
this.activePaneId = pane.id
|
this.activePaneId = pane.id
|
||||||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
||||||
|
|
||||||
@@ -108,43 +105,40 @@ export class PaneManager {
|
|||||||
const isVertical = direction === 'vertical'
|
const isVertical = direction === 'vertical'
|
||||||
const divider = this.createDividerWrapped(isVertical)
|
const divider = this.createDividerWrapped(isVertical)
|
||||||
|
|
||||||
// Why: wrapInSplit reparents the existing container, which causes the
|
// Why: wrapInSplit reparents the existing container, resetting scrollTop.
|
||||||
// browser to asynchronously reset scrollTop to 0 during layout. Capture
|
|
||||||
// the scroll state before reparenting so we can restore it after all
|
|
||||||
// layout and reflow have settled.
|
|
||||||
const scrollState = captureScrollState(existing.terminal)
|
const scrollState = captureScrollState(existing.terminal)
|
||||||
|
// Why: lock prevents safeFit/fitAllPanes from restoring scroll during
|
||||||
// Why: multiple async operations fire after the split (rAFs from
|
// the async settle window — scheduleSplitScrollRestore owns the restore.
|
||||||
// queueResizeAll, WebGL context loss, ResizeObserver 150ms debounce).
|
|
||||||
// Each would independently try to restore scroll, potentially to wrong
|
|
||||||
// positions due to intermediate buffer states. The lock makes safeFit
|
|
||||||
// and fitAllPanesInternal skip their own scroll restoration, leaving
|
|
||||||
// the authoritative restore to the timeout below.
|
|
||||||
existing.pendingSplitScrollState = scrollState
|
existing.pendingSplitScrollState = scrollState
|
||||||
|
|
||||||
|
// Why: DOM reparenting can silently invalidate a WebGL context without
|
||||||
|
// firing contextlost — Chromium reclaims the oldest context near its
|
||||||
|
// ~8–16 limit. Dispose before the move, reattach in the 200ms timer.
|
||||||
|
const hadWebgl = !!existing.webglAddon
|
||||||
|
disposeWebgl(existing)
|
||||||
|
|
||||||
wrapInSplit(existing.container, newPane.container, isVertical, divider, opts)
|
wrapInSplit(existing.container, newPane.container, isVertical, divider, opts)
|
||||||
|
|
||||||
openTerminal(newPane)
|
openTerminal(newPane)
|
||||||
this.activePaneId = newPane.id
|
this.activePaneId = newPane.id
|
||||||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
||||||
this.applyDividerStylesWrapped()
|
applyDividerStyles(this.root, this.styleOptions)
|
||||||
newPane.terminal?.focus()
|
newPane.terminal?.focus()
|
||||||
updateMultiPaneState(this.getDragCallbacks())
|
updateMultiPaneState(this.getDragCallbacks())
|
||||||
// Why: forward the caller's spawn hint so onPaneCreated → connectPanePty
|
// Why: forward cwd hint so the new PTY spawns in the source pane's cwd.
|
||||||
// can boot the new PTY in the source pane's live cwd instead of the
|
|
||||||
// worktree root. The hint is synchronous-only: splitPane returns after
|
|
||||||
// onPaneCreated runs, so there is no reason for it to outlive this call.
|
|
||||||
void this.options.onPaneCreated?.(
|
void this.options.onPaneCreated?.(
|
||||||
toPublicPane(newPane),
|
toPublicPane(newPane),
|
||||||
opts?.cwd ? { cwd: opts.cwd } : undefined
|
opts?.cwd ? { cwd: opts.cwd } : undefined
|
||||||
)
|
)
|
||||||
this.options.onLayoutChanged?.()
|
this.options.onLayoutChanged?.()
|
||||||
|
|
||||||
|
const reattach = hadWebgl ? reattachWebglIfNeeded : undefined
|
||||||
scheduleSplitScrollRestore(
|
scheduleSplitScrollRestore(
|
||||||
(id) => this.panes.get(id),
|
(id) => this.panes.get(id),
|
||||||
existing.id,
|
existing.id,
|
||||||
scrollState,
|
scrollState,
|
||||||
() => this.destroyed
|
() => this.destroyed,
|
||||||
|
reattach
|
||||||
)
|
)
|
||||||
|
|
||||||
return toPublicPane(newPane)
|
return toPublicPane(newPane)
|
||||||
@@ -171,13 +165,9 @@ export class PaneManager {
|
|||||||
paneContainer.remove()
|
paneContainer.remove()
|
||||||
}
|
}
|
||||||
if (this.activePaneId === paneId) {
|
if (this.activePaneId === paneId) {
|
||||||
const remaining = Array.from(this.panes.values())
|
const next = this.panes.values().next().value as ManagedPaneInternal | undefined
|
||||||
if (remaining.length > 0) {
|
this.activePaneId = next?.id ?? null
|
||||||
this.activePaneId = remaining[0].id
|
next?.terminal.focus()
|
||||||
remaining[0].terminal.focus()
|
|
||||||
} else {
|
|
||||||
this.activePaneId = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
||||||
for (const p of this.panes.values()) {
|
for (const p of this.panes.values()) {
|
||||||
@@ -225,14 +215,10 @@ export class PaneManager {
|
|||||||
setPaneStyleOptions(opts: PaneStyleOptions): void {
|
setPaneStyleOptions(opts: PaneStyleOptions): void {
|
||||||
this.styleOptions = { ...opts }
|
this.styleOptions = { ...opts }
|
||||||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
||||||
this.applyDividerStylesWrapped()
|
applyDividerStyles(this.root, this.styleOptions)
|
||||||
applyRootBackground(this.root, this.styleOptions)
|
applyRootBackground(this.root, this.styleOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enable or disable programming-ligatures rendering on a single pane.
|
|
||||||
* Called by applyTerminalAppearance whenever the resolved ligatures state
|
|
||||||
* changes, so toggling the setting or switching fonts takes effect on
|
|
||||||
* live panes without restarting. */
|
|
||||||
setPaneLigaturesEnabled(paneId: number, enabled: boolean): void {
|
setPaneLigaturesEnabled(paneId: number, enabled: boolean): void {
|
||||||
const pane = this.panes.get(paneId)
|
const pane = this.panes.get(paneId)
|
||||||
if (!pane) {
|
if (!pane) {
|
||||||
@@ -272,25 +258,18 @@ export class PaneManager {
|
|||||||
this.renderingSuspended = false
|
this.renderingSuspended = false
|
||||||
for (const pane of this.panes.values()) {
|
for (const pane of this.panes.values()) {
|
||||||
pane.webglAttachmentDeferred = false
|
pane.webglAttachmentDeferred = false
|
||||||
if (pane.gpuRenderingEnabled && !pane.webglDisabledAfterContextLoss && !pane.webglAddon) {
|
reattachWebglIfNeeded(pane)
|
||||||
attachWebgl(pane)
|
// Why: fresh WebGL canvas has no content — refresh prevents frozen terminal.
|
||||||
// Why: the fitPanes() optimization skips panes whose dimensions are
|
if (pane.webglAddon) {
|
||||||
// unchanged (common when a worktree goes hidden→visible at the same
|
|
||||||
// window size). But the fresh WebGL canvas created by attachWebgl()
|
|
||||||
// has no painted content — without an explicit refresh the terminal
|
|
||||||
// appears frozen until something forces a dimension change (e.g. a
|
|
||||||
// split). This mirrors the onContextLoss handler in attachWebgl which
|
|
||||||
// calls the same refresh after falling back to the DOM renderer.
|
|
||||||
try {
|
try {
|
||||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore — pane may not be fully initialised yet */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Move a pane from its current position to a new position relative to a target pane. */
|
|
||||||
movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {
|
movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {
|
||||||
handlePaneDrop(sourcePaneId, targetPaneId, zone, this.dragState, this.getDragCallbacks())
|
handlePaneDrop(sourcePaneId, targetPaneId, zone, this.dragState, this.getDragCallbacks())
|
||||||
}
|
}
|
||||||
@@ -305,10 +284,6 @@ export class PaneManager {
|
|||||||
this.activePaneId = null
|
this.activePaneId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
private createPaneInternal(): ManagedPaneInternal {
|
private createPaneInternal(): ManagedPaneInternal {
|
||||||
const id = this.nextPaneId++
|
const id = this.nextPaneId++
|
||||||
const pane = createPaneDOM(
|
const pane = createPaneDOM(
|
||||||
@@ -316,14 +291,10 @@ export class PaneManager {
|
|||||||
this.options,
|
this.options,
|
||||||
this.dragState,
|
this.dragState,
|
||||||
this.getDragCallbacks(),
|
this.getDragCallbacks(),
|
||||||
|
// Why: always re-focus even if already active — after splits the
|
||||||
|
// browser's real textarea focus can lag the manager's activePaneId.
|
||||||
(paneId) => {
|
(paneId) => {
|
||||||
if (!this.destroyed) {
|
if (!this.destroyed) {
|
||||||
// Why: split-pane layout/focus callbacks can leave the manager's
|
|
||||||
// activePaneId temporarily in sync while the browser's real focused
|
|
||||||
// xterm textarea is still on a different pane. Clicking a pane must
|
|
||||||
// always re-focus its terminal, even if the manager already thinks
|
|
||||||
// that pane is active; otherwise input can keep going to the wrong
|
|
||||||
// split after vertical/horizontal splits.
|
|
||||||
this.setActivePane(paneId, { focus: true })
|
this.setActivePane(paneId, { focus: true })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -336,16 +307,6 @@ export class PaneManager {
|
|||||||
return pane
|
return pane
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Focus-follows-mouse entry point. Collects gate inputs from the manager
|
|
||||||
* and delegates to the pure gate helper.
|
|
||||||
*
|
|
||||||
* Invariant for future contributors: modal overlays (context menus, close
|
|
||||||
* dialogs, command palette) must be rendered as portals/siblings OUTSIDE
|
|
||||||
* the pane container. If a future overlay is ever rendered inside a .pane
|
|
||||||
* element, mouseenter will still fire on the pane underneath and this
|
|
||||||
* handler will incorrectly switch focus. Keep overlays out of the pane.
|
|
||||||
*/
|
|
||||||
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
|
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
|
||||||
if (
|
if (
|
||||||
shouldFollowMouseFocus({
|
shouldFollowMouseFocus({
|
||||||
@@ -368,11 +329,6 @@ export class PaneManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyDividerStylesWrapped(): void {
|
|
||||||
applyDividerStyles(this.root, this.styleOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the callbacks object for drag-reorder functions. */
|
|
||||||
private getDragCallbacks() {
|
private getDragCallbacks() {
|
||||||
return {
|
return {
|
||||||
getPanes: () => this.panes,
|
getPanes: () => this.panes,
|
||||||
@@ -382,7 +338,7 @@ export class PaneManager {
|
|||||||
safeFit: (pane: ManagedPaneInternal) => safeFit(pane),
|
safeFit: (pane: ManagedPaneInternal) => safeFit(pane),
|
||||||
applyPaneOpacity: () =>
|
applyPaneOpacity: () =>
|
||||||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions),
|
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions),
|
||||||
applyDividerStyles: () => this.applyDividerStylesWrapped(),
|
applyDividerStyles: () => applyDividerStyles(this.root, this.styleOptions),
|
||||||
refitPanesUnder: (el: HTMLElement) => refitPanesUnder(el, this.panes),
|
refitPanesUnder: (el: HTMLElement) => refitPanesUnder(el, this.panes),
|
||||||
onLayoutChanged: this.options.onLayoutChanged
|
onLayoutChanged: this.options.onLayoutChanged
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||||
import { restoreScrollState } from './pane-scroll'
|
import { restoreScrollState } from './pane-scroll'
|
||||||
|
|
||||||
// Why: wrapInSplit reparents the existing pane's container, which briefly
|
|
||||||
// detaches the WebGL canvas from the DOM. The WebGL renderer's internal
|
|
||||||
// render state can become stale after the re-attachment, leaving the canvas
|
|
||||||
// blank even though the terminal buffer has data. This mirrors the explicit
|
|
||||||
// refresh in resumeRendering() (pane-manager.ts) and the onContextLoss
|
|
||||||
// handler (pane-lifecycle.ts) which address the same "frozen terminal"
|
|
||||||
// symptom for analogous WebGL state transitions.
|
|
||||||
function refreshAfterReparent(pane: ManagedPaneInternal): void {
|
function refreshAfterReparent(pane: ManagedPaneInternal): void {
|
||||||
try {
|
try {
|
||||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||||
@@ -16,15 +9,79 @@ function refreshAfterReparent(pane: ManagedPaneInternal): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logPaneHealth(pane: ManagedPaneInternal, phase: string): void {
|
||||||
|
const canvases = pane.container.querySelectorAll('canvas')
|
||||||
|
const canvasInfo = Array.from(canvases).map((c) => {
|
||||||
|
const gl = c.getContext('webgl2') ?? c.getContext('webgl')
|
||||||
|
return {
|
||||||
|
w: c.width,
|
||||||
|
h: c.height,
|
||||||
|
inDOM: c.isConnected,
|
||||||
|
ctxLost: gl ? gl.isContextLost() : 'no-ctx'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const content = pane.serializeAddon?.serialize?.() ?? ''
|
||||||
|
// oxlint-disable-next-line no-control-regex
|
||||||
|
const stripped = content.replace(/[\s\x00-\x1f]/g, '')
|
||||||
|
const info = {
|
||||||
|
phase,
|
||||||
|
paneId: pane.id,
|
||||||
|
webgl: !!pane.webglAddon,
|
||||||
|
webglDeferred: pane.webglAttachmentDeferred,
|
||||||
|
webglDisabled: pane.webglDisabledAfterContextLoss,
|
||||||
|
canvases: canvasInfo,
|
||||||
|
contentLen: stripped.length,
|
||||||
|
bufferLines: pane.terminal.buffer.active.length
|
||||||
|
}
|
||||||
|
const hasBufferData = pane.terminal.buffer.active.length > pane.terminal.rows
|
||||||
|
if (stripped.length === 0 && hasBufferData) {
|
||||||
|
console.error(
|
||||||
|
'[split-diag] DEAD TERMINAL — pane',
|
||||||
|
pane.id,
|
||||||
|
pane.debugLabel ?? '',
|
||||||
|
'has buffer data but no rendered content at',
|
||||||
|
phase,
|
||||||
|
info
|
||||||
|
)
|
||||||
|
} else if (stripped.length === 0) {
|
||||||
|
console.log(
|
||||||
|
'[split-diag] pane',
|
||||||
|
pane.id,
|
||||||
|
pane.debugLabel ?? '',
|
||||||
|
'no content yet at',
|
||||||
|
phase,
|
||||||
|
'(PTY likely still spawning)'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'[split-diag] pane',
|
||||||
|
pane.id,
|
||||||
|
pane.debugLabel ?? '',
|
||||||
|
'healthy at',
|
||||||
|
phase,
|
||||||
|
'— content:',
|
||||||
|
stripped.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Why: reparenting a terminal container during split resets the viewport
|
// Why: reparenting a terminal container during split resets the viewport
|
||||||
// scroll position (browser clears scrollTop on DOM move). This schedules a
|
// scroll position (browser clears scrollTop on DOM move). This schedules a
|
||||||
// two-phase restore: an early double-rAF (~32ms) to minimise the visible
|
// two-phase restore: an early double-rAF (~32ms) to minimise the visible
|
||||||
// flash, plus a 200ms authoritative restore that also clears the scroll lock.
|
// flash, plus a 200ms authoritative restore that also clears the scroll lock.
|
||||||
|
//
|
||||||
|
// The optional reattachWebgl callback re-creates the WebGL addon after the
|
||||||
|
// DOM has settled. splitPane() disposes WebGL before wrapInSplit() to free
|
||||||
|
// the GPU context slot (Chromium silently kills the oldest context when
|
||||||
|
// approaching its limit without firing contextlost). Reattaching at 200ms
|
||||||
|
// — after all layout and reflow have completed — creates a fresh context on
|
||||||
|
// a stable DOM tree.
|
||||||
export function scheduleSplitScrollRestore(
|
export function scheduleSplitScrollRestore(
|
||||||
getPaneById: (id: number) => ManagedPaneInternal | undefined,
|
getPaneById: (id: number) => ManagedPaneInternal | undefined,
|
||||||
paneId: number,
|
paneId: number,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
isDestroyed: () => boolean
|
isDestroyed: () => boolean,
|
||||||
|
reattachWebgl?: (pane: ManagedPaneInternal) => void
|
||||||
): void {
|
): void {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -48,7 +105,21 @@ export function scheduleSplitScrollRestore(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
live.pendingSplitScrollState = null
|
live.pendingSplitScrollState = null
|
||||||
|
if (reattachWebgl) {
|
||||||
|
reattachWebgl(live)
|
||||||
|
}
|
||||||
restoreScrollState(live.terminal, scrollState)
|
restoreScrollState(live.terminal, scrollState)
|
||||||
refreshAfterReparent(live)
|
refreshAfterReparent(live)
|
||||||
}, 200)
|
}, 200)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isDestroyed()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const live = getPaneById(paneId)
|
||||||
|
// Skip suspended panes — they have no WebGL/content by design.
|
||||||
|
if (live && !live.webglAttachmentDeferred) {
|
||||||
|
logPaneHealth(live, '1s-health-check')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types'
|
import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types'
|
||||||
import { createDivider } from './pane-divider'
|
import { createDivider } from './pane-divider'
|
||||||
|
import { disposeWebgl, attachWebgl } from './pane-lifecycle'
|
||||||
|
|
||||||
export { findLineByContent, captureScrollState, restoreScrollState } from './pane-scroll'
|
export { findLineByContent, captureScrollState, restoreScrollState } from './pane-scroll'
|
||||||
|
|
||||||
@@ -40,29 +41,6 @@ export function safeFit(pane: ManagedPaneInternal): void {
|
|||||||
// Why: divider drags fire refits every frame, but most frames do not
|
// Why: divider drags fire refits every frame, but most frames do not
|
||||||
// cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh()
|
// cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh()
|
||||||
// churn, which was causing visible terminal blinking while resizing.
|
// churn, which was causing visible terminal blinking while resizing.
|
||||||
//
|
|
||||||
// Why: wrapInSplit() reparents the pane's container, which can leave
|
|
||||||
// the WebGL canvas stale even when proposed dimensions match current
|
|
||||||
// (the browser detaches and reattaches the canvas during the DOM move).
|
|
||||||
// When pendingSplitScrollState is set we must force a fit + refresh so
|
|
||||||
// the WebGL renderer repaints. Without this, the pane appears blank
|
|
||||||
// until something forces a dimension change.
|
|
||||||
if (pane.pendingSplitScrollState) {
|
|
||||||
console.warn(
|
|
||||||
'[terminal] safeFit forcing fit+refresh during pending split for pane',
|
|
||||||
pane.id,
|
|
||||||
`— dims ${dims.cols}×${dims.rows} match current, webgl:`,
|
|
||||||
!!pane.webglAddon,
|
|
||||||
pane.debugLabel ? `(${pane.debugLabel})` : ''
|
|
||||||
)
|
|
||||||
pane.fitAddon.fit()
|
|
||||||
try {
|
|
||||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
|
||||||
} catch {
|
|
||||||
/* ignore — terminal may not be fully initialised */
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pane.fitAddon.fit()
|
pane.fitAddon.fit()
|
||||||
@@ -183,6 +161,13 @@ export function insertPaneNextTo(
|
|||||||
applyPaneFlexStyle(source.container)
|
applyPaneFlexStyle(source.container)
|
||||||
applyPaneFlexStyle(targetContainer)
|
applyPaneFlexStyle(targetContainer)
|
||||||
|
|
||||||
|
// Why: same pattern as splitPane — dispose WebGL before the DOM reparent
|
||||||
|
// to free GPU context slots, then reattach after layout settles.
|
||||||
|
const sourceHadWebgl = !!source.webglAddon
|
||||||
|
const targetHadWebgl = !!target.webglAddon
|
||||||
|
disposeWebgl(source)
|
||||||
|
disposeWebgl(target)
|
||||||
|
|
||||||
// Replace target with the split in the DOM
|
// Replace target with the split in the DOM
|
||||||
parent.replaceChild(split, targetContainer)
|
parent.replaceChild(split, targetContainer)
|
||||||
|
|
||||||
@@ -197,23 +182,15 @@ export function insertPaneNextTo(
|
|||||||
split.appendChild(source.container)
|
split.appendChild(source.container)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refit both and refresh rendering surfaces — both panes were reparented
|
|
||||||
// into the new split wrapper, which can leave the WebGL canvas in a stale
|
|
||||||
// state (same mechanism as wrapInSplit; see refreshAfterReparent in
|
|
||||||
// pane-split-scroll.ts).
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (sourceHadWebgl && source.gpuRenderingEnabled && !source.webglDisabledAfterContextLoss) {
|
||||||
|
attachWebgl(source)
|
||||||
|
}
|
||||||
|
if (targetHadWebgl && target.gpuRenderingEnabled && !target.webglDisabledAfterContextLoss) {
|
||||||
|
attachWebgl(target)
|
||||||
|
}
|
||||||
callbacks.safeFit(source)
|
callbacks.safeFit(source)
|
||||||
callbacks.safeFit(target)
|
callbacks.safeFit(target)
|
||||||
try {
|
|
||||||
source.terminal.refresh(0, source.terminal.rows - 1)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
target.terminal.refresh(0, target.terminal.rows - 1)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): Work
|
|||||||
return cachedSnapshot
|
return cachedSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
const allWorktrees = Object.values(worktreesByRepo).flat()
|
// Why: a race between createWorktree (which appends) and fetchWorktrees
|
||||||
|
// (which replaces) can produce duplicate entries for the same worktree ID
|
||||||
|
// within a single repo's array. Deduplicating here prevents React from
|
||||||
|
// seeing duplicate keys, which can corrupt terminal DOM containers.
|
||||||
const worktreeMap = new Map<string, Worktree>()
|
const worktreeMap = new Map<string, Worktree>()
|
||||||
for (const worktree of allWorktrees) {
|
for (const worktree of Object.values(worktreesByRepo).flat()) {
|
||||||
worktreeMap.set(worktree.id, worktree)
|
worktreeMap.set(worktree.id, worktree)
|
||||||
}
|
}
|
||||||
|
const allWorktrees = Array.from(worktreeMap.values())
|
||||||
|
|
||||||
const snapshot = { allWorktrees, worktreeMap }
|
const snapshot = { allWorktrees, worktreeMap }
|
||||||
worktreeSnapshotCache.set(worktreesByRepo, snapshot)
|
worktreeSnapshotCache.set(worktreesByRepo, snapshot)
|
||||||
|
|||||||
@@ -105,13 +105,22 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||||||
baseBranch,
|
baseBranch,
|
||||||
setupDecision
|
setupDecision
|
||||||
})
|
})
|
||||||
set((s) => ({
|
// Why: a file watcher (worktrees.onChanged) can fire between the
|
||||||
worktreesByRepo: {
|
// backend creating the worktree and this callback running, causing
|
||||||
...s.worktreesByRepo,
|
// fetchWorktrees to add the worktree first. Appending unconditionally
|
||||||
[repoId]: [...(s.worktreesByRepo[repoId] ?? []), result.worktree]
|
// then produces a duplicate entry in worktreesByRepo, which gives
|
||||||
},
|
// React duplicate keys and can corrupt terminal DOM containers.
|
||||||
sortEpoch: s.sortEpoch + 1
|
set((s) => {
|
||||||
}))
|
const current = s.worktreesByRepo[repoId] ?? []
|
||||||
|
const alreadyPresent = current.some((w) => w.id === result.worktree.id)
|
||||||
|
return {
|
||||||
|
worktreesByRepo: {
|
||||||
|
...s.worktreesByRepo,
|
||||||
|
[repoId]: alreadyPresent ? current : [...current, result.worktree]
|
||||||
|
},
|
||||||
|
sortEpoch: s.sortEpoch + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
|||||||
Reference in New Issue
Block a user