diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 4712828a5a2..37048c650c3 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -11,8 +11,7 @@ import { getSpawnArgsForWindows } from '../win32-utils' export const EXTERNAL_EDITOR_CLI_COMMAND = 'code' const REPO_ICON_IMAGE_MIME_TYPES: Record = { - '.png': 'image/png', - '.svg': 'image/svg+xml' + '.png': 'image/png' } async function pathExists(pathValue: string): Promise { @@ -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) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index a2345bea7f3..f3a1c2fd7f8 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 }) ) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 50404c8af73..a288e49a4ce 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 + } + ).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 + } + ).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 () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 591044a8a28..bb9f56920e2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 228e284860b..19bdbd54e89 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(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 diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index befc287d2f6..88acbca21b2 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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()) - const measurableBackgroundWorktreeIdsRef = useRef(new Set()) - const measurableBackgroundWorktreeTimersRef = useRef(new Map()) - 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(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([]) // 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()) + const measurableBackgroundWorktreeIdsRef = useRef(new Set()) + const measurableBackgroundWorktreeTimersRef = useRef(new Map()) + 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 ( - - ) - })} + {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 ( + + ) + })} ) : 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 ? ( -
0) || - (activeTabType === 'browser' && worktreeBrowserTabs.length > 0) - ? 'hidden' - : '' - }`} - > - {terminalWorktreeSnapshot.mountedWorktrees.map((worktree) => { + {/* Terminal panes container - hidden when editor tab active */} +
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} > - {(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 {
) })} -
- ) : null} + {/* 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 (
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index ce19eefb1da..760e933ee50 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -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) { diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-open-files.test.ts b/src/renderer/src/components/floating-terminal/floating-terminal-open-files.test.ts deleted file mode 100644 index 9f1eecf8939..00000000000 --- a/src/renderer/src/components/floating-terminal/floating-terminal-open-files.test.ts +++ /dev/null @@ -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']) - }) -}) diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-open-files.ts b/src/renderer/src/components/floating-terminal/floating-terminal-open-files.ts deleted file mode 100644 index 155cee8812b..00000000000 --- a/src/renderer/src/components/floating-terminal/floating-terminal-open-files.ts +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 5931d3afe9a..a0f90a20a85 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -230,7 +230,7 @@ export function RepositoryIconPicker({ onClick={handleUploadImage} > - Upload PNG/SVG + Upload PNG
-

PNG/SVG uploads must be 256KB or smaller.

+

PNG uploads must be 256KB or smaller.

diff --git a/src/renderer/src/components/settings/TerminalPane.ghostty.test.ts b/src/renderer/src/components/settings/TerminalPane.ghostty.test.ts index 3d62f77a49a..ba86793f667 100644 --- a/src/renderer/src/components/settings/TerminalPane.ghostty.test.ts +++ b/src/renderer/src/components/settings/TerminalPane.ghostty.test.ts @@ -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 diff --git a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts index 53293e7a2a4..804d2038be4 100644 --- a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts +++ b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts @@ -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 } -function getPropNodes(props: Record | 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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2dd4c539c55..4d9c2efcf0a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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>() 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() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 2f6c14c69b3..cc10dd15041 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -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() }) diff --git a/src/renderer/src/components/terminal-pane/terminal-wheel-input-batcher.test.ts b/src/renderer/src/components/terminal-pane/terminal-wheel-input-batcher.test.ts deleted file mode 100644 index 605dea7e81d..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-wheel-input-batcher.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-wheel-input-batcher.ts b/src/renderer/src/components/terminal-pane/terminal-wheel-input-batcher.ts deleted file mode 100644 index 2c9e60ab265..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-wheel-input-batcher.ts +++ /dev/null @@ -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 | null = null - let maxTimer: ReturnType | 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 - } - } -} diff --git a/src/renderer/src/components/terminal/active-worktree-open-files.test.ts b/src/renderer/src/components/terminal/active-worktree-open-files.test.ts deleted file mode 100644 index 91600ef39f0..00000000000 --- a/src/renderer/src/components/terminal/active-worktree-open-files.test.ts +++ /dev/null @@ -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([]) - }) -}) diff --git a/src/renderer/src/components/terminal/active-worktree-open-files.ts b/src/renderer/src/components/terminal/active-worktree-open-files.ts deleted file mode 100644 index 48f5342b50a..00000000000 --- a/src/renderer/src/components/terminal/active-worktree-open-files.ts +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/components/terminal/terminal-browser-pane-worktrees.test.ts b/src/renderer/src/components/terminal/terminal-browser-pane-worktrees.test.ts deleted file mode 100644 index 600eec9d73f..00000000000 --- a/src/renderer/src/components/terminal/terminal-browser-pane-worktrees.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/terminal/terminal-browser-pane-worktrees.ts b/src/renderer/src/components/terminal/terminal-browser-pane-worktrees.ts deleted file mode 100644 index 9c28c26cb16..00000000000 --- a/src/renderer/src/components/terminal/terminal-browser-pane-worktrees.ts +++ /dev/null @@ -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] -} diff --git a/src/renderer/src/components/terminal/terminal-browser-tab-slices.test.ts b/src/renderer/src/components/terminal/terminal-browser-tab-slices.test.ts deleted file mode 100644 index 3f3379c062f..00000000000 --- a/src/renderer/src/components/terminal/terminal-browser-tab-slices.test.ts +++ /dev/null @@ -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({}) - }) -}) diff --git a/src/renderer/src/components/terminal/terminal-browser-tab-slices.ts b/src/renderer/src/components/terminal/terminal-browser-tab-slices.ts deleted file mode 100644 index df6f23c7828..00000000000 --- a/src/renderer/src/components/terminal/terminal-browser-tab-slices.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { BrowserTab } from '../../../../shared/types' - -export type TerminalBrowserTabSlices = { - activeBrowserTabs: BrowserTab[] - mountedBrowserTabsByWorktree: Record -} - -const EMPTY_BROWSER_TABS: BrowserTab[] = [] -let cachedBrowserTabsByWorktree: Record | null = null -let cachedMountedIdsKey = '' -let cachedActiveWorktreeId: string | null = null -let cachedSlices: TerminalBrowserTabSlices = { - activeBrowserTabs: EMPTY_BROWSER_TABS, - mountedBrowserTabsByWorktree: {} -} - -function mountedIdsKey(mountedWorktreeIds: ReadonlySet): string { - return [...mountedWorktreeIds].sort().join('\0') -} - -function sameMountedBrowserTabs( - left: Record, - right: Record -): 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, - mountedWorktreeIds: ReadonlySet, - 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 = {} - 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 -} diff --git a/src/renderer/src/components/terminal/terminal-mounted-worktrees.test.ts b/src/renderer/src/components/terminal/terminal-mounted-worktrees.test.ts deleted file mode 100644 index 7b5ae4b3475..00000000000 --- a/src/renderer/src/components/terminal/terminal-mounted-worktrees.test.ts +++ /dev/null @@ -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 & Pick): 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' }]) - }) -}) diff --git a/src/renderer/src/components/terminal/terminal-mounted-worktrees.ts b/src/renderer/src/components/terminal/terminal-mounted-worktrees.ts deleted file mode 100644 index 1d8fcae3673..00000000000 --- a/src/renderer/src/components/terminal/terminal-mounted-worktrees.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Worktree } from '../../../../shared/types' - -export type TerminalMountedWorktreeSnapshot = { - mountedWorktrees: Pick[] - worktreeIds: string[] -} - -let cachedWorktreesByRepo: Record | null = null -let cachedMountedIdsKey = '' -let cachedSnapshot: TerminalMountedWorktreeSnapshot = { - mountedWorktrees: [], - worktreeIds: [] -} - -function mountedIdsKey(mountedWorktreeIds: ReadonlySet): string { - return [...mountedWorktreeIds].sort().join('\0') -} - -function sameWorktreeProjection( - left: Pick[], - right: Pick[] -): 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, - mountedWorktreeIds: ReadonlySet -): TerminalMountedWorktreeSnapshot { - const nextMountedIdsKey = mountedIdsKey(mountedWorktreeIds) - if (worktreesByRepo === cachedWorktreesByRepo && nextMountedIdsKey === cachedMountedIdsKey) { - return cachedSnapshot - } - - const worktreeById = new Map>() - 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 -} diff --git a/src/renderer/src/components/terminal/terminal-tab-slices.test.ts b/src/renderer/src/components/terminal/terminal-tab-slices.test.ts deleted file mode 100644 index d6372c34bd8..00000000000 --- a/src/renderer/src/components/terminal/terminal-tab-slices.test.ts +++ /dev/null @@ -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({}) - }) -}) diff --git a/src/renderer/src/components/terminal/terminal-tab-slices.ts b/src/renderer/src/components/terminal/terminal-tab-slices.ts deleted file mode 100644 index a64b234991a..00000000000 --- a/src/renderer/src/components/terminal/terminal-tab-slices.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { TerminalTab } from '../../../../shared/types' - -export type TerminalTabSlices = { - activeTabs: TerminalTab[] - mountedTabsByWorktree: Record -} - -const EMPTY_TERMINAL_TABS: TerminalTab[] = [] -let cachedTabsByWorktree: Record | null = null -let cachedMountedIdsKey = '' -let cachedActiveWorktreeId: string | null = null -let cachedSlices: TerminalTabSlices = { - activeTabs: EMPTY_TERMINAL_TABS, - mountedTabsByWorktree: {} -} - -function mountedIdsKey(mountedWorktreeIds: ReadonlySet): string { - return [...mountedWorktreeIds].sort().join('\0') -} - -function sameMountedTabs( - left: Record, - right: Record -): 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, - mountedWorktreeIds: ReadonlySet, - 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 = {} - 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 -} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-output-hold.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-output-hold.ts deleted file mode 100644 index b7d51c2e254..00000000000 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-output-hold.ts +++ /dev/null @@ -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 | null - maxTimer: ReturnType | null -} - -type WriteForegroundOutput = ( - terminal: TerminalOutputTarget, - data: string, - beforeWrite?: TerminalOutputBeforeWrite -) => void - -const foregroundHoldByTerminal = new Map() - -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) - } -} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 2ba2f9308c8..9b4f922d4a6 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -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() - }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 0eccfe7d9f6..6232903b535 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -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 { 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) } diff --git a/src/renderer/src/store/slices/repos-update-serialization.test.ts b/src/renderer/src/store/slices/repos-update-serialization.test.ts index 961eaa51823..811d4c81f07 100644 --- a/src/renderer/src/store/slices/repos-update-serialization.test.ts +++ b/src/renderer/src/store/slices/repos-update-serialization.test.ts @@ -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 }) diff --git a/src/shared/repo-icon.test.ts b/src/shared/repo-icon.test.ts index 39f499d9e4e..8f312104fff 100644 --- a/src/shared/repo-icon.test.ts +++ b/src/shared/repo-icon.test.ts @@ -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() }) }) diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts index 1add7c82f64..84d865dc8f3 100644 --- a/src/shared/repo-icon.ts +++ b/src/shared/repo-icon.ts @@ -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