mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Fix OpenCode TUI scroll regression
Revert the May 23 terminal perf restore path and cap retained newline-free TUI redraw output while keeping completed-line terminal read pagination.
This commit is contained in:
@@ -11,8 +11,7 @@ import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
|
||||
|
||||
const REPO_ICON_IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml'
|
||||
'.png': 'image/png'
|
||||
}
|
||||
|
||||
async function pathExists(pathValue: string): Promise<boolean> {
|
||||
@@ -248,7 +247,7 @@ export function registerShellHandlers(): void {
|
||||
async (): Promise<{ dataUrl: string; fileName: string } | null> => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Repo icon images', extensions: ['png', 'svg'] }]
|
||||
filters: [{ name: 'Repo icon images', extensions: ['png'] }]
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
@@ -258,7 +257,7 @@ export function registerShellHandlers(): void {
|
||||
const extension = extname(filePath).toLowerCase()
|
||||
const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension]
|
||||
if (!mimeType) {
|
||||
throw new Error('Repo icons must be PNG or SVG files.')
|
||||
throw new Error('Repo icons must be PNG files.')
|
||||
}
|
||||
|
||||
const stats = await stat(filePath)
|
||||
|
||||
@@ -1383,8 +1383,8 @@ describe('Store', () => {
|
||||
const updated = store.updateRepo('r1', {
|
||||
repoIcon: {
|
||||
type: 'image',
|
||||
source: 'github',
|
||||
src: 'https://example.com/icon.png'
|
||||
source: 'upload',
|
||||
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
|
||||
} as never
|
||||
})
|
||||
|
||||
@@ -1399,8 +1399,8 @@ describe('Store', () => {
|
||||
makeRepo({
|
||||
repoIcon: {
|
||||
type: 'image',
|
||||
source: 'github',
|
||||
src: 'https://example.com/icon.png'
|
||||
source: 'upload',
|
||||
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
|
||||
} as never
|
||||
})
|
||||
)
|
||||
|
||||
@@ -3455,7 +3455,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(futureCursorRead.limited).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds preview reads by characters for long partial terminal output', async () => {
|
||||
it('bounds retained partial terminal output before preview reads', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
@@ -3481,16 +3481,43 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const longPartialLine = `${'x'.repeat(40_000)}tail-marker`
|
||||
runtime.onPtyData('pty-1', longPartialLine, 100)
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
`${Array.from({ length: 2000 }, (_, index) => `line-${index}`).join('\n')}\n`,
|
||||
99
|
||||
)
|
||||
runtime.onPtyData('pty-1', `${'x'.repeat(40_000)}tail-marker-0`, 100)
|
||||
type RetainedTailState = {
|
||||
tailBuffer: string[]
|
||||
tailPartialLine: string
|
||||
tailTruncated: boolean
|
||||
}
|
||||
const cappedPartialState = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, RetainedTailState>
|
||||
}
|
||||
).ptysById.get('pty-1')
|
||||
const retainedLineBuffer = cappedPartialState?.tailBuffer
|
||||
for (let index = 1; index < 5; index += 1) {
|
||||
runtime.onPtyData('pty-1', `${'x'.repeat(40_000)}tail-marker-${index}`, 100 + index)
|
||||
}
|
||||
|
||||
const retained = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, RetainedTailState>
|
||||
}
|
||||
).ptysById.get('pty-1')
|
||||
expect(retained?.tailBuffer).toBe(retainedLineBuffer)
|
||||
expect(retained?.tailPartialLine).toHaveLength(4000)
|
||||
expect(retained?.tailPartialLine.endsWith('tail-marker-4')).toBe(true)
|
||||
expect(retained?.tailTruncated).toBe(true)
|
||||
|
||||
const preview = await runtime.readTerminal(terminal.handle)
|
||||
expect(preview.tail).toHaveLength(1)
|
||||
expect(preview.tail[0]).toHaveLength(32 * 1024)
|
||||
expect(preview.tail[0].endsWith('tail-marker')).toBe(true)
|
||||
expect(preview.limited).toBe(true)
|
||||
expect(preview.tail).toHaveLength(120)
|
||||
expect(preview.tail.at(-1)).toHaveLength(4000)
|
||||
expect(preview.tail.at(-1)?.endsWith('tail-marker-4')).toBe(true)
|
||||
expect(preview.truncated).toBe(true)
|
||||
expect(preview.nextCursor).toBe('0')
|
||||
expect(preview.nextCursor).toBe('2000')
|
||||
})
|
||||
|
||||
it('delivers pending orchestration messages to an already-idle agent', async () => {
|
||||
|
||||
@@ -11822,6 +11822,7 @@ export class OrcaRuntimeService {
|
||||
|
||||
const MAX_TAIL_LINES = 2000
|
||||
const MAX_TAIL_CHARS = 256 * 1024
|
||||
const MAX_TAIL_PARTIAL_CHARS = 4000
|
||||
const DEFAULT_TERMINAL_READ_LIMIT = 120
|
||||
const MAX_TERMINAL_READ_LIMIT = 2000
|
||||
const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024
|
||||
@@ -11931,26 +11932,40 @@ function appendToTailBuffer(
|
||||
}
|
||||
}
|
||||
|
||||
const pieces = `${previousPartialLine}${normalizedChunk}`.split('\n')
|
||||
// Why: fullscreen TUIs often emit long, newline-free redraw streams. Keep the
|
||||
// larger line transcript for pagination, but keep partial-line work bounded.
|
||||
const previousPartialWasCapped = previousPartialLine.length > MAX_TAIL_PARTIAL_CHARS
|
||||
const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS)
|
||||
const pieces = `${boundedPreviousPartialLine}${normalizedChunk}`.split('\n')
|
||||
const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '')
|
||||
const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS)
|
||||
const newCompleteLines = pieces.length
|
||||
const nextLines = [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))]
|
||||
let truncated = false
|
||||
let nextLines =
|
||||
newCompleteLines > 0
|
||||
? [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))]
|
||||
: previousLines
|
||||
let truncated = previousPartialWasCapped || nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS
|
||||
|
||||
while (nextLines.length > MAX_TAIL_LINES) {
|
||||
nextLines.shift()
|
||||
truncated = true
|
||||
}
|
||||
|
||||
let totalChars = nextLines.reduce((sum, line) => sum + line.length, 0) + nextPartialLine.length
|
||||
while (nextLines.length > 0 && totalChars > MAX_TAIL_CHARS) {
|
||||
totalChars -= nextLines.shift()!.length
|
||||
truncated = true
|
||||
if (newCompleteLines > 0 || retainedPartialLine.length > previousPartialLine.length) {
|
||||
if (nextLines === previousLines) {
|
||||
nextLines = [...previousLines]
|
||||
}
|
||||
let totalChars =
|
||||
nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length
|
||||
while (nextLines.length > 0 && totalChars > MAX_TAIL_CHARS) {
|
||||
totalChars -= nextLines.shift()!.length
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lines: nextLines,
|
||||
partialLine: nextPartialLine.slice(-MAX_TAIL_CHARS),
|
||||
partialLine: retainedPartialLine,
|
||||
truncated,
|
||||
newCompleteLines
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ import {
|
||||
canGoBackWorktreeHistory,
|
||||
canGoForwardWorktreeHistory
|
||||
} from '@/store/slices/worktree-nav-history'
|
||||
import { useActiveTerminalTabs } from './store/selectors'
|
||||
import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor'
|
||||
import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types'
|
||||
import type { OnboardingState } from '../../shared/types'
|
||||
@@ -308,7 +307,7 @@ function App(): React.JSX.Element {
|
||||
// that remount so the left workspace list doesn't restart at scrollTop 0.
|
||||
const worktreeSidebarScrollOffsetRef = useRef(0)
|
||||
const worktreeSidebarScrollAnchorRef = useRef<VirtualizedScrollAnchor>(null)
|
||||
const tabs = useActiveTerminalTabs()
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const floatingUnifiedTabCount = useAppStore(
|
||||
(s) => s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.length ?? 0
|
||||
)
|
||||
@@ -964,6 +963,7 @@ function App(): React.JSX.Element {
|
||||
return () => document.removeEventListener('visibilitychange', handler)
|
||||
}, [actions])
|
||||
|
||||
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
|
||||
const hasTabBar = tabs.length >= 2
|
||||
const effectiveActiveTabId = activeTabId ?? tabs[0]?.id ?? null
|
||||
const activeTabCanExpand = effectiveActiveTabId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import React, { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
|
||||
import React, { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type BackgroundMountTerminalWorktreeDetail
|
||||
} from '@/constants/terminal'
|
||||
import { useAppStore } from '../store'
|
||||
import { useAllWorktrees } from '../store/selectors'
|
||||
import { createUntitledMarkdownFile } from '../lib/create-untitled-markdown'
|
||||
import { getConnectionId } from '../lib/connection-context'
|
||||
import { extractIpcErrorMessage } from '../lib/ipc-error'
|
||||
@@ -49,16 +50,8 @@ import {
|
||||
handleSwitchTerminalTab
|
||||
} from '../hooks/ipc-tab-switch'
|
||||
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
|
||||
import { getActiveWorktreeOpenFiles } from './terminal/active-worktree-open-files'
|
||||
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
|
||||
import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair'
|
||||
import {
|
||||
getTerminalBrowserPaneWorktreeIds,
|
||||
shouldRenderPreReadyBrowserPaneFallback
|
||||
} from './terminal/terminal-browser-pane-worktrees'
|
||||
import { getTerminalBrowserTabSlices } from './terminal/terminal-browser-tab-slices'
|
||||
import { getTerminalMountedWorktreeSnapshot } from './terminal/terminal-mounted-worktrees'
|
||||
import { getTerminalTabSlices } from './terminal/terminal-tab-slices'
|
||||
import { addBackgroundMountedTerminalWorktree } from './terminal/background-terminal-worktree-mount'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import {
|
||||
@@ -108,8 +101,10 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
|
||||
}
|
||||
|
||||
function Terminal(): React.JSX.Element | null {
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const activeTabId = useAppStore((s) => s.activeTabId)
|
||||
const createTab = useAppStore((s) => s.createTab)
|
||||
const closeTab = useAppStore((s) => s.closeTab)
|
||||
@@ -123,37 +118,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
|
||||
// Track which worktrees have been activated during this app session.
|
||||
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
|
||||
// spawning when restoring a session with many saved worktree tabs.
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
|
||||
const [, setBackgroundMountRevision] = useState(0)
|
||||
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
|
||||
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
|
||||
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
|
||||
// with ptyId: null, and TerminalPane would call connectPanePty -> pty:spawn,
|
||||
// creating a duplicate PTY for the same tab.
|
||||
if (activeWorktreeId && workspaceSessionReady) {
|
||||
mountedWorktreeIdsRef.current.add(activeWorktreeId)
|
||||
}
|
||||
const terminalWorktreeSnapshot = useAppStore((s) =>
|
||||
getTerminalMountedWorktreeSnapshot(s.worktreesByRepo, mountedWorktreeIdsRef.current)
|
||||
)
|
||||
const terminalTabSlices = useAppStore((s) =>
|
||||
getTerminalTabSlices(s.tabsByWorktree, mountedWorktreeIdsRef.current, activeWorktreeId)
|
||||
)
|
||||
const terminalBrowserTabSlices = useAppStore((s) =>
|
||||
getTerminalBrowserTabSlices(
|
||||
s.browserTabsByWorktree,
|
||||
mountedWorktreeIdsRef.current,
|
||||
activeWorktreeId
|
||||
)
|
||||
)
|
||||
const worktreeFiles = useAppStore((s) =>
|
||||
getActiveWorktreeOpenFiles(s.openFiles, activeWorktreeId)
|
||||
)
|
||||
const openFiles = useAppStore((s) => s.openFiles)
|
||||
const activeFileId = useAppStore((s) => s.activeFileId)
|
||||
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
|
||||
const activeTabType = useAppStore((s) => s.activeTabType)
|
||||
@@ -166,6 +131,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
const closeFile = useAppStore((s) => s.closeFile)
|
||||
const pinFile = useAppStore((s) => s.pinFile)
|
||||
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
const closeBrowserTab = useAppStore((s) => s.closeBrowserTab)
|
||||
const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab)
|
||||
@@ -189,7 +155,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeView === 'activity'
|
||||
)
|
||||
|
||||
const tabs = terminalTabSlices.activeTabs
|
||||
const tabs = useMemo(
|
||||
() => (activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []),
|
||||
[activeWorktreeId, tabsByWorktree]
|
||||
)
|
||||
|
||||
// Why: the TabBar is rendered into the titlebar via a portal so tabs share
|
||||
// the same row as the "Orca" title. The target element is created by App.tsx.
|
||||
@@ -200,9 +169,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
return
|
||||
}
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
@@ -210,9 +176,15 @@ function Terminal(): React.JSX.Element | null {
|
||||
// worktree always has a root group so terminal-first fallback can attach
|
||||
// fresh tabs to a concrete owner even before any explicit split exists.
|
||||
ensureWorktreeRootGroup(activeWorktreeId)
|
||||
}, [activeWorktreeId, ensureWorktreeRootGroup, workspaceSessionReady])
|
||||
}, [activeWorktreeId, ensureWorktreeRootGroup])
|
||||
|
||||
const worktreeBrowserTabs = terminalBrowserTabSlices.activeBrowserTabs
|
||||
// Filter editor files to only show those belonging to the active worktree
|
||||
const worktreeFiles = activeWorktreeId
|
||||
? openFiles.filter((f) => f.worktreeId === activeWorktreeId)
|
||||
: []
|
||||
const worktreeBrowserTabs = activeWorktreeId
|
||||
? (browserTabsByWorktree[activeWorktreeId] ?? [])
|
||||
: []
|
||||
const getEffectiveLayoutForWorktree = useCallback(
|
||||
(worktreeId: string) =>
|
||||
getEffectiveLayout(worktreeId, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree),
|
||||
@@ -221,20 +193,13 @@ function Terminal(): React.JSX.Element | null {
|
||||
const effectiveActiveLayout = activeWorktreeId
|
||||
? getEffectiveLayoutForWorktree(activeWorktreeId)
|
||||
: undefined
|
||||
const activeWorktreeBrowserTabIdsKey = worktreeBrowserTabs.map((tab) => tab.id).join(',')
|
||||
const browserPaneWorktreeIds = getTerminalBrowserPaneWorktreeIds({
|
||||
mountedWorktreeIds: terminalWorktreeSnapshot.mountedWorktrees.map((worktree) => worktree.id),
|
||||
worktreeIds: terminalWorktreeSnapshot.worktreeIds,
|
||||
activeWorktreeId,
|
||||
activeTabType,
|
||||
activeBrowserTabCount: worktreeBrowserTabs.length
|
||||
})
|
||||
const activeWorktreeBrowserTabIdsKey = activeWorktreeId
|
||||
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
|
||||
: ''
|
||||
|
||||
// Save confirmation dialog state
|
||||
const [saveDialogFileId, setSaveDialogFileId] = useState<string | null>(null)
|
||||
const saveDialogFile = useAppStore((s) =>
|
||||
saveDialogFileId ? (s.openFiles.find((file) => file.id === saveDialogFileId) ?? null) : null
|
||||
)
|
||||
const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null
|
||||
const pendingEditorCloseQueueRef = useRef<string[]>([])
|
||||
|
||||
// Why: while a save-and-close is awaiting the file to disappear from
|
||||
@@ -567,6 +532,13 @@ function Terminal(): React.JSX.Element | null {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId, activeTabType, setActiveTab, tabs])
|
||||
|
||||
// Track which worktrees have been activated during this app session.
|
||||
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
|
||||
// spawning when restoring a session with many saved worktree tabs.
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
|
||||
const [, setBackgroundMountRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
const timers = measurableBackgroundWorktreeTimersRef.current
|
||||
const onBackgroundMountTerminalWorktree = (event: Event): void => {
|
||||
@@ -609,35 +581,28 @@ function Terminal(): React.JSX.Element | null {
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
|
||||
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
|
||||
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
|
||||
// with ptyId: null, and TerminalPane would call connectPanePty → pty:spawn,
|
||||
// creating a duplicate PTY for the same tab.
|
||||
if (activeWorktreeId && workspaceSessionReady) {
|
||||
mountedWorktreeIdsRef.current.add(activeWorktreeId)
|
||||
}
|
||||
// Prune IDs of worktrees that no longer exist (deleted/removed)
|
||||
const allWorktreeIds = new Set(terminalWorktreeSnapshot.worktreeIds)
|
||||
const allWorktreeIds = new Set(allWorktrees.map((wt) => wt.id))
|
||||
for (const id of mountedWorktreeIdsRef.current) {
|
||||
if (!allWorktreeIds.has(id)) {
|
||||
mountedWorktreeIdsRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout(
|
||||
terminalWorktreeSnapshot.worktreeIds,
|
||||
allWorktrees.map((wt) => wt.id),
|
||||
mountedWorktreeIdsRef.current,
|
||||
layoutByWorktree,
|
||||
groupsByWorktree,
|
||||
activeGroupIdByWorktree
|
||||
)
|
||||
const activeWorktreeMounted =
|
||||
activeWorktreeId !== null && mountedWorktreeIdsRef.current.has(activeWorktreeId)
|
||||
const shouldRenderLegacyWorkspaceSurface = !effectiveActiveLayout && !anyMountedWorktreeHasLayout
|
||||
const shouldRenderPreReadyBrowserSurface = shouldRenderPreReadyBrowserPaneFallback({
|
||||
worktreeIds: terminalWorktreeSnapshot.worktreeIds,
|
||||
activeWorktreeId,
|
||||
activeTabType,
|
||||
activeBrowserTabCount: worktreeBrowserTabs.length,
|
||||
activeWorktreeMounted
|
||||
})
|
||||
const browserPaneWorktreeIdsForLegacySurface = shouldRenderLegacyWorkspaceSurface
|
||||
? browserPaneWorktreeIds
|
||||
: shouldRenderPreReadyBrowserSurface && activeWorktreeId
|
||||
? [activeWorktreeId]
|
||||
: []
|
||||
// Auto-create first tab when worktree activates
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
@@ -1512,33 +1477,35 @@ function Terminal(): React.JSX.Element | null {
|
||||
can preserve hidden trees without reflowing the active one. Keep
|
||||
a relative anchor here so those panes size to the workspace body
|
||||
rather than some outer ancestor when split groups are enabled. */}
|
||||
{terminalWorktreeSnapshot.mountedWorktrees.map((worktree) => {
|
||||
const layout = getEffectiveLayoutForWorktree(worktree.id)
|
||||
if (!layout) {
|
||||
return null
|
||||
}
|
||||
// Why: use strict equality with 'terminal' instead of !== 'settings'
|
||||
// so the terminal/browser surface hides on the tasks page too.
|
||||
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
|
||||
return (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${worktree.id}`}
|
||||
worktreeId={worktree.id}
|
||||
worktreePath={worktree.path}
|
||||
layout={layout}
|
||||
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
|
||||
isVisible={isVisible}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{allWorktrees
|
||||
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
|
||||
.map((worktree) => {
|
||||
const layout = getEffectiveLayoutForWorktree(worktree.id)
|
||||
if (!layout) {
|
||||
return null
|
||||
}
|
||||
// Why: use strict equality with 'terminal' instead of !== 'settings'
|
||||
// so the terminal/browser surface hides on the tasks page too.
|
||||
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
|
||||
return (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${worktree.id}`}
|
||||
worktreeId={worktree.id}
|
||||
worktreePath={worktree.path}
|
||||
layout={layout}
|
||||
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
|
||||
isVisible={isVisible}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(shouldRenderLegacyWorkspaceSurface || shouldRenderPreReadyBrowserSurface) && (
|
||||
{!effectiveActiveLayout && !anyMountedWorktreeHasLayout && (
|
||||
<>
|
||||
{/* Why: split-group layouts render their own terminal/browser/editor
|
||||
surfaces through TabGroupPanel plus stable overlay layers.
|
||||
@@ -1558,21 +1525,23 @@ function Terminal(): React.JSX.Element | null {
|
||||
startFreshSpawn → new PTY. That respawn is exactly what flips
|
||||
getWorktreeStatus back to 'active' and re-lights the sidebar
|
||||
dot green moments after the user clicked Shutdown. */}
|
||||
{shouldRenderLegacyWorkspaceSurface ? (
|
||||
<div
|
||||
className={`relative flex-1 min-h-0 overflow-hidden ${
|
||||
// Why: only hide the terminal container when another tab type has
|
||||
// content to display. Hiding unconditionally for non-terminal types
|
||||
// causes a blank screen when activeTabType is stale (e.g. 'editor'
|
||||
// with no files after session restore). The terminal stays visible
|
||||
// as a fallback until another surface is ready.
|
||||
(activeTabType === 'editor' && worktreeFiles.length > 0) ||
|
||||
(activeTabType === 'browser' && worktreeBrowserTabs.length > 0)
|
||||
? 'hidden'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{terminalWorktreeSnapshot.mountedWorktrees.map((worktree) => {
|
||||
{/* Terminal panes container - hidden when editor tab active */}
|
||||
<div
|
||||
className={`relative flex-1 min-h-0 overflow-hidden ${
|
||||
// Why: only hide the terminal container when another tab type has
|
||||
// content to display. Hiding unconditionally for non-terminal types
|
||||
// causes a blank screen when activeTabType is stale (e.g. 'editor'
|
||||
// with no files after session restore). The terminal stays visible
|
||||
// as a fallback until another surface is ready.
|
||||
(activeTabType === 'editor' && worktreeFiles.length > 0) ||
|
||||
(activeTabType === 'browser' && worktreeBrowserTabs.length > 0)
|
||||
? 'hidden'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{allWorktrees
|
||||
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
|
||||
.map((worktree) => {
|
||||
// Why: use strict equality with 'terminal' instead of !== 'settings'
|
||||
// so the terminal/browser surface hides on the tasks page too.
|
||||
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
|
||||
@@ -1591,7 +1560,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<CodexRestartChip worktreeId={worktree.id} />
|
||||
{(terminalTabSlices.mountedTabsByWorktree[worktree.id] ?? []).map((tab) => {
|
||||
{(tabsByWorktree[worktree.id] ?? []).map((tab) => {
|
||||
const activityTerminalPortal = findActivityTerminalPortal(
|
||||
activityTerminalPortals,
|
||||
{ worktreeId: worktree.id, tabId: tab.id }
|
||||
@@ -1613,7 +1582,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
isVisible={isActiveTerminalTab || isActivityPortalTab}
|
||||
// Why: when portaled to Activity for a specific agent
|
||||
// pane, isolate that leaf so split siblings stay
|
||||
// hidden. Workspace renders pass null -> no override.
|
||||
// hidden. Workspace renders pass null → no override.
|
||||
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
|
||||
onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)}
|
||||
onCloseTab={() => handleCloseTab(tab.id)}
|
||||
@@ -1631,8 +1600,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Browser panes container — all browser panes for the active worktree
|
||||
stay mounted so webview DOM state (scroll position, form inputs, etc.)
|
||||
@@ -1642,20 +1610,18 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeTabType !== 'browser' ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
{browserPaneWorktreeIdsForLegacySurface.map((worktreeId) => {
|
||||
const browserTabs =
|
||||
worktreeId === activeWorktreeId
|
||||
? worktreeBrowserTabs
|
||||
: (terminalBrowserTabSlices.mountedBrowserTabsByWorktree[worktreeId] ?? [])
|
||||
{allWorktrees.map((worktree) => {
|
||||
const browserTabs = browserTabsByWorktree[worktree.id] ?? []
|
||||
// Why: use strict equality with 'terminal' instead of !== 'settings'
|
||||
// so browser panes also hide on the tasks page.
|
||||
const isVisibleWorktree = activeView === 'terminal' && worktreeId === activeWorktreeId
|
||||
const isVisibleWorktree =
|
||||
activeView === 'terminal' && worktree.id === activeWorktreeId
|
||||
if (browserTabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`browser-${worktreeId}`}
|
||||
key={`browser-${worktree.id}`}
|
||||
className={isVisibleWorktree ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isVisibleWorktree}
|
||||
>
|
||||
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
getMaximizedFloatingTerminalBounds,
|
||||
type FloatingTerminalPanelBounds
|
||||
} from './floating-terminal-panel-bounds'
|
||||
import { getFloatingTerminalOpenFiles } from './floating-terminal-open-files'
|
||||
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
|
||||
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
|
||||
const EMPTY_GROUPS: TabGroup[] = []
|
||||
@@ -90,19 +89,11 @@ export function FloatingTerminalPanel({
|
||||
open,
|
||||
onOpenChange
|
||||
}: FloatingTerminalPanelProps): React.JSX.Element | null {
|
||||
const tabs = useAppStore(
|
||||
(s) => s.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS
|
||||
)
|
||||
const browserTabs = useAppStore(
|
||||
(s) => s.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_BROWSER_TABS
|
||||
)
|
||||
const groups = useAppStore(
|
||||
(s) => s.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_GROUPS
|
||||
)
|
||||
const unifiedTabs = useAppStore(
|
||||
(s) => s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_UNIFIED_TABS
|
||||
)
|
||||
const floatingFiles = useAppStore((s) => getFloatingTerminalOpenFiles(s.openFiles))
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
|
||||
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
|
||||
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
|
||||
const openFiles = useAppStore((s) => s.openFiles)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
const createTab = useAppStore((s) => s.createTab)
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
@@ -141,6 +132,14 @@ export function FloatingTerminalPanel({
|
||||
top: number
|
||||
} | null>(null)
|
||||
|
||||
const tabs = tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS
|
||||
const browserTabs = browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_BROWSER_TABS
|
||||
const groups = groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_GROUPS
|
||||
const unifiedTabs = unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_UNIFIED_TABS
|
||||
const floatingFiles = useMemo(
|
||||
() => openFiles.filter((file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID),
|
||||
[openFiles]
|
||||
)
|
||||
const activeGroup = useMemo(
|
||||
() =>
|
||||
groups.find((group) => group.activeTabId != null) ??
|
||||
@@ -243,7 +242,7 @@ export function FloatingTerminalPanel({
|
||||
handleSaveDialogSave,
|
||||
handleSaveDialogDiscard,
|
||||
handleSaveDialogCancel
|
||||
} = useTerminalSaveDialog({ openFiles: floatingFiles, closeFile, markFileDirty })
|
||||
} = useTerminalSaveDialog({ openFiles, closeFile, markFileDirty })
|
||||
|
||||
const getNextQueuedEditorClose = useCallback((): string | null => {
|
||||
while (pendingEditorCloseQueueRef.current.length > 0) {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { getFloatingTerminalOpenFiles } from './floating-terminal-open-files'
|
||||
|
||||
const file = (id: string, worktreeId: string): OpenFile =>
|
||||
({
|
||||
id,
|
||||
filePath: `/tmp/${id}.md`,
|
||||
relativePath: `${id}.md`,
|
||||
worktreeId,
|
||||
language: 'markdown',
|
||||
content: '',
|
||||
isDirty: false,
|
||||
isPinned: false,
|
||||
mode: 'edit',
|
||||
mtime: 0,
|
||||
runtimeEnvironmentId: null
|
||||
}) as OpenFile
|
||||
|
||||
describe('getFloatingTerminalOpenFiles', () => {
|
||||
it('preserves the filtered array when unrelated worktree files change', () => {
|
||||
const floating = file('floating', FLOATING_TERMINAL_WORKTREE_ID)
|
||||
const first = getFloatingTerminalOpenFiles([floating, file('main-a', 'wt-1')])
|
||||
const second = getFloatingTerminalOpenFiles([floating, file('main-b', 'wt-2')])
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second).toEqual([floating])
|
||||
})
|
||||
|
||||
it('updates the filtered array when a floating file changes', () => {
|
||||
const first = getFloatingTerminalOpenFiles([file('floating-a', FLOATING_TERMINAL_WORKTREE_ID)])
|
||||
const second = getFloatingTerminalOpenFiles([file('floating-b', FLOATING_TERMINAL_WORKTREE_ID)])
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.map((item) => item.id)).toEqual(['floating-b'])
|
||||
})
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
|
||||
let cachedOpenFiles: OpenFile[] | null = null
|
||||
let cachedFloatingFiles: OpenFile[] = []
|
||||
|
||||
export function getFloatingTerminalOpenFiles(openFiles: OpenFile[]): OpenFile[] {
|
||||
if (openFiles === cachedOpenFiles) {
|
||||
return cachedFloatingFiles
|
||||
}
|
||||
|
||||
const nextFloatingFiles = openFiles.filter(
|
||||
(file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID
|
||||
)
|
||||
if (
|
||||
cachedOpenFiles !== null &&
|
||||
nextFloatingFiles.length === cachedFloatingFiles.length &&
|
||||
nextFloatingFiles.every((file, index) => file === cachedFloatingFiles[index])
|
||||
) {
|
||||
cachedOpenFiles = openFiles
|
||||
return cachedFloatingFiles
|
||||
}
|
||||
|
||||
cachedOpenFiles = openFiles
|
||||
cachedFloatingFiles = nextFloatingFiles
|
||||
return cachedFloatingFiles
|
||||
}
|
||||
@@ -230,7 +230,7 @@ export function RepositoryIconPicker({
|
||||
onClick={handleUploadImage}
|
||||
>
|
||||
<Image className="size-3.5" />
|
||||
Upload PNG/SVG
|
||||
Upload PNG
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -262,7 +262,7 @@ export function RepositoryIconPicker({
|
||||
Favicon
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">PNG/SVG uploads must be 256KB or smaller.</p>
|
||||
<p className="text-xs text-muted-foreground">PNG uploads must be 256KB or smaller.</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -80,17 +80,21 @@ vi.mock('../ui/toggle-group', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./SettingsFormControls', () => ({
|
||||
SettingsRow: function SettingsRow({ children }: { children?: unknown }) {
|
||||
return children
|
||||
},
|
||||
NumberField: function NumberField() {
|
||||
return null
|
||||
},
|
||||
FontAutocomplete: function FontAutocomplete() {
|
||||
return null
|
||||
},
|
||||
SettingsRow: function SettingsRow() {
|
||||
return null
|
||||
},
|
||||
SettingsSegmentedControl: function SettingsSegmentedControl() {
|
||||
return null
|
||||
SettingsSegmentedControl: function SettingsSegmentedControl({
|
||||
options
|
||||
}: {
|
||||
options?: readonly { label: string }[]
|
||||
}) {
|
||||
return options?.map((option) => option.label) ?? null
|
||||
},
|
||||
SettingsSubsectionHeader: function SettingsSubsectionHeader() {
|
||||
return null
|
||||
|
||||
@@ -80,17 +80,29 @@ vi.mock('../ui/toggle-group', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./SettingsFormControls', () => ({
|
||||
SettingsRow: function SettingsRow({
|
||||
description,
|
||||
control,
|
||||
children
|
||||
}: {
|
||||
description?: unknown
|
||||
control?: unknown
|
||||
children?: unknown
|
||||
}) {
|
||||
return [description, control, children]
|
||||
},
|
||||
NumberField: function NumberField() {
|
||||
return null
|
||||
},
|
||||
FontAutocomplete: function FontAutocomplete() {
|
||||
return null
|
||||
},
|
||||
SettingsRow: function SettingsRow() {
|
||||
return null
|
||||
},
|
||||
SettingsSegmentedControl: function SettingsSegmentedControl() {
|
||||
return null
|
||||
SettingsSegmentedControl: function SettingsSegmentedControl({
|
||||
options
|
||||
}: {
|
||||
options?: readonly { label: string }[]
|
||||
}) {
|
||||
return options?.map((option) => option.label) ?? null
|
||||
},
|
||||
SettingsSubsectionHeader: function SettingsSubsectionHeader() {
|
||||
return null
|
||||
@@ -152,24 +164,13 @@ type ReactElementLike = {
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
function getPropNodes(props: Record<string, unknown> | undefined): unknown[] {
|
||||
if (!props) {
|
||||
return []
|
||||
function getPropNodes(el: ReactElementLike): unknown[] {
|
||||
const nodes = [el.props?.children, el.props?.description, el.props?.control]
|
||||
const options = el.props?.options
|
||||
if (Array.isArray(options)) {
|
||||
nodes.push(options.map((option) => (option as { label?: unknown }).label))
|
||||
}
|
||||
const optionLabels = Array.isArray(props.options)
|
||||
? props.options.map((option) =>
|
||||
option && typeof option === 'object' ? (option as { label?: unknown }).label : undefined
|
||||
)
|
||||
: []
|
||||
return [
|
||||
props.children,
|
||||
props.title,
|
||||
props.label,
|
||||
props.description,
|
||||
props.control,
|
||||
props.action,
|
||||
...optionLabels
|
||||
]
|
||||
return nodes
|
||||
}
|
||||
|
||||
function collectText(node: unknown): string {
|
||||
@@ -186,7 +187,7 @@ function collectText(node: unknown): string {
|
||||
return node.map(collectText).join('')
|
||||
}
|
||||
const el = node as ReactElementLike
|
||||
return getPropNodes(el.props).map(collectText).join('')
|
||||
return getPropNodes(el).map(collectText).join('')
|
||||
}
|
||||
|
||||
function findAnchorByText(node: unknown, text: string): ReactElementLike | null {
|
||||
@@ -210,7 +211,7 @@ function findAnchorByText(node: unknown, text: string): ReactElementLike | null
|
||||
if (typeName === 'a' && collectText(el.props.children).includes(text)) {
|
||||
return el
|
||||
}
|
||||
for (const child of getPropNodes(el.props)) {
|
||||
for (const child of getPropNodes(el)) {
|
||||
const found = findAnchorByText(child, text)
|
||||
if (found) {
|
||||
return found
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
type AgentInterruptInputIntent
|
||||
} from '../../../../shared/agent-interrupt-intent'
|
||||
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
|
||||
import { createTerminalWheelInputBatcher } from './terminal-wheel-input-batcher'
|
||||
|
||||
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
|
||||
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
|
||||
@@ -851,26 +850,6 @@ export function connectPanePty(
|
||||
: createIpcPtyTransport(transportOptions)
|
||||
const hasExistingPaneTransport = deps.paneTransportsRef.current.size > 0
|
||||
deps.paneTransportsRef.current.set(pane.id, transport)
|
||||
const canSendTerminalInput = (): boolean => {
|
||||
if (disposed || isPaneReplaying(deps.replayingPanesRef, pane.id)) {
|
||||
return false
|
||||
}
|
||||
const currentPtyId = transport.getPtyId()
|
||||
return (
|
||||
!isCodexPaneStale({
|
||||
tabId: deps.tabId,
|
||||
worktreeId: deps.worktreeId,
|
||||
panePtyId: currentPtyId
|
||||
}) && !(currentPtyId && isPtyLocked(currentPtyId))
|
||||
)
|
||||
}
|
||||
const wheelInputBatcher = createTerminalWheelInputBatcher({
|
||||
terminal: pane.terminal,
|
||||
sendInput: (data) => transport.sendInput(data),
|
||||
// Why: wheel batches flush after a timer; replay/stale/mobile-lock state can
|
||||
// change after the original onData guard accepted the first wheel report.
|
||||
canSend: canSendTerminalInput
|
||||
})
|
||||
|
||||
const onDataDisposable = pane.terminal.onData((data) => {
|
||||
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
|
||||
@@ -881,7 +860,6 @@ export function connectPanePty(
|
||||
// engage the guard via replayIntoTerminal; here we drop everything
|
||||
// xterm emits while the guard is active. See replay-guard.ts.
|
||||
if (isPaneReplaying(deps.replayingPanesRef, pane.id)) {
|
||||
wheelInputBatcher.discard()
|
||||
return
|
||||
}
|
||||
const currentPtyId = transport.getPtyId()
|
||||
@@ -898,7 +876,6 @@ export function connectPanePty(
|
||||
panePtyId: currentPtyId
|
||||
})
|
||||
) {
|
||||
wheelInputBatcher.discard()
|
||||
clearPendingTerminalInputIntent()
|
||||
return
|
||||
}
|
||||
@@ -910,7 +887,6 @@ export function connectPanePty(
|
||||
// The pty:write IPC has a defense-in-depth twin. See
|
||||
// docs/mobile-presence-lock.md.
|
||||
if (currentPtyId && isPtyLocked(currentPtyId)) {
|
||||
wheelInputBatcher.discard()
|
||||
clearPendingTerminalInputIntent()
|
||||
return
|
||||
}
|
||||
@@ -926,11 +902,6 @@ export function connectPanePty(
|
||||
// normal cursor visibility when commands intentionally produce no echo.
|
||||
suppressTerminalCursorUntilOutputSettles(pane.terminal)
|
||||
}
|
||||
if (wheelInputBatcher.enqueueIfWheelInput(data)) {
|
||||
clearPendingTerminalInputIntent()
|
||||
return
|
||||
}
|
||||
wheelInputBatcher.flush()
|
||||
const intent = pendingTerminalInputIntent
|
||||
// Why: real xterm can deliver the terminal byte even when our DOM keydown
|
||||
// listener missed the press. Exact Ctrl+C/Escape bytes are still safe to
|
||||
@@ -1816,7 +1787,6 @@ export function connectPanePty(
|
||||
if (terminalKeyTargetSupportsEvents) {
|
||||
terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true })
|
||||
}
|
||||
wheelInputBatcher.discard()
|
||||
clearPendingTerminalInputIntent()
|
||||
pendingTerminalInputWrite = null
|
||||
interruptInference.dispose()
|
||||
|
||||
@@ -515,13 +515,13 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after\x1b]0;. Claude working\x07\x07'
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
await vi.waitFor(() =>
|
||||
expect(onAgentStatus).toHaveBeenCalledWith({
|
||||
state: 'working',
|
||||
prompt: 'ship it',
|
||||
agentType: 'codex'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(onData).toHaveBeenCalledWith('beforeafter\x1b]0;. Claude working\x07\x07')
|
||||
expect(onTitleChange).toHaveBeenCalledWith('. Claude working', '. Claude working')
|
||||
expect(onBell).toHaveBeenCalledTimes(1)
|
||||
@@ -548,13 +548,13 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after'
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
await vi.waitFor(() =>
|
||||
expect(onAgentStatus).toHaveBeenCalledWith({
|
||||
state: 'working',
|
||||
prompt: 'ship it',
|
||||
agentType: 'codex'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(onData).toHaveBeenCalledWith('beforeafter')
|
||||
})
|
||||
|
||||
@@ -856,9 +856,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
)
|
||||
|
||||
expect(onReplayData).toHaveBeenCalledWith('beforeafter\x1b]0;Remote title\x07\x07')
|
||||
await vi.waitFor(() => {
|
||||
await vi.waitFor(() =>
|
||||
expect(onTitleChange).toHaveBeenCalledWith('Remote title', 'Remote title')
|
||||
})
|
||||
)
|
||||
expect(onAgentStatus).not.toHaveBeenCalled()
|
||||
expect(onBell).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createTerminalWheelInputBatcher,
|
||||
isTerminalWheelInput
|
||||
} from './terminal-wheel-input-batcher'
|
||||
import { holdForegroundTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
holdForegroundTerminalOutput: vi.fn()
|
||||
}))
|
||||
|
||||
describe('isTerminalWheelInput', () => {
|
||||
it('recognizes SGR wheel reports', () => {
|
||||
expect(isTerminalWheelInput('\x1b[<64;10;20M')).toBe(true)
|
||||
expect(isTerminalWheelInput('\x1b[<65;10;20M\x1b[<64;10;20M')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes default-encoded wheel reports', () => {
|
||||
const report = `\x1b[M${String.fromCharCode(64 + 32)}!!`
|
||||
|
||||
expect(isTerminalWheelInput(report)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects mouse moves, releases, mixed input, and malformed reports', () => {
|
||||
expect(isTerminalWheelInput('\x1b[<35;10;20M')).toBe(false)
|
||||
expect(isTerminalWheelInput('\x1b[<64;10;20m')).toBe(false)
|
||||
expect(isTerminalWheelInput('\x1b[<64;10;20Mabc')).toBe(false)
|
||||
expect(isTerminalWheelInput('a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTerminalWheelInputBatcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(holdForegroundTerminalOutput).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('batches wheel input until the burst goes idle', () => {
|
||||
const terminal = {}
|
||||
const sendInput = vi.fn(() => true)
|
||||
const batcher = createTerminalWheelInputBatcher({
|
||||
terminal: terminal as never,
|
||||
sendInput
|
||||
})
|
||||
|
||||
expect(batcher.enqueueIfWheelInput('\x1b[<64;10;20M')).toBe(true)
|
||||
expect(batcher.enqueueIfWheelInput('\x1b[<65;10;20M')).toBe(true)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(139)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[<64;10;20M\x1b[<65;10;20M')
|
||||
expect(holdForegroundTerminalOutput).toHaveBeenCalledWith(terminal, {
|
||||
idleMs: 96,
|
||||
maxMs: 2000
|
||||
})
|
||||
})
|
||||
|
||||
it('flushes at the maximum batch time during continuous wheel input', () => {
|
||||
const sendInput = vi.fn(() => true)
|
||||
const batcher = createTerminalWheelInputBatcher({
|
||||
terminal: {} as never,
|
||||
sendInput
|
||||
})
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
expect(batcher.enqueueIfWheelInput('\x1b[<64;10;20M')).toBe(true)
|
||||
if (i < 10) {
|
||||
vi.advanceTimersByTime(139)
|
||||
}
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(109)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[<64;10;20M'.repeat(11))
|
||||
})
|
||||
|
||||
it('does not claim non-wheel input', () => {
|
||||
const sendInput = vi.fn(() => true)
|
||||
const batcher = createTerminalWheelInputBatcher({
|
||||
terminal: {} as never,
|
||||
sendInput
|
||||
})
|
||||
|
||||
expect(batcher.enqueueIfWheelInput('\x03')).toBe(false)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops delayed wheel input when the live send guard fails before flush', () => {
|
||||
let canSend = true
|
||||
const sendInput = vi.fn(() => true)
|
||||
const batcher = createTerminalWheelInputBatcher({
|
||||
terminal: {} as never,
|
||||
sendInput,
|
||||
canSend: () => canSend
|
||||
})
|
||||
|
||||
expect(batcher.enqueueIfWheelInput('\x1b[<64;10;20M')).toBe(true)
|
||||
canSend = false
|
||||
vi.advanceTimersByTime(140)
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
expect(holdForegroundTerminalOutput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('flushes and discards pending input on demand', () => {
|
||||
const sendInput = vi.fn(() => true)
|
||||
const batcher = createTerminalWheelInputBatcher({
|
||||
terminal: {} as never,
|
||||
sendInput
|
||||
})
|
||||
|
||||
batcher.enqueueIfWheelInput('\x1b[<64;10;20M')
|
||||
batcher.flush()
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[<64;10;20M')
|
||||
|
||||
batcher.enqueueIfWheelInput('\x1b[<65;10;20M')
|
||||
batcher.discard()
|
||||
vi.advanceTimersByTime(1500)
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { holdForegroundTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
|
||||
// Why: trusted wheel events arrive far enough apart that a 32-80ms debounce
|
||||
// still interleaves TUI redraws; 140ms keeps a gesture together while keeping a
|
||||
// lone wheel notch below normal human reaction latency.
|
||||
const WHEEL_INPUT_BATCH_IDLE_MS = 140
|
||||
const WHEEL_INPUT_BATCH_MAX_MS = 1500
|
||||
const WHEEL_OUTPUT_HOLD_IDLE_MS = 96
|
||||
const WHEEL_OUTPUT_HOLD_MAX_MS = 2000
|
||||
const SGR_MOUSE_PREFIX = '\x1b[<'
|
||||
const DEFAULT_MOUSE_PREFIX = '\x1b[M'
|
||||
|
||||
type TerminalWheelInputBatcherOptions = {
|
||||
terminal: Terminal
|
||||
sendInput: (data: string) => boolean
|
||||
canSend?: () => boolean
|
||||
}
|
||||
|
||||
export type TerminalWheelInputBatcher = {
|
||||
enqueueIfWheelInput: (data: string) => boolean
|
||||
flush: () => void
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
function isWheelButtonCode(code: number): boolean {
|
||||
return (code & 64) === 64
|
||||
}
|
||||
|
||||
function consumeSgrMouseReport(
|
||||
data: string,
|
||||
index: number
|
||||
): { nextIndex: number; code: number } | null {
|
||||
if (!data.startsWith(SGR_MOUSE_PREFIX, index)) {
|
||||
return null
|
||||
}
|
||||
const finalIndex = data.indexOf('M', index + SGR_MOUSE_PREFIX.length)
|
||||
if (finalIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const report = data.slice(index + SGR_MOUSE_PREFIX.length, finalIndex)
|
||||
const [codeText, colText, rowText] = report.split(';')
|
||||
if (!codeText || !colText || !rowText) {
|
||||
return null
|
||||
}
|
||||
const code = Number(codeText)
|
||||
const col = Number(colText)
|
||||
const row = Number(rowText)
|
||||
if (!Number.isInteger(code) || !Number.isInteger(col) || !Number.isInteger(row)) {
|
||||
return null
|
||||
}
|
||||
return { nextIndex: finalIndex + 1, code }
|
||||
}
|
||||
|
||||
function consumeDefaultMouseReport(
|
||||
data: string,
|
||||
index: number
|
||||
): { nextIndex: number; code: number } | null {
|
||||
if (!data.startsWith(DEFAULT_MOUSE_PREFIX, index) || index + 6 > data.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
nextIndex: index + 6,
|
||||
code: data.charCodeAt(index + 3) - 32
|
||||
}
|
||||
}
|
||||
|
||||
export function isTerminalWheelInput(data: string): boolean {
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
|
||||
let index = 0
|
||||
while (index < data.length) {
|
||||
const report = consumeSgrMouseReport(data, index) ?? consumeDefaultMouseReport(data, index)
|
||||
if (!report || !isWheelButtonCode(report.code)) {
|
||||
return false
|
||||
}
|
||||
index = report.nextIndex
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function createTerminalWheelInputBatcher({
|
||||
terminal,
|
||||
sendInput,
|
||||
canSend = () => true
|
||||
}: TerminalWheelInputBatcherOptions): TerminalWheelInputBatcher {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let maxTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const pendingInput: string[] = []
|
||||
|
||||
const clearTimers = (): void => {
|
||||
if (idleTimer !== null) {
|
||||
clearTimeout(idleTimer)
|
||||
idleTimer = null
|
||||
}
|
||||
if (maxTimer !== null) {
|
||||
clearTimeout(maxTimer)
|
||||
maxTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const holdOutput = (): void => {
|
||||
holdForegroundTerminalOutput(terminal, {
|
||||
idleMs: WHEEL_OUTPUT_HOLD_IDLE_MS,
|
||||
maxMs: WHEEL_OUTPUT_HOLD_MAX_MS
|
||||
})
|
||||
}
|
||||
|
||||
const flush = (): void => {
|
||||
clearTimers()
|
||||
if (pendingInput.length === 0) {
|
||||
return
|
||||
}
|
||||
const data = pendingInput.join('')
|
||||
pendingInput.length = 0
|
||||
if (!canSend()) {
|
||||
return
|
||||
}
|
||||
// Why: flushing a wheel batch usually causes a full-screen TUI repaint.
|
||||
// Keep the output side held briefly so xterm doesn't parse that repaint
|
||||
// in the middle of the next native wheel event.
|
||||
holdOutput()
|
||||
sendInput(data)
|
||||
}
|
||||
|
||||
const scheduleFlush = (): void => {
|
||||
if (idleTimer !== null) {
|
||||
clearTimeout(idleTimer)
|
||||
}
|
||||
idleTimer = setTimeout(flush, WHEEL_INPUT_BATCH_IDLE_MS)
|
||||
if (maxTimer === null) {
|
||||
maxTimer = setTimeout(flush, WHEEL_INPUT_BATCH_MAX_MS)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueIfWheelInput: (data: string): boolean => {
|
||||
if (!isTerminalWheelInput(data)) {
|
||||
return false
|
||||
}
|
||||
pendingInput.push(data)
|
||||
holdOutput()
|
||||
scheduleFlush()
|
||||
return true
|
||||
},
|
||||
flush,
|
||||
discard: () => {
|
||||
clearTimers()
|
||||
pendingInput.length = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { getActiveWorktreeOpenFiles } from './active-worktree-open-files'
|
||||
|
||||
const file = (id: string, worktreeId: string): OpenFile =>
|
||||
({
|
||||
id,
|
||||
filePath: `/tmp/${id}.md`,
|
||||
relativePath: `${id}.md`,
|
||||
worktreeId,
|
||||
language: 'markdown',
|
||||
isDirty: false,
|
||||
runtimeEnvironmentId: null
|
||||
}) as OpenFile
|
||||
|
||||
describe('getActiveWorktreeOpenFiles', () => {
|
||||
it('preserves the active slice when unrelated worktree files change', () => {
|
||||
const active = file('active', 'wt-active')
|
||||
const first = getActiveWorktreeOpenFiles([active, file('other-a', 'wt-other')], 'wt-active')
|
||||
const second = getActiveWorktreeOpenFiles([active, file('other-b', 'wt-other')], 'wt-active')
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second).toEqual([active])
|
||||
})
|
||||
|
||||
it('updates the active slice when an active file changes', () => {
|
||||
const first = getActiveWorktreeOpenFiles([file('active-a', 'wt-active')], 'wt-active')
|
||||
const second = getActiveWorktreeOpenFiles([file('active-b', 'wt-active')], 'wt-active')
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.map((item) => item.id)).toEqual(['active-b'])
|
||||
})
|
||||
|
||||
it('returns a stable empty slice without an active worktree', () => {
|
||||
const first = getActiveWorktreeOpenFiles([file('active', 'wt-active')], null)
|
||||
const second = getActiveWorktreeOpenFiles([file('other', 'wt-other')], null)
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
const EMPTY_OPEN_FILES: OpenFile[] = []
|
||||
|
||||
let cachedOpenFiles: OpenFile[] | null = null
|
||||
let cachedWorktreeId: string | null = null
|
||||
let cachedFiles: OpenFile[] = EMPTY_OPEN_FILES
|
||||
|
||||
export function getActiveWorktreeOpenFiles(
|
||||
openFiles: OpenFile[],
|
||||
activeWorktreeId: string | null
|
||||
): OpenFile[] {
|
||||
if (!activeWorktreeId) {
|
||||
return EMPTY_OPEN_FILES
|
||||
}
|
||||
if (openFiles === cachedOpenFiles && activeWorktreeId === cachedWorktreeId) {
|
||||
return cachedFiles
|
||||
}
|
||||
|
||||
const nextFiles = openFiles.filter((file) => file.worktreeId === activeWorktreeId)
|
||||
if (
|
||||
cachedOpenFiles !== null &&
|
||||
activeWorktreeId === cachedWorktreeId &&
|
||||
nextFiles.length === cachedFiles.length &&
|
||||
nextFiles.every((file, index) => file === cachedFiles[index])
|
||||
) {
|
||||
cachedOpenFiles = openFiles
|
||||
return cachedFiles
|
||||
}
|
||||
|
||||
cachedOpenFiles = openFiles
|
||||
cachedWorktreeId = activeWorktreeId
|
||||
cachedFiles = nextFiles.length > 0 ? nextFiles : EMPTY_OPEN_FILES
|
||||
return cachedFiles
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getTerminalBrowserPaneWorktreeIds,
|
||||
shouldRenderPreReadyBrowserPaneFallback
|
||||
} from './terminal-browser-pane-worktrees'
|
||||
|
||||
describe('getTerminalBrowserPaneWorktreeIds', () => {
|
||||
it('returns mounted worktrees unchanged for normal mounted browser panes', () => {
|
||||
const mounted = ['wt-active']
|
||||
|
||||
const result = getTerminalBrowserPaneWorktreeIds({
|
||||
mountedWorktreeIds: mounted,
|
||||
worktreeIds: ['wt-active'],
|
||||
activeWorktreeId: 'wt-active',
|
||||
activeTabType: 'browser',
|
||||
activeBrowserTabCount: 1
|
||||
})
|
||||
|
||||
expect(result).toBe(mounted)
|
||||
})
|
||||
|
||||
it('adds the active browser worktree before terminal panes are allowed to mount', () => {
|
||||
const result = getTerminalBrowserPaneWorktreeIds({
|
||||
mountedWorktreeIds: [],
|
||||
worktreeIds: ['wt-active'],
|
||||
activeWorktreeId: 'wt-active',
|
||||
activeTabType: 'browser',
|
||||
activeBrowserTabCount: 1
|
||||
})
|
||||
|
||||
expect(result).toEqual(['wt-active'])
|
||||
})
|
||||
|
||||
it('does not add missing or non-browser active worktrees', () => {
|
||||
expect(
|
||||
getTerminalBrowserPaneWorktreeIds({
|
||||
mountedWorktreeIds: [],
|
||||
worktreeIds: ['wt-other'],
|
||||
activeWorktreeId: 'wt-active',
|
||||
activeTabType: 'browser',
|
||||
activeBrowserTabCount: 1
|
||||
})
|
||||
).toEqual([])
|
||||
|
||||
expect(
|
||||
getTerminalBrowserPaneWorktreeIds({
|
||||
mountedWorktreeIds: [],
|
||||
worktreeIds: ['wt-active'],
|
||||
activeWorktreeId: 'wt-active',
|
||||
activeTabType: 'terminal',
|
||||
activeBrowserTabCount: 1
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('flags only active unmounted browser worktrees for pre-ready fallback rendering', () => {
|
||||
expect(
|
||||
shouldRenderPreReadyBrowserPaneFallback({
|
||||
worktreeIds: ['wt-active'],
|
||||
activeWorktreeId: 'wt-active',
|
||||
activeTabType: 'browser',
|
||||
activeBrowserTabCount: 1,
|
||||
activeWorktreeMounted: false
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
shouldRenderPreReadyBrowserPaneFallback({
|
||||
worktreeIds: ['wt-active'],
|
||||
activeWorktreeId: 'wt-active',
|
||||
activeTabType: 'browser',
|
||||
activeBrowserTabCount: 1,
|
||||
activeWorktreeMounted: true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldRenderPreReadyBrowserPaneFallback({
|
||||
worktreeIds: ['wt-active'],
|
||||
activeWorktreeId: 'wt-active',
|
||||
activeTabType: 'terminal',
|
||||
activeBrowserTabCount: 1,
|
||||
activeWorktreeMounted: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { WorkspaceVisibleTabType } from '../../../../shared/types'
|
||||
|
||||
export type TerminalBrowserPaneWorktreeInput = {
|
||||
mountedWorktreeIds: string[]
|
||||
worktreeIds: string[]
|
||||
activeWorktreeId: string | null
|
||||
activeTabType: WorkspaceVisibleTabType
|
||||
activeBrowserTabCount: number
|
||||
}
|
||||
|
||||
export type TerminalBrowserPaneFallbackInput = Omit<
|
||||
TerminalBrowserPaneWorktreeInput,
|
||||
'mountedWorktreeIds'
|
||||
> & {
|
||||
activeWorktreeMounted: boolean
|
||||
}
|
||||
|
||||
export function shouldRenderPreReadyBrowserPaneFallback({
|
||||
worktreeIds,
|
||||
activeWorktreeId,
|
||||
activeTabType,
|
||||
activeBrowserTabCount,
|
||||
activeWorktreeMounted
|
||||
}: TerminalBrowserPaneFallbackInput): boolean {
|
||||
return (
|
||||
activeWorktreeId !== null &&
|
||||
activeTabType === 'browser' &&
|
||||
activeBrowserTabCount > 0 &&
|
||||
!activeWorktreeMounted &&
|
||||
worktreeIds.includes(activeWorktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
export function getTerminalBrowserPaneWorktreeIds({
|
||||
mountedWorktreeIds,
|
||||
worktreeIds,
|
||||
activeWorktreeId,
|
||||
activeTabType,
|
||||
activeBrowserTabCount
|
||||
}: TerminalBrowserPaneWorktreeInput): string[] {
|
||||
if (
|
||||
activeWorktreeId === null ||
|
||||
activeTabType !== 'browser' ||
|
||||
activeBrowserTabCount === 0 ||
|
||||
mountedWorktreeIds.includes(activeWorktreeId) ||
|
||||
!worktreeIds.includes(activeWorktreeId)
|
||||
) {
|
||||
return mountedWorktreeIds
|
||||
}
|
||||
|
||||
// Why: BrowserPane does not spawn PTYs. Keep a restored active browser
|
||||
// visible while TerminalPane mounts still wait for reconnect to finish.
|
||||
return [...mountedWorktreeIds, activeWorktreeId]
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrowserTab } from '../../../../shared/types'
|
||||
import { getTerminalBrowserTabSlices } from './terminal-browser-tab-slices'
|
||||
|
||||
const browserTab = (id: string, worktreeId = 'wt-active'): BrowserTab => ({
|
||||
id,
|
||||
worktreeId,
|
||||
url: `https://example.com/${id}`,
|
||||
title: id,
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0
|
||||
})
|
||||
|
||||
describe('getTerminalBrowserTabSlices', () => {
|
||||
it('preserves slices when an unmounted worktree browser tab array changes', () => {
|
||||
const activeBrowserTabs = [browserTab('active')]
|
||||
const mountedIds = new Set(['wt-active'])
|
||||
const first = getTerminalBrowserTabSlices(
|
||||
{
|
||||
'wt-active': activeBrowserTabs,
|
||||
'wt-hidden': [browserTab('hidden-a', 'wt-hidden')]
|
||||
},
|
||||
mountedIds,
|
||||
'wt-active'
|
||||
)
|
||||
const second = getTerminalBrowserTabSlices(
|
||||
{
|
||||
'wt-active': activeBrowserTabs,
|
||||
'wt-hidden': [browserTab('hidden-b', 'wt-hidden')]
|
||||
},
|
||||
mountedIds,
|
||||
'wt-active'
|
||||
)
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second.activeBrowserTabs).toBe(activeBrowserTabs)
|
||||
})
|
||||
|
||||
it('updates slices when a mounted worktree browser tab array changes', () => {
|
||||
const mountedIds = new Set(['wt-active', 'wt-mounted'])
|
||||
const first = getTerminalBrowserTabSlices(
|
||||
{
|
||||
'wt-active': [browserTab('active')],
|
||||
'wt-mounted': [browserTab('mounted-a', 'wt-mounted')]
|
||||
},
|
||||
mountedIds,
|
||||
'wt-active'
|
||||
)
|
||||
const mountedBrowserTabs = [browserTab('mounted-b', 'wt-mounted')]
|
||||
const second = getTerminalBrowserTabSlices(
|
||||
{ 'wt-active': first.activeBrowserTabs, 'wt-mounted': mountedBrowserTabs },
|
||||
mountedIds,
|
||||
'wt-active'
|
||||
)
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.mountedBrowserTabsByWorktree['wt-mounted']).toBe(mountedBrowserTabs)
|
||||
})
|
||||
|
||||
it('keeps active browser tabs available even before the active worktree is mounted', () => {
|
||||
const activeBrowserTabs = [browserTab('active')]
|
||||
const slices = getTerminalBrowserTabSlices(
|
||||
{ 'wt-active': activeBrowserTabs },
|
||||
new Set(),
|
||||
'wt-active'
|
||||
)
|
||||
|
||||
expect(slices.activeBrowserTabs).toBe(activeBrowserTabs)
|
||||
expect(slices.mountedBrowserTabsByWorktree).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { BrowserTab } from '../../../../shared/types'
|
||||
|
||||
export type TerminalBrowserTabSlices = {
|
||||
activeBrowserTabs: BrowserTab[]
|
||||
mountedBrowserTabsByWorktree: Record<string, BrowserTab[]>
|
||||
}
|
||||
|
||||
const EMPTY_BROWSER_TABS: BrowserTab[] = []
|
||||
let cachedBrowserTabsByWorktree: Record<string, BrowserTab[]> | null = null
|
||||
let cachedMountedIdsKey = ''
|
||||
let cachedActiveWorktreeId: string | null = null
|
||||
let cachedSlices: TerminalBrowserTabSlices = {
|
||||
activeBrowserTabs: EMPTY_BROWSER_TABS,
|
||||
mountedBrowserTabsByWorktree: {}
|
||||
}
|
||||
|
||||
function mountedIdsKey(mountedWorktreeIds: ReadonlySet<string>): string {
|
||||
return [...mountedWorktreeIds].sort().join('\0')
|
||||
}
|
||||
|
||||
function sameMountedBrowserTabs(
|
||||
left: Record<string, BrowserTab[]>,
|
||||
right: Record<string, BrowserTab[]>
|
||||
): boolean {
|
||||
const leftKeys = Object.keys(left)
|
||||
const rightKeys = Object.keys(right)
|
||||
return leftKeys.length === rightKeys.length && leftKeys.every((key) => left[key] === right[key])
|
||||
}
|
||||
|
||||
export function getTerminalBrowserTabSlices(
|
||||
browserTabsByWorktree: Record<string, BrowserTab[]>,
|
||||
mountedWorktreeIds: ReadonlySet<string>,
|
||||
activeWorktreeId: string | null
|
||||
): TerminalBrowserTabSlices {
|
||||
const nextMountedIdsKey = mountedIdsKey(mountedWorktreeIds)
|
||||
if (
|
||||
browserTabsByWorktree === cachedBrowserTabsByWorktree &&
|
||||
nextMountedIdsKey === cachedMountedIdsKey &&
|
||||
activeWorktreeId === cachedActiveWorktreeId
|
||||
) {
|
||||
return cachedSlices
|
||||
}
|
||||
|
||||
const activeBrowserTabs = activeWorktreeId
|
||||
? (browserTabsByWorktree[activeWorktreeId] ?? EMPTY_BROWSER_TABS)
|
||||
: EMPTY_BROWSER_TABS
|
||||
const mountedBrowserTabsByWorktree: Record<string, BrowserTab[]> = {}
|
||||
for (const worktreeId of mountedWorktreeIds) {
|
||||
mountedBrowserTabsByWorktree[worktreeId] =
|
||||
browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS
|
||||
}
|
||||
|
||||
cachedBrowserTabsByWorktree = browserTabsByWorktree
|
||||
cachedMountedIdsKey = nextMountedIdsKey
|
||||
cachedActiveWorktreeId = activeWorktreeId
|
||||
if (
|
||||
activeBrowserTabs === cachedSlices.activeBrowserTabs &&
|
||||
sameMountedBrowserTabs(mountedBrowserTabsByWorktree, cachedSlices.mountedBrowserTabsByWorktree)
|
||||
) {
|
||||
return cachedSlices
|
||||
}
|
||||
|
||||
// Why: hidden BrowserPanes are retained only for mounted worktrees. Avoid
|
||||
// rendering or resubscribing the terminal surface when browser tabs in
|
||||
// unvisited worktrees restore or refresh in the background.
|
||||
cachedSlices = { activeBrowserTabs, mountedBrowserTabsByWorktree }
|
||||
return cachedSlices
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { getTerminalMountedWorktreeSnapshot } from './terminal-mounted-worktrees'
|
||||
|
||||
const worktree = (input: Partial<Worktree> & Pick<Worktree, 'id' | 'path'>): Worktree => ({
|
||||
id: input.id,
|
||||
path: input.path,
|
||||
repoId: input.repoId ?? 'repo-1',
|
||||
displayName: input.displayName ?? input.id,
|
||||
comment: input.comment ?? '',
|
||||
branch: input.branch ?? 'main',
|
||||
head: input.head ?? 'abc123',
|
||||
isBare: input.isBare ?? false,
|
||||
isMainWorktree: input.isMainWorktree ?? false,
|
||||
linkedIssue: input.linkedIssue ?? null,
|
||||
linkedPR: input.linkedPR ?? null,
|
||||
linkedLinearIssue: input.linkedLinearIssue ?? null,
|
||||
isArchived: input.isArchived ?? false,
|
||||
isUnread: input.isUnread ?? false,
|
||||
isPinned: input.isPinned ?? false,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
lastActivityAt: input.lastActivityAt ?? 0
|
||||
})
|
||||
|
||||
describe('getTerminalMountedWorktreeSnapshot', () => {
|
||||
it('preserves the snapshot when unrelated worktree metadata changes', () => {
|
||||
const mountedIds = new Set(['wt-active'])
|
||||
const first = getTerminalMountedWorktreeSnapshot(
|
||||
{
|
||||
'repo-1': [
|
||||
worktree({ id: 'wt-active', path: '/repo/active', linkedIssue: 1 }),
|
||||
worktree({ id: 'wt-other', path: '/repo/other', displayName: 'Other' })
|
||||
]
|
||||
},
|
||||
mountedIds
|
||||
)
|
||||
const second = getTerminalMountedWorktreeSnapshot(
|
||||
{
|
||||
'repo-1': [
|
||||
worktree({ id: 'wt-active', path: '/repo/active', linkedIssue: 2 }),
|
||||
worktree({ id: 'wt-other', path: '/repo/other', displayName: 'Renamed' })
|
||||
]
|
||||
},
|
||||
mountedIds
|
||||
)
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second.mountedWorktrees).toEqual([{ id: 'wt-active', path: '/repo/active' }])
|
||||
})
|
||||
|
||||
it('returns a new snapshot when a mounted worktree path changes', () => {
|
||||
const mountedIds = new Set(['wt-active'])
|
||||
const first = getTerminalMountedWorktreeSnapshot(
|
||||
{ 'repo-1': [worktree({ id: 'wt-active', path: '/repo/active' })] },
|
||||
mountedIds
|
||||
)
|
||||
const second = getTerminalMountedWorktreeSnapshot(
|
||||
{ 'repo-1': [worktree({ id: 'wt-active', path: '/repo/moved' })] },
|
||||
mountedIds
|
||||
)
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.mountedWorktrees).toEqual([{ id: 'wt-active', path: '/repo/moved' }])
|
||||
})
|
||||
|
||||
it('preserves the snapshot when an unmounted worktree path changes', () => {
|
||||
const mountedIds = new Set(['wt-active'])
|
||||
const first = getTerminalMountedWorktreeSnapshot(
|
||||
{
|
||||
'repo-1': [
|
||||
worktree({ id: 'wt-active', path: '/repo/active' }),
|
||||
worktree({ id: 'wt-hidden', path: '/repo/hidden-a' })
|
||||
]
|
||||
},
|
||||
mountedIds
|
||||
)
|
||||
const second = getTerminalMountedWorktreeSnapshot(
|
||||
{
|
||||
'repo-1': [
|
||||
worktree({ id: 'wt-active', path: '/repo/active' }),
|
||||
worktree({ id: 'wt-hidden', path: '/repo/hidden-b' })
|
||||
]
|
||||
},
|
||||
mountedIds
|
||||
)
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('updates mounted worktrees when the mounted id set changes', () => {
|
||||
const first = getTerminalMountedWorktreeSnapshot(
|
||||
{
|
||||
'repo-1': [
|
||||
worktree({ id: 'wt-active', path: '/repo/active' }),
|
||||
worktree({ id: 'wt-mounted', path: '/repo/mounted' })
|
||||
]
|
||||
},
|
||||
new Set(['wt-active'])
|
||||
)
|
||||
const second = getTerminalMountedWorktreeSnapshot(
|
||||
{
|
||||
'repo-1': [
|
||||
worktree({ id: 'wt-active', path: '/repo/active' }),
|
||||
worktree({ id: 'wt-mounted', path: '/repo/mounted' })
|
||||
]
|
||||
},
|
||||
new Set(['wt-active', 'wt-mounted'])
|
||||
)
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.mountedWorktrees.map((item) => item.id)).toEqual(['wt-active', 'wt-mounted'])
|
||||
})
|
||||
|
||||
it('dedupes duplicate worktree ids before mounting pane trees', () => {
|
||||
const snapshot = getTerminalMountedWorktreeSnapshot(
|
||||
{
|
||||
'repo-1': [worktree({ id: 'wt-active', path: '/repo/active-a' })],
|
||||
'repo-2': [worktree({ id: 'wt-active', path: '/repo/active-b', repoId: 'repo-2' })]
|
||||
},
|
||||
new Set(['wt-active'])
|
||||
)
|
||||
|
||||
expect(snapshot.worktreeIds).toEqual(['wt-active'])
|
||||
expect(snapshot.mountedWorktrees).toEqual([{ id: 'wt-active', path: '/repo/active-a' }])
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
|
||||
export type TerminalMountedWorktreeSnapshot = {
|
||||
mountedWorktrees: Pick<Worktree, 'id' | 'path'>[]
|
||||
worktreeIds: string[]
|
||||
}
|
||||
|
||||
let cachedWorktreesByRepo: Record<string, Worktree[]> | null = null
|
||||
let cachedMountedIdsKey = ''
|
||||
let cachedSnapshot: TerminalMountedWorktreeSnapshot = {
|
||||
mountedWorktrees: [],
|
||||
worktreeIds: []
|
||||
}
|
||||
|
||||
function mountedIdsKey(mountedWorktreeIds: ReadonlySet<string>): string {
|
||||
return [...mountedWorktreeIds].sort().join('\0')
|
||||
}
|
||||
|
||||
function sameWorktreeProjection(
|
||||
left: Pick<Worktree, 'id' | 'path'>[],
|
||||
right: Pick<Worktree, 'id' | 'path'>[]
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((worktree, index) => {
|
||||
const other = right[index]
|
||||
return worktree.id === other.id && worktree.path === other.path
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function getTerminalMountedWorktreeSnapshot(
|
||||
worktreesByRepo: Record<string, Worktree[]>,
|
||||
mountedWorktreeIds: ReadonlySet<string>
|
||||
): TerminalMountedWorktreeSnapshot {
|
||||
const nextMountedIdsKey = mountedIdsKey(mountedWorktreeIds)
|
||||
if (worktreesByRepo === cachedWorktreesByRepo && nextMountedIdsKey === cachedMountedIdsKey) {
|
||||
return cachedSnapshot
|
||||
}
|
||||
|
||||
const worktreeById = new Map<string, Pick<Worktree, 'id' | 'path'>>()
|
||||
for (const repoWorktrees of Object.values(worktreesByRepo)) {
|
||||
for (const worktree of repoWorktrees) {
|
||||
if (!worktreeById.has(worktree.id)) {
|
||||
worktreeById.set(worktree.id, { id: worktree.id, path: worktree.path })
|
||||
}
|
||||
}
|
||||
}
|
||||
const worktreeIds = [...worktreeById.keys()]
|
||||
const mountedWorktrees = [...worktreeById.values()].filter((worktree) =>
|
||||
mountedWorktreeIds.has(worktree.id)
|
||||
)
|
||||
|
||||
cachedWorktreesByRepo = worktreesByRepo
|
||||
cachedMountedIdsKey = nextMountedIdsKey
|
||||
if (
|
||||
worktreeIds.length === cachedSnapshot.worktreeIds.length &&
|
||||
worktreeIds.every((id, index) => id === cachedSnapshot.worktreeIds[index]) &&
|
||||
sameWorktreeProjection(mountedWorktrees, cachedSnapshot.mountedWorktrees)
|
||||
) {
|
||||
return cachedSnapshot
|
||||
}
|
||||
|
||||
// Why: Terminal only needs all IDs for pruning plus id/path for mounted pane
|
||||
// trees. Preserve the snapshot when unrelated or unmounted worktree metadata
|
||||
// changes so sidebar/status refreshes don't rerender xterm during typing.
|
||||
cachedSnapshot = { mountedWorktrees, worktreeIds }
|
||||
return cachedSnapshot
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { getTerminalTabSlices } from './terminal-tab-slices'
|
||||
|
||||
const tab = (id: string, worktreeId = 'wt-active'): TerminalTab => ({
|
||||
id,
|
||||
title: id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
generation: 0
|
||||
})
|
||||
|
||||
describe('getTerminalTabSlices', () => {
|
||||
it('preserves slices when an unmounted worktree tab array changes', () => {
|
||||
const activeTabs = [tab('active')]
|
||||
const mountedIds = new Set(['wt-active'])
|
||||
const first = getTerminalTabSlices(
|
||||
{ 'wt-active': activeTabs, 'wt-hidden': [tab('hidden-a', 'wt-hidden')] },
|
||||
mountedIds,
|
||||
'wt-active'
|
||||
)
|
||||
const second = getTerminalTabSlices(
|
||||
{ 'wt-active': activeTabs, 'wt-hidden': [tab('hidden-b', 'wt-hidden')] },
|
||||
mountedIds,
|
||||
'wt-active'
|
||||
)
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second.activeTabs).toBe(activeTabs)
|
||||
})
|
||||
|
||||
it('updates slices when a mounted worktree tab array changes', () => {
|
||||
const mountedIds = new Set(['wt-active', 'wt-mounted'])
|
||||
const first = getTerminalTabSlices(
|
||||
{ 'wt-active': [tab('active')], 'wt-mounted': [tab('mounted-a', 'wt-mounted')] },
|
||||
mountedIds,
|
||||
'wt-active'
|
||||
)
|
||||
const mountedTabs = [tab('mounted-b', 'wt-mounted')]
|
||||
const second = getTerminalTabSlices(
|
||||
{ 'wt-active': first.activeTabs, 'wt-mounted': mountedTabs },
|
||||
mountedIds,
|
||||
'wt-active'
|
||||
)
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.mountedTabsByWorktree['wt-mounted']).toBe(mountedTabs)
|
||||
})
|
||||
|
||||
it('keeps active tabs available even before the active worktree is mounted', () => {
|
||||
const activeTabs = [tab('active')]
|
||||
const slices = getTerminalTabSlices({ 'wt-active': activeTabs }, new Set(), 'wt-active')
|
||||
|
||||
expect(slices.activeTabs).toBe(activeTabs)
|
||||
expect(slices.mountedTabsByWorktree).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
|
||||
export type TerminalTabSlices = {
|
||||
activeTabs: TerminalTab[]
|
||||
mountedTabsByWorktree: Record<string, TerminalTab[]>
|
||||
}
|
||||
|
||||
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
|
||||
let cachedTabsByWorktree: Record<string, TerminalTab[]> | null = null
|
||||
let cachedMountedIdsKey = ''
|
||||
let cachedActiveWorktreeId: string | null = null
|
||||
let cachedSlices: TerminalTabSlices = {
|
||||
activeTabs: EMPTY_TERMINAL_TABS,
|
||||
mountedTabsByWorktree: {}
|
||||
}
|
||||
|
||||
function mountedIdsKey(mountedWorktreeIds: ReadonlySet<string>): string {
|
||||
return [...mountedWorktreeIds].sort().join('\0')
|
||||
}
|
||||
|
||||
function sameMountedTabs(
|
||||
left: Record<string, TerminalTab[]>,
|
||||
right: Record<string, TerminalTab[]>
|
||||
): boolean {
|
||||
const leftKeys = Object.keys(left)
|
||||
const rightKeys = Object.keys(right)
|
||||
return leftKeys.length === rightKeys.length && leftKeys.every((key) => left[key] === right[key])
|
||||
}
|
||||
|
||||
export function getTerminalTabSlices(
|
||||
tabsByWorktree: Record<string, TerminalTab[]>,
|
||||
mountedWorktreeIds: ReadonlySet<string>,
|
||||
activeWorktreeId: string | null
|
||||
): TerminalTabSlices {
|
||||
const nextMountedIdsKey = mountedIdsKey(mountedWorktreeIds)
|
||||
if (
|
||||
tabsByWorktree === cachedTabsByWorktree &&
|
||||
nextMountedIdsKey === cachedMountedIdsKey &&
|
||||
activeWorktreeId === cachedActiveWorktreeId
|
||||
) {
|
||||
return cachedSlices
|
||||
}
|
||||
|
||||
const activeTabs = activeWorktreeId
|
||||
? (tabsByWorktree[activeWorktreeId] ?? EMPTY_TERMINAL_TABS)
|
||||
: EMPTY_TERMINAL_TABS
|
||||
const mountedTabsByWorktree: Record<string, TerminalTab[]> = {}
|
||||
for (const worktreeId of mountedWorktreeIds) {
|
||||
mountedTabsByWorktree[worktreeId] = tabsByWorktree[worktreeId] ?? EMPTY_TERMINAL_TABS
|
||||
}
|
||||
|
||||
cachedTabsByWorktree = tabsByWorktree
|
||||
cachedMountedIdsKey = nextMountedIdsKey
|
||||
cachedActiveWorktreeId = activeWorktreeId
|
||||
if (
|
||||
activeTabs === cachedSlices.activeTabs &&
|
||||
sameMountedTabs(mountedTabsByWorktree, cachedSlices.mountedTabsByWorktree)
|
||||
) {
|
||||
return cachedSlices
|
||||
}
|
||||
|
||||
// Why: Terminal renders only the active titlebar and mounted pane trees.
|
||||
// Ignore tab-array churn for unmounted worktrees so background metadata
|
||||
// updates do not rerender xterm while the user is typing.
|
||||
cachedSlices = { activeTabs, mountedTabsByWorktree }
|
||||
return cachedSlices
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { ForegroundTerminalOutputTarget } from './pane-terminal-foreground-render-settle'
|
||||
|
||||
export type TerminalOutputBeforeWrite = (data: string) => void
|
||||
|
||||
export type ForegroundOutputHoldOptions = {
|
||||
idleMs: number
|
||||
maxMs: number
|
||||
}
|
||||
|
||||
type TerminalOutputTarget = ForegroundTerminalOutputTarget
|
||||
|
||||
type ForegroundHoldEntry = {
|
||||
terminal: TerminalOutputTarget
|
||||
chunks: string[]
|
||||
beforeWrite?: TerminalOutputBeforeWrite
|
||||
holdUntil: number
|
||||
idleTimer: ReturnType<typeof setTimeout> | null
|
||||
maxTimer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
type WriteForegroundOutput = (
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
beforeWrite?: TerminalOutputBeforeWrite
|
||||
) => void
|
||||
|
||||
const foregroundHoldByTerminal = new Map<TerminalOutputTarget, ForegroundHoldEntry>()
|
||||
|
||||
function nowMs(): number {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now()
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
function clearForegroundHoldTimers(entry: ForegroundHoldEntry): void {
|
||||
if (entry.idleTimer !== null) {
|
||||
clearTimeout(entry.idleTimer)
|
||||
entry.idleTimer = null
|
||||
}
|
||||
if (entry.maxTimer !== null) {
|
||||
clearTimeout(entry.maxTimer)
|
||||
entry.maxTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function takeAllQueuedOutput(entry: ForegroundHoldEntry): string {
|
||||
const data = entry.chunks.join('')
|
||||
entry.chunks.length = 0
|
||||
return data
|
||||
}
|
||||
|
||||
function flushForegroundHoldEntry(
|
||||
entry: ForegroundHoldEntry,
|
||||
writeForegroundOutput: WriteForegroundOutput
|
||||
): void {
|
||||
clearForegroundHoldTimers(entry)
|
||||
foregroundHoldByTerminal.delete(entry.terminal)
|
||||
|
||||
const data = takeAllQueuedOutput(entry)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
writeForegroundOutput(entry.terminal, data, entry.beforeWrite)
|
||||
}
|
||||
|
||||
function scheduleForegroundHoldFlush(
|
||||
entry: ForegroundHoldEntry,
|
||||
writeForegroundOutput: WriteForegroundOutput
|
||||
): void {
|
||||
if (entry.idleTimer !== null) {
|
||||
clearTimeout(entry.idleTimer)
|
||||
}
|
||||
|
||||
const delayMs = Math.max(0, entry.holdUntil - nowMs())
|
||||
entry.idleTimer = setTimeout(
|
||||
() => flushForegroundHoldEntry(entry, writeForegroundOutput),
|
||||
delayMs
|
||||
)
|
||||
}
|
||||
|
||||
function getActiveForegroundHoldEntry(
|
||||
terminal: TerminalOutputTarget,
|
||||
writeForegroundOutput: WriteForegroundOutput,
|
||||
atMs = nowMs()
|
||||
): ForegroundHoldEntry | null {
|
||||
const entry = foregroundHoldByTerminal.get(terminal)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (atMs <= entry.holdUntil) {
|
||||
return entry
|
||||
}
|
||||
flushForegroundHoldEntry(entry, writeForegroundOutput)
|
||||
return null
|
||||
}
|
||||
|
||||
export function queueForegroundOutputIfHeld(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
beforeWrite: TerminalOutputBeforeWrite | undefined,
|
||||
writeForegroundOutput: WriteForegroundOutput
|
||||
): boolean {
|
||||
const entry = getActiveForegroundHoldEntry(terminal, writeForegroundOutput)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
entry.beforeWrite = beforeWrite
|
||||
entry.chunks.push(data)
|
||||
scheduleForegroundHoldFlush(entry, writeForegroundOutput)
|
||||
return true
|
||||
}
|
||||
|
||||
export function holdForegroundTerminalOutput(
|
||||
terminal: TerminalOutputTarget,
|
||||
options: ForegroundOutputHoldOptions,
|
||||
writeForegroundOutput?: WriteForegroundOutput
|
||||
): void {
|
||||
const atMs = nowMs()
|
||||
const idleMs = Math.max(0, options.idleMs)
|
||||
const maxMs = Math.max(idleMs, options.maxMs)
|
||||
const existing = foregroundHoldByTerminal.get(terminal)
|
||||
const entry =
|
||||
existing && atMs <= existing.holdUntil
|
||||
? existing
|
||||
: {
|
||||
terminal,
|
||||
chunks: [],
|
||||
holdUntil: atMs,
|
||||
idleTimer: null,
|
||||
maxTimer: null
|
||||
}
|
||||
|
||||
if (existing && existing !== entry && writeForegroundOutput) {
|
||||
flushForegroundHoldEntry(existing, writeForegroundOutput)
|
||||
}
|
||||
|
||||
entry.holdUntil = atMs + idleMs
|
||||
foregroundHoldByTerminal.set(terminal, entry)
|
||||
|
||||
if (writeForegroundOutput) {
|
||||
scheduleForegroundHoldFlush(entry, writeForegroundOutput)
|
||||
}
|
||||
|
||||
if (entry.maxTimer === null && writeForegroundOutput) {
|
||||
// Why: TUI wheel bursts should coalesce enough output for input to keep up,
|
||||
// but continuous scrolling must still repaint periodically.
|
||||
entry.maxTimer = setTimeout(() => flushForegroundHoldEntry(entry, writeForegroundOutput), maxMs)
|
||||
}
|
||||
}
|
||||
|
||||
export function flushHeldForegroundOutput(
|
||||
terminal: TerminalOutputTarget,
|
||||
writeForegroundOutput: WriteForegroundOutput
|
||||
): void {
|
||||
const heldEntry = foregroundHoldByTerminal.get(terminal)
|
||||
if (heldEntry) {
|
||||
flushForegroundHoldEntry(heldEntry, writeForegroundOutput)
|
||||
}
|
||||
}
|
||||
|
||||
export function discardHeldForegroundOutput(terminal: TerminalOutputTarget): void {
|
||||
const heldEntry = foregroundHoldByTerminal.get(terminal)
|
||||
if (heldEntry) {
|
||||
clearForegroundHoldTimers(heldEntry)
|
||||
foregroundHoldByTerminal.delete(terminal)
|
||||
}
|
||||
}
|
||||
@@ -233,96 +233,6 @@ describe('pane terminal output scheduler', () => {
|
||||
expect(terminal.write.mock.calls.map(([data]) => data)).toEqual(['old', 'new'])
|
||||
})
|
||||
|
||||
it('holds foreground output until a TUI wheel burst goes idle', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { holdForegroundTerminalOutput, writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
const beforeWrite = vi.fn()
|
||||
|
||||
holdForegroundTerminalOutput(terminal, { idleMs: 80, maxMs: 300 })
|
||||
writeTerminalOutput(terminal, 'a', { foreground: true, beforeWrite })
|
||||
writeTerminalOutput(terminal, 'b', { foreground: true, beforeWrite })
|
||||
|
||||
expect(beforeWrite).not.toHaveBeenCalled()
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(79)
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(beforeWrite).toHaveBeenCalledTimes(1)
|
||||
expect(beforeWrite).toHaveBeenCalledWith('ab')
|
||||
expect(terminal.write).toHaveBeenCalledWith('ab', expect.any(Function))
|
||||
})
|
||||
|
||||
it('extends the foreground hold while TUI wheel events continue', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { holdForegroundTerminalOutput, writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
holdForegroundTerminalOutput(terminal, { idleMs: 80, maxMs: 300 })
|
||||
writeTerminalOutput(terminal, 'a', { foreground: true })
|
||||
|
||||
vi.advanceTimersByTime(70)
|
||||
holdForegroundTerminalOutput(terminal, { idleMs: 80, maxMs: 300 })
|
||||
writeTerminalOutput(terminal, 'b', { foreground: true })
|
||||
|
||||
vi.advanceTimersByTime(79)
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(terminal.write.mock.calls.map(([data]) => data)).toEqual(['ab'])
|
||||
})
|
||||
|
||||
it('flushes held foreground output at the maximum hold time during continuous wheel input', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { holdForegroundTerminalOutput, writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
holdForegroundTerminalOutput(terminal, { idleMs: 80, maxMs: 300 })
|
||||
writeTerminalOutput(terminal, 'a', { foreground: true })
|
||||
|
||||
for (const chunk of ['b', 'c', 'd', 'e']) {
|
||||
vi.advanceTimersByTime(70)
|
||||
holdForegroundTerminalOutput(terminal, { idleMs: 80, maxMs: 300 })
|
||||
writeTerminalOutput(terminal, chunk, { foreground: true })
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(19)
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(terminal.write.mock.calls.map(([data]) => data)).toEqual(['abcde'])
|
||||
})
|
||||
|
||||
it('flushes held foreground output before explicit terminal snapshots', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { flushTerminalOutput, holdForegroundTerminalOutput, writeTerminalOutput } =
|
||||
await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
holdForegroundTerminalOutput(terminal, { idleMs: 80, maxMs: 300 })
|
||||
writeTerminalOutput(terminal, 'held', { foreground: true })
|
||||
flushTerminalOutput(terminal)
|
||||
|
||||
expect(terminal.write.mock.calls.map(([data]) => data)).toEqual(['held'])
|
||||
})
|
||||
|
||||
it('keeps held foreground output before later background output', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { holdForegroundTerminalOutput, writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
holdForegroundTerminalOutput(terminal, { idleMs: 80, maxMs: 300 })
|
||||
writeTerminalOutput(terminal, 'foreground', { foreground: true })
|
||||
writeTerminalOutput(terminal, 'background', { foreground: false })
|
||||
|
||||
expect(terminal.write.mock.calls.map(([data]) => data)).toEqual(['foreground'])
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(terminal.write.mock.calls.map(([data]) => data)).toEqual(['foreground', 'background'])
|
||||
})
|
||||
|
||||
it('discards queued output for disposed terminals', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { discardTerminalOutput, writeTerminalOutput } = await loadScheduler()
|
||||
@@ -355,18 +265,4 @@ describe('pane terminal output scheduler', () => {
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(throwing.write).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('discards held foreground output for disposed terminals', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { discardTerminalOutput, holdForegroundTerminalOutput, writeTerminalOutput } =
|
||||
await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
holdForegroundTerminalOutput(terminal, { idleMs: 80, maxMs: 300 })
|
||||
writeTerminalOutput(terminal, 'stale', { foreground: true })
|
||||
discardTerminalOutput(terminal)
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,17 +5,11 @@ import {
|
||||
writeForegroundTerminalChunk,
|
||||
type ForegroundTerminalOutputTarget
|
||||
} from './pane-terminal-foreground-render-settle'
|
||||
import {
|
||||
discardHeldForegroundOutput,
|
||||
flushHeldForegroundOutput,
|
||||
holdForegroundTerminalOutput as holdForegroundTerminalOutputForWriter,
|
||||
queueForegroundOutputIfHeld,
|
||||
type ForegroundOutputHoldOptions,
|
||||
type TerminalOutputBeforeWrite
|
||||
} from './pane-terminal-foreground-output-hold'
|
||||
|
||||
type TerminalOutputTarget = ForegroundTerminalOutputTarget
|
||||
|
||||
type TerminalOutputBeforeWrite = (data: string) => void
|
||||
|
||||
type QueueEntry = {
|
||||
terminal: TerminalOutputTarget
|
||||
chunks: string[]
|
||||
@@ -166,27 +160,45 @@ function drainQueuedOutput(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function writeForegroundOutput(
|
||||
export function writeTerminalOutput(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
beforeWrite?: TerminalOutputBeforeWrite
|
||||
): void {
|
||||
if (debugEnabled) {
|
||||
debugState.foregroundWriteCount++
|
||||
}
|
||||
beforeWrite?.(data)
|
||||
writeForegroundTerminalChunk(terminal, data)
|
||||
}
|
||||
|
||||
export function holdForegroundTerminalOutput(
|
||||
terminal: TerminalOutputTarget,
|
||||
options: ForegroundOutputHoldOptions
|
||||
options: { foreground: boolean; beforeWrite?: TerminalOutputBeforeWrite }
|
||||
): void {
|
||||
exposeDebugApi()
|
||||
holdForegroundTerminalOutputForWriter(terminal, options, writeForegroundOutput)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
if (options.foreground) {
|
||||
flushTerminalOutput(terminal)
|
||||
if (debugEnabled) {
|
||||
debugState.foregroundWriteCount++
|
||||
}
|
||||
options.beforeWrite?.(data)
|
||||
writeForegroundTerminalChunk(terminal, data)
|
||||
return
|
||||
}
|
||||
|
||||
let entry = queuedByTerminal.get(terminal)
|
||||
if (!entry) {
|
||||
entry = { terminal, chunks: [], beforeWrite: options.beforeWrite }
|
||||
queuedByTerminal.set(terminal, entry)
|
||||
} else {
|
||||
entry.beforeWrite = options.beforeWrite
|
||||
}
|
||||
entry.chunks.push(data)
|
||||
if (debugEnabled) {
|
||||
debugState.backgroundEnqueueCount++
|
||||
}
|
||||
// Why: non-focused panes can produce output continuously. Letting every
|
||||
// pane call xterm.write immediately schedules one xterm WriteBuffer timer
|
||||
// per pane, which starves the focused terminal on the shared renderer thread.
|
||||
scheduleDrain(BACKGROUND_FLUSH_DELAY_MS)
|
||||
}
|
||||
|
||||
function flushQueuedBackgroundOutput(terminal: TerminalOutputTarget): void {
|
||||
export function flushTerminalOutput(terminal: TerminalOutputTarget): void {
|
||||
exposeDebugApi()
|
||||
const entry = queuedByTerminal.get(terminal)
|
||||
if (!entry) {
|
||||
return
|
||||
@@ -211,49 +223,6 @@ function flushQueuedBackgroundOutput(terminal: TerminalOutputTarget): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTerminalOutput(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
options: { foreground: boolean; beforeWrite?: TerminalOutputBeforeWrite }
|
||||
): void {
|
||||
exposeDebugApi()
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
if (options.foreground) {
|
||||
flushQueuedBackgroundOutput(terminal)
|
||||
if (queueForegroundOutputIfHeld(terminal, data, options.beforeWrite, writeForegroundOutput)) {
|
||||
return
|
||||
}
|
||||
writeForegroundOutput(terminal, data, options.beforeWrite)
|
||||
return
|
||||
}
|
||||
|
||||
flushHeldForegroundOutput(terminal, writeForegroundOutput)
|
||||
let entry = queuedByTerminal.get(terminal)
|
||||
if (!entry) {
|
||||
entry = { terminal, chunks: [], beforeWrite: options.beforeWrite }
|
||||
queuedByTerminal.set(terminal, entry)
|
||||
} else {
|
||||
entry.beforeWrite = options.beforeWrite
|
||||
}
|
||||
entry.chunks.push(data)
|
||||
if (debugEnabled) {
|
||||
debugState.backgroundEnqueueCount++
|
||||
}
|
||||
// Why: non-focused panes can produce output continuously. Letting every
|
||||
// pane call xterm.write immediately schedules one xterm WriteBuffer timer
|
||||
// per pane, which starves the focused terminal on the shared renderer thread.
|
||||
scheduleDrain(BACKGROUND_FLUSH_DELAY_MS)
|
||||
}
|
||||
|
||||
export function flushTerminalOutput(terminal: TerminalOutputTarget): void {
|
||||
exposeDebugApi()
|
||||
flushHeldForegroundOutput(terminal, writeForegroundOutput)
|
||||
flushQueuedBackgroundOutput(terminal)
|
||||
}
|
||||
|
||||
export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Promise<void> {
|
||||
flushTerminalOutput(terminal)
|
||||
|
||||
@@ -282,7 +251,6 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro
|
||||
export function discardTerminalOutput(terminal: TerminalOutputTarget): void {
|
||||
exposeDebugApi()
|
||||
queuedByTerminal.delete(terminal)
|
||||
discardHeldForegroundOutput(terminal)
|
||||
discardForegroundRenderSettle(terminal)
|
||||
}
|
||||
|
||||
|
||||
@@ -136,8 +136,8 @@ describe('repo update serialization', () => {
|
||||
await store.getState().updateRepo(localRepo.id, {
|
||||
repoIcon: {
|
||||
type: 'image',
|
||||
source: 'github',
|
||||
src: 'https://example.com/icon.png'
|
||||
source: 'upload',
|
||||
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
|
||||
} as never
|
||||
})
|
||||
|
||||
|
||||
@@ -46,17 +46,6 @@ describe('sanitizeRepoIcon', () => {
|
||||
src: 'data:image/png;base64,aGVsbG8=',
|
||||
source: 'upload'
|
||||
})
|
||||
expect(
|
||||
sanitizeRepoIcon({
|
||||
type: 'image',
|
||||
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
|
||||
source: 'upload'
|
||||
})
|
||||
).toEqual({
|
||||
type: 'image',
|
||||
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
|
||||
source: 'upload'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps null as an explicit reset', () => {
|
||||
@@ -78,6 +67,13 @@ describe('sanitizeRepoIcon', () => {
|
||||
source: 'upload'
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
sanitizeRepoIcon({
|
||||
type: 'image',
|
||||
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
|
||||
source: 'upload'
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
sanitizeRepoIcon({
|
||||
type: 'image',
|
||||
@@ -85,19 +81,5 @@ describe('sanitizeRepoIcon', () => {
|
||||
source: 'github'
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
sanitizeRepoIcon({
|
||||
type: 'image',
|
||||
src: 'https://example.com/icon.png',
|
||||
source: 'favicon'
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
sanitizeRepoIcon({
|
||||
type: 'image',
|
||||
src: 'https://example.com/icon.png',
|
||||
source: 'upload'
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ const isRepoIconImageSource = (value: string): value is RepoIconImageSource =>
|
||||
|
||||
function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean {
|
||||
if (source === 'upload') {
|
||||
return /^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/i.test(src)
|
||||
return /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src)
|
||||
}
|
||||
|
||||
let url: URL
|
||||
|
||||
Reference in New Issue
Block a user