diff --git a/config/scripts/git-diff-blob-concurrency-benchmark.mjs b/config/scripts/git-diff-blob-concurrency-benchmark.mjs index 602efd00e1b..8b7e9503e4a 100644 --- a/config/scripts/git-diff-blob-concurrency-benchmark.mjs +++ b/config/scripts/git-diff-blob-concurrency-benchmark.mjs @@ -87,7 +87,7 @@ const parent = `${head}~1` const CANDIDATES = [ 'src/main/git/status.ts', 'src/shared/agent-hook-listener.ts', - 'src/renderer/src/components/TaskPage.tsx' + 'src/renderer/src/components/task-page/TaskPage.tsx' ] const files = [] diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts index 2491591572f..25902305f41 100644 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -2,10 +2,18 @@ import { readFileSync } from 'node:fs' const SOURCE_FILES = [ './terminal-webview-html.ts', - ...Array.from( - { length: 10 }, - (_, index) => `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts` - ) + './terminal-webview-html/document-shell.ts', + './terminal-webview-html/runtime-state-and-text-scaling.ts', + './terminal-webview-html/fit-scale-and-write-queue.ts', + './terminal-webview-html/terminal-init-and-write.ts', + './terminal-webview-html/host-message-router.ts', + './terminal-webview-html/selection-state-and-eviction.ts', + './terminal-webview-html/term-observers-and-mode-mirroring.ts', + './terminal-webview-html/mouse-report-and-scroll-routing.ts', + './terminal-webview-html/smooth-scroll-and-cell-geometry.ts', + './terminal-webview-html/selection-overlay.ts', + './terminal-webview-html/surface-touch-gestures.ts', + './terminal-webview-html/message-bridge-and-document-close.ts' ] as const /** Reads the TypeScript source that assembles the in-WebView document. */ diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index b55421bbe4d..40b7a2db22c 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,28 +1,34 @@ -import { TERMINAL_HTML_FRAGMENT_01 } from './terminal-webview-html/fragment-01' -import { TERMINAL_HTML_FRAGMENT_02 } from './terminal-webview-html/fragment-02' -import { TERMINAL_HTML_FRAGMENT_03 } from './terminal-webview-html/fragment-03' -import { TERMINAL_HTML_FRAGMENT_04 } from './terminal-webview-html/fragment-04' -import { TERMINAL_HTML_FRAGMENT_05 } from './terminal-webview-html/fragment-05' -import { TERMINAL_HTML_FRAGMENT_06 } from './terminal-webview-html/fragment-06' -import { TERMINAL_HTML_FRAGMENT_07 } from './terminal-webview-html/fragment-07' -import { TERMINAL_HTML_FRAGMENT_08 } from './terminal-webview-html/fragment-08' -import { TERMINAL_HTML_FRAGMENT_09 } from './terminal-webview-html/fragment-09' -import { TERMINAL_HTML_FRAGMENT_10 } from './terminal-webview-html/fragment-10' +import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' +import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' +import { TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE } from './terminal-webview-html/fit-scale-and-write-queue' +import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' +import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' +import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' +import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring' +import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing' +import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry' +import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay' +import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures' +import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close' export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' -// Why: keep the document source stable while each script/style concern remains independently reviewable. +// Why: keep the document source stable while each script/style concern remains independently +// reviewable. Boundaries can only fall where the emitted document allows, so a few modules +// carry a second concern noted at the top of the file. export const XTERM_HTML = [ - TERMINAL_HTML_FRAGMENT_01, - TERMINAL_HTML_FRAGMENT_02, - TERMINAL_HTML_FRAGMENT_03, - TERMINAL_HTML_FRAGMENT_04, - TERMINAL_HTML_FRAGMENT_05, - TERMINAL_HTML_FRAGMENT_06, - TERMINAL_HTML_FRAGMENT_07, - TERMINAL_HTML_FRAGMENT_08, - TERMINAL_HTML_FRAGMENT_09, - TERMINAL_HTML_FRAGMENT_10 + TERMINAL_HTML_DOCUMENT_SHELL, + TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, + TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE, + TERMINAL_HTML_INIT_AND_WRITE, + TERMINAL_HTML_HOST_MESSAGE_ROUTER, + TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, + TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING, + TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING, + TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY, + TERMINAL_HTML_SELECTION_OVERLAY, + TERMINAL_HTML_SURFACE_TOUCH_GESTURES, + TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE ].join('') export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/terminal/terminal-webview-html/fragment-01.ts b/mobile/src/terminal/terminal-webview-html/document-shell.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-01.ts rename to mobile/src/terminal/terminal-webview-html/document-shell.ts index 77dba06232a..d6e733cdd77 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-01.ts +++ b/mobile/src/terminal/terminal-webview-html/document-shell.ts @@ -1,7 +1,7 @@ import { colors } from '../../theme/mobile-theme' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated' -export const TERMINAL_HTML_FRAGMENT_01 = ` +export const TERMINAL_HTML_DOCUMENT_SHELL = ` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-03.ts b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-03.ts rename to mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts index 075ee819ec6..074185d43a5 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-03.ts +++ b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts @@ -1,6 +1,7 @@ import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' -export const TERMINAL_HTML_FRAGMENT_03 = `${TERMINAL_WEBVIEW_THEME_JS} +// Also carries the DECSET mouse-mode scanner: emitted-document order pins it between these two concerns. +export const TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE = `${TERMINAL_WEBVIEW_THEME_JS} function getCellHeight() { if (!term || !term._core) return 15; diff --git a/mobile/src/terminal/terminal-webview-html/fragment-10.ts b/mobile/src/terminal/terminal-webview-html/fragment-10.ts deleted file mode 100644 index d461f4a2039..00000000000 --- a/mobile/src/terminal/terminal-webview-html/fragment-10.ts +++ /dev/null @@ -1,132 +0,0 @@ -export const TERMINAL_HTML_FRAGMENT_10 = ` updateTransform(); - } - - var deltaY = ts.lastY - y; - ts.lastTime = now; - if (shouldRouteScrollToTerminalInput()) { - updateTouchVelocity(deltaY, dt); - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, x, y); - } - } else { - if (enqueueNormalBufferScrollDelta(deltaY)) { - updateTouchVelocity(deltaY, dt); - } else { - ts.velY = 0; - } - } - ts.lastX = x; - ts.lastY = y; - } - }, { capture: true, passive: false }); - - targetSurface.addEventListener('touchend', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false; - // Why: a finished pinch snaps to the nearest preset and becomes the new - // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way - // to set the text size. The CSS pinch zoom (userScale) is reset; the real - // size change reflows columns and RN persists + resizes the PTY to match. - var target = snapToTextScalePreset(currentTextScale * userScale); - var changed = target !== currentTextScale; - userScale = 1; - panX = 0; panY = 0; - applyTextScale(target); - updateTransform(); - notify({ type: 'font-scale-changed', fontScale: target }); - if (changed) notify({ type: 'haptic', kind: 'selection' }); - if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - return; - } - - if (e.touches.length === 0) { - var vel = ts.velY; - var FRICTION = 0.972; - var MIN_VEL = 0.012; - function momentumStep() { - vel *= FRICTION; - if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var delta = vel * 16; - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += delta; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); - } - } else { - if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null; - return; - } - } - ts.momentumId = requestAnimationFrame(momentumStep); - } - if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); - } - } - }, { capture: true, passive: true }); - } - - attachSurfaceEventHandlers(surface); - - function handleIncomingMessage(e) { - var msg; - try { - msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; - } catch (ex) { - return; - } - try { - handleMsg(msg); - } catch(ex) { - reportEngineError( - msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', - ex, - msg && msg.type === 'init' && !everReady - ); - } - } - - window.addEventListener('message', handleIncomingMessage); - - document.addEventListener('message', handleIncomingMessage); - - window.addEventListener('resize', function() { - // Why: viewport changed (keyboard open/close, orientation, RN container - // size update). Re-fit so the scale matches the new vpWidth — without - // this, opening the keyboard leaves the terminal at the old scale even - // though there's now less vertical room and the fit ratio may differ. - applyFitScale('window-resize'); - adjustRowsForViewport(); - repositionOverlay(); - clampPan(); - updateTransform(); - }); - - if (window.Terminal) { - notify({ type: 'web-ready' }); - } else { - reportEngineError('terminal engine missing', 'xterm failed to load', true); - } -})(); - - -` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-05.ts b/mobile/src/terminal/terminal-webview-html/host-message-router.ts similarity index 77% rename from mobile/src/terminal/terminal-webview-html/fragment-05.ts rename to mobile/src/terminal/terminal-webview-html/host-message-router.ts index c8852c50d78..29cba25df36 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-05.ts +++ b/mobile/src/terminal/terminal-webview-html/host-message-router.ts @@ -1,6 +1,6 @@ import { TERMINAL_REFLOW_JS } from '../terminal-webview-reflow-injected' -export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS} +export const TERMINAL_HTML_HOST_MESSAGE_ROUTER = ` ${TERMINAL_REFLOW_JS} function notify(msg) { if (window.ReactNativeWebView) { @@ -186,45 +186,4 @@ export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS} } } - // ============================================================ - // SELECTION MODE (long-press → handles → Copy) - // ============================================================ - var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; - var LONG_PRESS_MS = 500; - var LONG_PRESS_SLOP = 10; - // Why: a tap that opens a link/path must survive small finger jitter. The - // long-press slop (10px) only cancels the press-to-select timer; reusing it - // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale - // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap - // window so deliberate scrolls/pans still don't fire a tap. - var TAP_SLOP = 24; - var TAP_MAX_MS = 700; - var EDGE_SCROLL_PX = 40; - var EDGE_SCROLL_INTERVAL = 60; - - var selectionOverlay = document.getElementById('selection-overlay'); - var handleStart = document.getElementById('sel-handle-start'); - var handleEnd = document.getElementById('sel-handle-end'); - var selMenu = document.getElementById('sel-menu'); - var btnCopy = document.getElementById('sel-menu-copy'); - var btnSelAll = document.getElementById('sel-menu-all'); - - // mode: 'navigate' | 'select' - var selMode = 'navigate'; - var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } - var longPressTimer = null; - var longPressOrigin = null; // {x,y, identifier} - // Why: tap detection is tracked separately from the long-press timer so a - // small jitter that cancels the press-to-select timer does not also cancel - // the tap (which opens links/paths). {x,y,t,identifier} or null once the - // gesture is disqualified as a tap (moved too far or held too long). - var tapCandidate = null; - var edgeScrollTimer = null; - var edgeScrollDir = 0; - var edgeScrollClientX = 0; - var edgeScrollClientY = 0; - - // Eviction watchdog: linesEverWritten counts onLineFeed since last init. - // Once buffer is full, every onLineFeed evicts the top row in xterm and - // we mirror that by decrementing stored absolute rows. ` diff --git a/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts new file mode 100644 index 00000000000..49cd3b4d84f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts @@ -0,0 +1,43 @@ +export const TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE = ` function handleIncomingMessage(e) { + var msg; + try { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; + } catch (ex) { + return; + } + try { + handleMsg(msg); + } catch(ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + ex, + msg && msg.type === 'init' && !everReady + ); + } + } + + window.addEventListener('message', handleIncomingMessage); + + document.addEventListener('message', handleIncomingMessage); + + window.addEventListener('resize', function() { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize'); + adjustRowsForViewport(); + repositionOverlay(); + clampPan(); + updateTransform(); + }); + + if (window.Terminal) { + notify({ type: 'web-ready' }); + } else { + reportEngineError('terminal engine missing', 'xterm failed to load', true); + } +})(); + + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-06.ts b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts similarity index 56% rename from mobile/src/terminal/terminal-webview-html/fragment-06.ts rename to mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts index 1a03331df34..3b7e7f24cf3 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-06.ts +++ b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts @@ -1,100 +1,6 @@ -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected' -export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0; - - function resetEvictionCounter() { linesEverWritten = 0; } - - function isBufferFull() { - if (!term) return false; - return linesEverWritten >= 5000 + (term.rows || 0); - } - - function checkEviction() { - if (selMode !== 'select' || !sel) return; - var oldest = Math.min(sel.anchor.row, sel.focus.row); - if (oldest < 0) { - notify({ type: 'selection-evicted' }); - cancelSelect(); - } - } - - function logFeedAndEvict() { - linesEverWritten++; - if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; - if (selMode === 'select' && sel && isBufferFull()) { - sel.anchor.row -= 1; - sel.focus.row -= 1; - checkEviction(); - repositionOverlay(); - } - } - - function emitModesIfChanged() { - if (!term) return; - var bp = !!(term.modes && term.modes.bracketedPasteMode); - var alt = false; - var mouseTrackingMode = getMouseTrackingMode(); - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - if ( - bp !== lastEmittedModes.bracketedPasteMode || - alt !== lastEmittedModes.altScreen || - mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || - sgrMouseMode !== lastEmittedModes.sgrMouseMode || - sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode - ) { - lastEmittedModes = { - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }; - notify({ - type: 'modes', - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }); - } - } - var lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - - ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} - - function attachTermObservers() { - if (!term) return; - disposeTermObservers(); - try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} - try { - termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); - } catch (e) {} - // Why: emit modes on every parsed write so RN's mirror stays current - // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. - try { - if (term.onWriteParsed) { - termObserverDisposables.push(term.onWriteParsed(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - })); - } - } catch (e) {} - // Initial emit once buffer settles. - afterWritesDrained(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - }); - } - - function viewportToCell(clientX, clientY) { +export const TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING = ` function viewportToCell(clientX, clientY) { if (!term) return null; var cellW = getCellWidth(); var cellH = getCellHeight(); @@ -201,4 +107,82 @@ export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0; } if (sgrMouseMode) { // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; + if (mouseTrackingMode === 'x10') return sgrPress; + return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + if (col > 126 || row > 126) return ''; + var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); + if (mouseTrackingMode === 'x10') return press; + return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isClickMouseTrackingMode(mode) { + return mode !== 'none'; + } + + function isWheelMouseTrackingMode(mode) { + return mode !== 'none' && mode !== 'x10'; + } + + function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); + } + + function buildMouseWheelScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var sequence = buildMouseWheelSequence(lines, clientX, clientY); + if (!sequence) return ''; + return repeatSequence(sequence, count); + } + + function buildTuiScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var mouseTrackingMode = getMouseTrackingMode(); + var sequence = ''; + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY); + } + if (!sequence) sequence = buildArrowScrollSequence(lines); + return repeatSequence(sequence, count); + } + + function routeScrollLines(lines, clientX, clientY) { + if (!term || lines === 0) return; + var mouseTrackingMode = getMouseTrackingMode(); + var alternateBufferActive = isAlternateBufferActive(); + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }); + return; + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); + if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); + return; + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + var input = buildTuiScrollInput(lines, clientX, clientY); + if (input) notify({ type: 'terminal-input', bytes: input }); + return; + } + term.scrollLines(lines); + } + ` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-02.ts b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts similarity index 97% rename from mobile/src/terminal/terminal-webview-html/fragment-02.ts rename to mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts index 7d25b0fb6b2..44ce1d39042 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-02.ts +++ b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts @@ -3,7 +3,8 @@ import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injec import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' import { DEFAULT_TERMINAL_THEME } from './theme' -export const TERMINAL_HTML_FRAGMENT_02 = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; +// Also carries the scroll-indicator painter, which reads the scale state declared here. +export const TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; var term = null; ${TERMINAL_QUERY_REPLY_JS} ${TERMINAL_SURFACE_SWAP_JS} var scrollIndicator = document.getElementById('scroll-indicator'); diff --git a/mobile/src/terminal/terminal-webview-html/fragment-08.ts b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts similarity index 97% rename from mobile/src/terminal/terminal-webview-html/fragment-08.ts rename to mobile/src/terminal/terminal-webview-html/selection-overlay.ts index 29f0d929918..335407af51a 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-08.ts +++ b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts @@ -1,7 +1,8 @@ import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected' import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap' -export const TERMINAL_HTML_FRAGMENT_08 = ` ${TERMINAL_PATH_TAP_JS} +// Opens with the path/url tap matchers: they land at this point in the emitted document. +export const TERMINAL_HTML_SELECTION_OVERLAY = ` ${TERMINAL_PATH_TAP_JS} ${URL_TAP_WEBVIEW_JS} function seedWordSelection(col, absRow) { diff --git a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts new file mode 100644 index 00000000000..48c05b6ad85 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts @@ -0,0 +1,71 @@ +export const TERMINAL_HTML_SELECTION_STATE_AND_EVICTION = ` // ============================================================ + // SELECTION MODE (long-press → handles → Copy) + // ============================================================ + var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; + var LONG_PRESS_MS = 500; + var LONG_PRESS_SLOP = 10; + // Why: a tap that opens a link/path must survive small finger jitter. The + // long-press slop (10px) only cancels the press-to-select timer; reusing it + // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale + // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap + // window so deliberate scrolls/pans still don't fire a tap. + var TAP_SLOP = 24; + var TAP_MAX_MS = 700; + var EDGE_SCROLL_PX = 40; + var EDGE_SCROLL_INTERVAL = 60; + + var selectionOverlay = document.getElementById('selection-overlay'); + var handleStart = document.getElementById('sel-handle-start'); + var handleEnd = document.getElementById('sel-handle-end'); + var selMenu = document.getElementById('sel-menu'); + var btnCopy = document.getElementById('sel-menu-copy'); + var btnSelAll = document.getElementById('sel-menu-all'); + + // mode: 'navigate' | 'select' + var selMode = 'navigate'; + var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } + var longPressTimer = null; + var longPressOrigin = null; // {x,y, identifier} + // Why: tap detection is tracked separately from the long-press timer so a + // small jitter that cancels the press-to-select timer does not also cancel + // the tap (which opens links/paths). {x,y,t,identifier} or null once the + // gesture is disqualified as a tap (moved too far or held too long). + var tapCandidate = null; + var edgeScrollTimer = null; + var edgeScrollDir = 0; + var edgeScrollClientX = 0; + var edgeScrollClientY = 0; + + // Eviction watchdog: linesEverWritten counts onLineFeed since last init. + // Once buffer is full, every onLineFeed evicts the top row in xterm and + // we mirror that by decrementing stored absolute rows. + var linesEverWritten = 0; + + function resetEvictionCounter() { linesEverWritten = 0; } + + function isBufferFull() { + if (!term) return false; + return linesEverWritten >= 5000 + (term.rows || 0); + } + + function checkEviction() { + if (selMode !== 'select' || !sel) return; + var oldest = Math.min(sel.anchor.row, sel.focus.row); + if (oldest < 0) { + notify({ type: 'selection-evicted' }); + cancelSelect(); + } + } + + function logFeedAndEvict() { + linesEverWritten++; + if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; + if (selMode === 'select' && sel && isBufferFull()) { + sel.anchor.row -= 1; + sel.focus.row -= 1; + checkEviction(); + repositionOverlay(); + } + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-07.ts b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts similarity index 54% rename from mobile/src/terminal/terminal-webview-html/fragment-07.ts rename to mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts index 22c3372b26f..de9db152fbf 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-07.ts +++ b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts @@ -1,82 +1,4 @@ -export const TERMINAL_HTML_FRAGMENT_07 = ` var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; - if (mouseTrackingMode === 'x10') return sgrPress; - return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; - } - // Why: non-SGR click coordinates use printable ASCII bytes on the mobile - // bridge; unsafe wide-terminal cells must not turn into corrupted input. - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - if (col > 126 || row > 126) return ''; - var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); - if (mouseTrackingMode === 'x10') return press; - return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isClickMouseTrackingMode(mode) { - return mode !== 'none'; - } - - function isWheelMouseTrackingMode(mode) { - return mode !== 'none' && mode !== 'x10'; - } - - function shouldRouteScrollToTerminalInput() { - return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); - } - - function buildMouseWheelScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var sequence = buildMouseWheelSequence(lines, clientX, clientY); - if (!sequence) return ''; - return repeatSequence(sequence, count); - } - - function buildTuiScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var mouseTrackingMode = getMouseTrackingMode(); - var sequence = ''; - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - sequence = buildMouseWheelSequence(lines, clientX, clientY); - } - if (!sequence) sequence = buildArrowScrollSequence(lines); - return repeatSequence(sequence, count); - } - - function routeScrollLines(lines, clientX, clientY) { - if (!term || lines === 0) return; - var mouseTrackingMode = getMouseTrackingMode(); - var alternateBufferActive = isAlternateBufferActive(); - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - // Why: xterm sends wheel events to mouse-aware TUIs before considering - // scrollback, even if the app stays on the normal buffer. - var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); - if (mouseInput) { - notify({ type: 'terminal-input', bytes: mouseInput }); - return; - } - // Why: default mouse encoding can be unrepresentable in our ASCII-safe - // RPC path on wide terminals. Send bounded arrows instead of local - // scrollback/no-op while a mouse-aware app owns scroll gestures. - var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); - if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); - return; - } - if (alternateBufferActive) { - // Why: alternate-screen TUIs own their scroll state and xterm has no - // scrollback there, so mobile scroll gestures must become terminal input. - var input = buildTuiScrollInput(lines, clientX, clientY); - if (input) notify({ type: 'terminal-input', bytes: input }); - return; - } - term.scrollLines(lines); - } - - function clampNormalScrollLines(lines) { +export const TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY = ` function clampNormalScrollLines(lines) { if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; var buffer = term.buffer.active; if (lines > 0) { diff --git a/mobile/src/terminal/terminal-webview-html/fragment-09.ts b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts similarity index 62% rename from mobile/src/terminal/terminal-webview-html/fragment-09.ts rename to mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts index 06ffa927c65..515d7fd9d26 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-09.ts +++ b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts @@ -2,7 +2,8 @@ import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injec import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected' import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected' -export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS} +// Also wires the selection menu's Copy/Select All buttons, which sit here in the emitted document. +export const TERMINAL_HTML_SURFACE_TOUCH_GESTURES = ` ${TERMINAL_TAP_DISPATCH_JS} // External mouse / trackpad scroll: see // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). @@ -135,4 +136,93 @@ export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS} if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { panX += x - ts.lastX; clampPan(); + updateTransform(); + } + + var deltaY = ts.lastY - y; + ts.lastTime = now; + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt); + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += deltaY; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, x, y); + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt); + } else { + ts.velY = 0; + } + } + ts.lastX = x; + ts.lastY = y; + } + }, { capture: true, passive: false }); + + targetSurface.addEventListener('touchend', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + + if (ts.isPinching && e.touches.length < 2) { + ts.isPinching = false; + // Why: a finished pinch snaps to the nearest preset and becomes the new + // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way + // to set the text size. The CSS pinch zoom (userScale) is reset; the real + // size change reflows columns and RN persists + resizes the PTY to match. + var target = snapToTextScalePreset(currentTextScale * userScale); + var changed = target !== currentTextScale; + userScale = 1; + panX = 0; panY = 0; + applyTextScale(target); + updateTransform(); + notify({ type: 'font-scale-changed', fontScale: target }); + if (changed) notify({ type: 'haptic', kind: 'selection' }); + if (e.touches.length === 1) { + ts.lastX = e.touches[0].clientX; + ts.lastY = e.touches[0].clientY; + ts.lastTime = Date.now(); + ts.velY = 0; + ts.accumDelta = 0; + } + return; + } + + if (e.touches.length === 0) { + var vel = ts.velY; + var FRICTION = 0.972; + var MIN_VEL = 0.012; + function momentumStep() { + vel *= FRICTION; + if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } + var delta = vel * 16; + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += delta; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, ts.lastX, ts.lastY); + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null; + return; + } + } + ts.momentumId = requestAnimationFrame(momentumStep); + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep); + } + } + }, { capture: true, passive: true }); + } + + attachSurfaceEventHandlers(surface); + ` diff --git a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts new file mode 100644 index 00000000000..b69547affb5 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts @@ -0,0 +1,67 @@ +import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' + +export const TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING = ` function emitModesIfChanged() { + if (!term) return; + var bp = !!(term.modes && term.modes.bracketedPasteMode); + var alt = false; + var mouseTrackingMode = getMouseTrackingMode(); + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + if ( + bp !== lastEmittedModes.bracketedPasteMode || + alt !== lastEmittedModes.altScreen || + mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || + sgrMouseMode !== lastEmittedModes.sgrMouseMode || + sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode + ) { + lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }; + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }); + } + } + var lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; + + ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} + + function attachTermObservers() { + if (!term) return; + disposeTermObservers(); + try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} + try { + termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); + } catch (e) {} + // Why: emit modes on every parsed write so RN's mirror stays current + // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. + try { + if (term.onWriteParsed) { + termObserverDisposables.push(term.onWriteParsed(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + })); + } + } catch (e) {} + // Initial emit once buffer settles. + afterWritesDrained(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + }); + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-04.ts b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-04.ts rename to mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts index c2c9863be79..90dba7cbdbc 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-04.ts +++ b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts @@ -1,7 +1,7 @@ import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected' import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme' -export const TERMINAL_HTML_FRAGMENT_04 = `${TERMINAL_WEBGL_RECOVERY_JS} +export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS} function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 962cfddaa4d..9218e5d6ad9 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' // The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in // TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file. @@ -8,16 +9,7 @@ const source = readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + - Array.from({ length: 10 }, (_, index) => - readFileSync( - new URL( - `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`, - import.meta.url - ), - 'utf8' - ) - ).join('') + readTerminalWebViewHtmlSource() const sessionSource = readFileSync( new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), 'utf8' diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 0deee79f670..d775237ddcc 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -11,8 +11,8 @@ const terminalHtmlModuleSource = readFileSync( new URL('./terminal-webview-html.ts', import.meta.url), 'utf8' ) -const terminalHtmlFragmentSource = readFileSync( - new URL('./terminal-webview-html/fragment-01.ts', import.meta.url), +const terminalHtmlDocumentShellSource = readFileSync( + new URL('./terminal-webview-html/document-shell.ts', import.meta.url), 'utf8' ) // Read behavior from the assembled document; the module source only contains @@ -150,7 +150,7 @@ describe('TerminalWebView text zoom', () => { }) it('loads Unicode 11 before replaying mobile terminal bytes', () => { - expect(terminalHtmlFragmentSource).toContain('XTERM_ENGINE_JS') + expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS') expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') const open = terminalHtmlSource.indexOf('term.open(surface)') const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts index d17c3446db1..3849f39a7c9 100644 --- a/src/cli/handler-group-manifest.test.ts +++ b/src/cli/handler-group-manifest.test.ts @@ -1,5 +1,5 @@ import { readdirSync } from 'node:fs' -import { join } from 'node:path' +import { join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch' @@ -9,6 +9,32 @@ import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' // group. These tests are the only thing standing between that trust and a // silently unreachable command, so they load every group for real. +// Why: __dirname works under both Vitest and the CommonJS tsc emit that +// build:cli type-checks this file against; import.meta.dirname does not. +const HANDLERS_DIR = join(__dirname, 'handlers') + +// Why: both the plural records and the single-command `*_HANDLER` ones that +// nested modules export get spread into a group, so both must route. +const HANDLER_RECORD_EXPORT = /_HANDLERS?$/ + +function listHandlerModules(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return listHandlerModules(path) + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : [] + }) +} + +function isHandlerRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + Object.values(value).every((entry) => typeof entry === 'function') + ) +} + describe('handler group manifest', () => { it('lists a loadable group for every entry', async () => { for (const group of HANDLER_GROUPS) { @@ -54,25 +80,35 @@ describe('handler group manifest', () => { }) // Why: dropping a group from the manifest silently unregisters its commands — - // scan the directory so a new or forgotten handler file fails here, not in prod. - it('registers every handler module that exports a handler group', async () => { - // Why: __dirname works under both Vitest and the CommonJS tsc emit that - // build:cli type-checks this file against; import.meta.dirname does not. - const dir = join(__dirname, 'handlers') - const modules = readdirSync(dir).filter( - (file) => file.endsWith('.ts') && !file.endsWith('.test.ts') - ) - const registered = new Set(HANDLER_GROUPS.map((group) => group.name)) - const missing: string[] = [] - for (const file of modules) { - const name = file.slice(0, -'.ts'.length) - const exports: Record = await import(join(dir, file)) - const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS')) - if (exportsGroup && !registered.has(name)) { - missing.push(name) + // walk the tree so a new or forgotten handler file fails here, not in prod. + // Nested modules are spread into a parent group rather than registered under + // their own name, so routability, not file name, is the invariant that holds. + it('routes every command exported by a handler module', async () => { + const routes = buildHandlerRoutes(HANDLER_GROUPS) + const unroutable: string[] = [] + for (const file of listHandlerModules(HANDLERS_DIR)) { + const exports: Record = await import(file) + for (const [name, value] of Object.entries(exports)) { + if (!HANDLER_RECORD_EXPORT.test(name) || !isHandlerRecord(value)) { + continue + } + for (const key of Object.keys(value)) { + if (!routes.has(key)) { + unroutable.push(`${relative(HANDLERS_DIR, file)} ${name}: ${key}`) + } + } } } - expect(missing).toEqual([]) + expect(unroutable).toEqual([]) + }) + + it('finds the modules it is meant to guard', () => { + // Why: a walk that missed the tree would make the guard above vacuously pass. + const modules = listHandlerModules(HANDLERS_DIR) + expect(modules.length).toBeGreaterThanOrEqual(40) + expect( + modules.filter((file) => relative(HANDLERS_DIR, file).includes(sep)).length + ).toBeGreaterThanOrEqual(7) }) }) diff --git a/src/main/ipc/filesystem-path-containment.ts b/src/main/ipc/filesystem-path-containment.ts index b236c90a94e..32d1c00f0e1 100644 --- a/src/main/ipc/filesystem-path-containment.ts +++ b/src/main/ipc/filesystem-path-containment.ts @@ -71,3 +71,11 @@ export function validateGitRelativeFilePath(worktreePath: string, filePath: stri return normalizedRelativePath } + +export function validateFullGitObjectId(value: string, label: string): string { + const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + if (!pattern.test(value)) { + throw new Error(`${label} must be a full git object id`) + } + return value +} diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts index 51a90c36213..6b73df909da 100644 --- a/src/main/ipc/filesystem/filesystem-download-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -12,10 +12,16 @@ import { DOWNLOAD_SESSION_TTL_MS, inspectDownloadDestination, promoteDownloadedFile, - validateRequiredString, createSiblingTransferPath, type DownloadFileResult -} from './filesystem-file-helpers' +} from './filesystem-download-promotion' + +function validateRequiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`) + } + return value +} export function registerFilesystemDownloadHandlers(context: FilesystemHandlerContext): void { const { downloadSessions, closeDownloadSession, cleanupDownloadSessionsForSender } = context diff --git a/src/main/ipc/filesystem/filesystem-download-promotion.ts b/src/main/ipc/filesystem/filesystem-download-promotion.ts new file mode 100644 index 00000000000..9fbc8acddd2 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-download-promotion.ts @@ -0,0 +1,79 @@ +import { randomUUID } from 'node:crypto' +import { rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { isENOENT } from '../filesystem-path-containment' + +export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { + return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') +} + +export function createSiblingTransferPath(destinationPath: string, suffix: string): string { + // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. + return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) +} + +export async function cleanupLocalTransferPath(filePath: string | null): Promise { + if (!filePath) { + return + } + await rm(filePath, { force: true }).catch(() => {}) +} + +export async function inspectDownloadDestination( + destinationPath: string +): Promise<{ existed: boolean }> { + try { + const destinationStat = await stat(destinationPath) + if (destinationStat.isDirectory()) { + throw new Error('Cannot download to a directory') + } + return { existed: true } + } catch (error) { + if (isENOENT(error)) { + return { existed: false } + } + throw error + } +} + +export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { + try { + await stat(destinationPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + throw new Error('Destination file appeared before download completed') +} + +export async function promoteDownloadedFile( + tempPath: string, + destinationPath: string, + destinationExisted: boolean +): Promise { + if (!destinationExisted) { + await assertDestinationStillUnclaimed(destinationPath) + await rename(tempPath, destinationPath) + return + } + + const backupPath = createSiblingTransferPath(destinationPath, 'backup') + let backupCreated = false + try { + await rename(destinationPath, backupPath) + backupCreated = true + await rename(tempPath, destinationPath) + await cleanupLocalTransferPath(backupPath) + } catch (error) { + if (backupCreated) { + await rename(backupPath, destinationPath).catch(() => {}) + } + throw error + } +} + +export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } + +export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts new file mode 100644 index 00000000000..b140532c372 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -0,0 +1,85 @@ +import { open } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' + +// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. +export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +export const BINARY_PROBE_BYTES = 8192 +// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + +/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, BINARY_PROBE_BYTES) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +export async function isBinaryFilePrefix(filePath: string): Promise { + const handle: FileHandle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + +export function isDirectoryEntry(entry: { + isDirectory(): boolean + isSymbolicLink(): boolean +}): boolean { + // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. + if (entry.isSymbolicLink()) { + return false + } + return entry.isDirectory() +} diff --git a/src/main/ipc/filesystem/filesystem-file-helpers.ts b/src/main/ipc/filesystem/filesystem-file-helpers.ts deleted file mode 100644 index 53e2eb3ec30..00000000000 --- a/src/main/ipc/filesystem/filesystem-file-helpers.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { open, rename, rm, stat } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' -import { isENOENT } from '../filesystem-path-containment' - -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB -export const BINARY_PROBE_BYTES = 8192 -export const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ -// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. -export const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 -// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB -export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} - -export async function readLocalLogSnapshot(filePath: string): Promise<{ - content: string - isBinary: boolean - fileIdentity?: string -}> { - const handle = await open(filePath, 'r') - try { - const stats = await handle.stat() - if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { - content: buffer.toString('utf8'), - isBinary: false, - fileIdentity: localLogFileIdentity(stats) - } - } finally { - await handle.close() - } -} - -export function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { - return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') -} - -export function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -export async function cleanupLocalTransferPath(filePath: string | null): Promise { - if (!filePath) { - return - } - await rm(filePath, { force: true }).catch(() => {}) -} - -export async function inspectDownloadDestination( - destinationPath: string -): Promise<{ existed: boolean }> { - try { - const destinationStat = await stat(destinationPath) - if (destinationStat.isDirectory()) { - throw new Error('Cannot download to a directory') - } - return { existed: true } - } catch (error) { - if (isENOENT(error)) { - return { existed: false } - } - throw error - } -} - -export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination file appeared before download completed') -} - -export async function promoteDownloadedFile( - tempPath: string, - destinationPath: string, - destinationExisted: boolean -): Promise { - if (!destinationExisted) { - await assertDestinationStillUnclaimed(destinationPath) - await rename(tempPath, destinationPath) - return - } - - const backupPath = createSiblingTransferPath(destinationPath, 'backup') - let backupCreated = false - try { - await rename(destinationPath, backupPath) - backupCreated = true - await rename(tempPath, destinationPath) - await cleanupLocalTransferPath(backupPath) - } catch (error) { - if (backupCreated) { - await rename(backupPath, destinationPath).catch(() => {}) - } - throw error - } -} - -/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ -export function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, BINARY_PROBE_BYTES) - for (let i = 0; i < len; i++) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -export async function isBinaryFilePrefix(filePath: string): Promise { - const handle: FileHandle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - -export function isDirectoryEntry(entry: { - isDirectory(): boolean - isSymbolicLink(): boolean -}): boolean { - // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. - if (entry.isSymbolicLink()) { - return false - } - return entry.isDirectory() -} - -export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } - -export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts index 4e55a39f76a..ae029dc2f41 100644 --- a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -23,7 +23,7 @@ import { getLocalAgentRuntimeTarget, getLocalTextGenerationTarget, getRepoForSourceControlAi -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' export function registerFilesystemGitCommitGenerationHandlers( context: FilesystemHandlerContext diff --git a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts index ff706fcb078..8434e02264b 100644 --- a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts @@ -7,8 +7,10 @@ import { } from '../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' -import { validateGitRelativeFilePath } from '../filesystem-path-containment' -import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import { + validateFullGitObjectId, + validateGitRelativeFilePath +} from '../filesystem-path-containment' import type { FilesystemHandlerContext } from './filesystem-handler-context' export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerContext): void { diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts index 0933c08587e..8de07853057 100644 --- a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -12,7 +12,7 @@ import { getSshGitProvider } from '../../providers/ssh-git-dispatch' import { resolveModelDiscoveryLocalPath, getLocalAgentRuntimeTarget -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import type { FilesystemHandlerContext } from './filesystem-handler-context' diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts index b779fb44429..1c16a119b3b 100644 --- a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -27,7 +27,7 @@ import { getLocalAgentRuntimeTarget, getLocalTextGenerationTarget, getRepoForSourceControlAi -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' export function registerFilesystemGitPullRequestGenerationHandlers( context: FilesystemHandlerContext diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts index cb14d98db60..4ed9b55057e 100644 --- a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -6,7 +6,7 @@ import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import { validateFullGitObjectId } from '../filesystem-path-containment' import type { FilesystemHandlerContext } from './filesystem-handler-context' export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerContext): void { diff --git a/src/main/ipc/filesystem/filesystem-handler-context.ts b/src/main/ipc/filesystem/filesystem-handler-context.ts index 3aeb8320cc3..02488931e1e 100644 --- a/src/main/ipc/filesystem/filesystem-handler-context.ts +++ b/src/main/ipc/filesystem/filesystem-handler-context.ts @@ -3,7 +3,7 @@ import type { FileHandle } from 'node:fs/promises' import type { Store } from '../../persistence' import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment' import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' -import { cleanupLocalTransferPath } from './filesystem-file-helpers' +import { cleanupLocalTransferPath } from './filesystem-download-promotion' export type DownloadSession = { destinationPath: string diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index d1a86f1f748..938370a2816 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -20,7 +20,7 @@ import { MAX_TEXT_FILE_SIZE, PREVIEWABLE_BINARY_MIME_TYPES, readLocalLogSnapshot -} from './filesystem-file-helpers' +} from './filesystem-file-content-inspection' export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { const { store } = context diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index c2ec1531ead..f6dd8d57284 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -27,7 +27,9 @@ import { searchWithGitGrep } from '../filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' import type { FilesystemHandlerContext } from './filesystem-handler-context' -import { QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT } from './filesystem-file-helpers' + +// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. +const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void { const { store, activeTextSearches } = context diff --git a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts similarity index 95% rename from src/main/ipc/filesystem/filesystem-worktree-helpers.ts rename to src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 18dd5c4f1f6..62b86e7361a 100644 --- a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -175,11 +175,3 @@ export function getLocalTextGenerationTarget( ...(env ? { env } : {}) } } - -export function validateFullGitObjectId(value: string, label: string): string { - const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ - if (!pattern.test(value)) { - throw new Error(`${label} must be a full git object id`) - } - return value -} diff --git a/src/main/ipc/filesystem/git-remote/compare-handlers.ts b/src/main/ipc/filesystem/git-remote/compare-handlers.ts index a522ae10af2..a2d7d7a3cd5 100644 --- a/src/main/ipc/filesystem/git-remote/compare-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/compare-handlers.ts @@ -10,7 +10,7 @@ import { } from '../../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' -import { validateFullGitObjectId } from '../filesystem-worktree-helpers' +import { validateFullGitObjectId } from '../../filesystem-path-containment' import type { FilesystemHandlerContext } from '../filesystem-handler-context' import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts index 73fc291f4ca..22a8b4c4178 100644 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -88,8 +88,8 @@ describe('GitHub IPC channel parity', () => { // The preload facade now composes the two GitHub bridge owners; inspect // both owners so the channel census remains tied to the actual invokes. const preloadSource = [ - '../../preload/api/gh-bridge-part-1.ts', - '../../preload/api/gh-bridge-part-2.ts' + '../../preload/api/gh-bridge-pull-requests-and-work-items.ts', + '../../preload/api/gh-bridge-mutations-and-projects.ts' ] .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')) .join('\n') diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts deleted file mode 100644 index f9fe77cbdc5..00000000000 --- a/src/main/runtime/agent-session-claim-key-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' - -export function isVerifiable( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): boolean { - const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) - return !retired || now - retired.retiredAt <= retentionMs -} - -export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { - return { - ...record, - updatedAt: now, - // A conflicted key must remain conflicted after its observing process exits. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - } -} - -export function retire( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): void { - if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { - state.retiredClaimKeys.push({ keyId, retiredAt: now }) - } - state.retiredClaimKeys = state.retiredClaimKeys.filter( - (entry) => now - entry.retiredAt <= retentionMs - ) -} - -export function listOrphanSpawnTokens( - records: readonly AgentSessionRecord[], - observedTokens: readonly string[] -): string[] { - const leases = records.map((record) => record.lease) - return observedTokens.filter( - (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' - ) -} diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index c9d82cbd744..911d185b0fd 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -733,13 +733,25 @@ function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) } +/** + * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered and named deps + * that failed to load. `unverifiable` — the probe never answered, which is evidence about the + * transport, not about the deps. + * + * Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a + * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. + * Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and + * docs/reference/ssh-execution-boundary.md — loss of contact is not evidence. + */ +type RelayNativeDepsProbeStatus = 'ok' | 'blocked' | 'unverifiable' + async function probeRequiredNativeDeps( conn: SshConnection, remoteDir: string, hostPlatform: RemoteHostPlatform, nodePath: string, signal?: AbortSignal -): Promise<{ available: boolean; missing: RelayNativeDepName[] }> { +): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') try { @@ -757,11 +769,14 @@ async function probeRequiredNativeDeps( `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` ) const probe = await execHostCommand(conn, hostPlatform, command, { signal }) - const available = probe.includes('ORCA-NATIVE-DEPS-OK') - return { available, missing: available ? [] : missingNativeDepsFromProbe(probe) } + return probe.includes('ORCA-NATIVE-DEPS-OK') + ? { status: 'ok', missing: [] } + : { status: 'blocked', missing: missingNativeDepsFromProbe(probe) } } catch { signal?.throwIfAborted() - return { available: false, missing: [...RELAY_NATIVE_DEP_NAMES] } + // Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and + // recompiled healthy relays, turning one dropped exec channel into a multi-minute reconnect. + return { status: 'unverifiable', missing: [] } } } @@ -810,7 +825,8 @@ async function repairInstalledNativeDeps( lockResult === 'busy' || lockResult === 'error' ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) : undefined - if (initialProbe.available) { + // Why: only a probe that answered may trigger repair; an unverifiable one launches as-is and the next reconnect re-probes. + if (initialProbe.status !== 'blocked') { // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. if (lockResult !== 'acquired') { return { ownsInstallLock: false, gcClaimToken } @@ -847,7 +863,9 @@ async function repairInstalledNativeDeps( // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) let repairNamespace: RelayInstallNamespace | undefined - if (!probe.available) { + if (probe.status !== 'ok') { + // Why: the locked re-probe can only narrow the repair; when it can't answer, the initial probe's answered evidence still stands. + const resetDeps = probe.status === 'unverifiable' ? initialProbe.missing : probe.missing // Why: only stamp ownership once the locked recheck proves this connection is the one about to write. repairNamespace = await createRelayLaunchNamespace( conn, @@ -863,7 +881,7 @@ async function repairInstalledNativeDeps( hostPlatform, nodePath, signal, - probe.missing, + resetDeps, repairNamespace ) await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts new file mode 100644 index 00000000000..c67270db4c3 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -0,0 +1,226 @@ +// Why: the repair path used to map ANY probe failure to "all deps missing", so one dropped exec +// channel rm -rf'd node-pty on a healthy relay and forced a node-gyp rebuild. Verdicts are +// ok / blocked / unverifiable — see docs/reference/ssh-execution-boundary.md. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' + +const NODE_PTY_RESET = "rm -rf 'node_modules/node-pty'" +const WATCHER_RESET = "rm -rf 'node_modules/@parcel/watcher'" + +describe('native-deps repair probe verdicts', () => { + const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + sftpCapture.paths.length = 0 + for (const key of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[key] + } + for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[key] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + function feed(execResponses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of execResponses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + function execCommands(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => command) + } + + function warnings(): string[] { + return warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + } + + it('launches an intact relay when the health probe never answers', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + { reject: 'SSH channel closed unexpectedly' }, // health probe: unverifiable, not MISSING + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + // Assert the repair-avoidance facts before the launch outcome so a regression names the defect + // rather than the fixture drift that follows from an unexpected repair. + const outcome = await deployAndLaunchRelay(conn).then( + (result) => result, + (err: Error) => err + ) + + const commands = execCommands() + expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( + false + ) + expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) + expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) + expect(commands.some((command) => command.includes('npm install'))).toBe(false) + // Exactly one probe: an unverifiable answer must not fall through to the locked re-probe. + expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(outcome, 'lost contact must not abort the connection').not.toBeInstanceOf(Error) + }) + + it('still resets and repairs when the probe answers without the OK marker', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'MISSING', // answered, no marker line: both deps are genuinely broken + 'MISSING', // re-probe under the repair lock + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(NODE_PTY_RESET) + expect(install).toContain(WATCHER_RESET) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + + it('skips repair entirely when the probe answers OK', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + expect(execCommands().some((command) => command.includes('npm install'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) + + it('keeps the answered reset scope when the locked re-probe cannot answer', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // answered: only the watcher is broken + { reject: 'SSH channel closed unexpectedly' }, // re-probe under the lock: unverifiable + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(WATCHER_RESET) + // The unanswered re-probe must not widen the reset to a dep no probe ever reported broken. + expect(install).not.toContain(NODE_PTY_RESET) + }) +}) diff --git a/src/preload/api/browser-bridge-part-1.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts similarity index 99% rename from src/preload/api/browser-bridge-part-1.ts rename to src/preload/api/browser-bridge-guest-registration-and-downloads.ts index 47883976338..3714a3bca7c 100644 --- a/src/preload/api/browser-bridge-part-1.ts +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -7,7 +7,7 @@ import type { import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' import { browserClientPageRendererRequests } from '../preload-runtime-support' -export const browserApiPart1 = { +export const browserGuestRegistrationAndDownloadsApi = { onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), registerGuest: (args: { diff --git a/src/preload/api/browser-bridge-part-2.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts similarity index 99% rename from src/preload/api/browser-bridge-part-2.ts rename to src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 07788c59d2e..0e959e0af4f 100644 --- a/src/preload/api/browser-bridge-part-2.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' -export const browserApiPart2 = { +export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( callback: (event: { browserPageId: string diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts index 9b72b1658d3..dca222c5843 100644 --- a/src/preload/api/browser-bridge.ts +++ b/src/preload/api/browser-bridge.ts @@ -1,4 +1,7 @@ -import { browserApiPart1 } from './browser-bridge-part-1' -import { browserApiPart2 } from './browser-bridge-part-2' +import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads' +import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions' -export const browserApi = { ...browserApiPart1, ...browserApiPart2 } +export const browserApi = { + ...browserGuestRegistrationAndDownloadsApi, + ...browserPageInteractionAndSessionsApi +} diff --git a/src/preload/api/gh-bridge-part-2.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts similarity index 99% rename from src/preload/api/gh-bridge-part-2.ts rename to src/preload/api/gh-bridge-mutations-and-projects.ts index c7ebc51b57e..3103cb432ec 100644 --- a/src/preload/api/gh-bridge-part-2.ts +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -36,7 +36,7 @@ import type { } from '../../shared/github/project-request-types' import type { AppStarSource } from '../../shared/gh-star-source' -export const ghApiPart2 = { +export const ghMutationsAndProjectsApi = { setPRAutoMerge: (args: { repoPath: string repoId?: string diff --git a/src/preload/api/gh-bridge-part-1.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts similarity index 99% rename from src/preload/api/gh-bridge-part-1.ts rename to src/preload/api/gh-bridge-pull-requests-and-work-items.ts index 11ed8c32f79..a4f3e1d45ef 100644 --- a/src/preload/api/gh-bridge-part-1.ts +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -10,7 +10,7 @@ import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/wo import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' import type { TaskSourceContext } from '../../shared/task-source-context' -export const ghApiPart1 = { +export const ghPullRequestsAndWorkItemsApi = { viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), repoSlug: (args: { repoPath: string; repoId?: string }): Promise => ipcRenderer.invoke('gh:repoSlug', args), diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts index c4bef718721..52c21a966a7 100644 --- a/src/preload/api/gh-bridge.ts +++ b/src/preload/api/gh-bridge.ts @@ -1,4 +1,4 @@ -import { ghApiPart1 } from './gh-bridge-part-1' -import { ghApiPart2 } from './gh-bridge-part-2' +import { ghPullRequestsAndWorkItemsApi } from './gh-bridge-pull-requests-and-work-items' +import { ghMutationsAndProjectsApi } from './gh-bridge-mutations-and-projects' -export const ghApi = { ...ghApiPart1, ...ghApiPart2 } +export const ghApi = { ...ghPullRequestsAndWorkItemsApi, ...ghMutationsAndProjectsApi } diff --git a/src/preload/api/pty-bridge-part-1.ts b/src/preload/api/pty-bridge-session-control.ts similarity index 99% rename from src/preload/api/pty-bridge-part-1.ts rename to src/preload/api/pty-bridge-session-control.ts index 40da8dbfa52..7e761738256 100644 --- a/src/preload/api/pty-bridge-part-1.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -16,7 +16,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' -export const ptyApiPart1 = { +export const ptySessionControlApi = { spawn: (opts: { cols: number rows: number diff --git a/src/preload/api/pty-bridge-part-2.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts similarity index 99% rename from src/preload/api/pty-bridge-part-2.ts rename to src/preload/api/pty-bridge-stream-and-serialization.ts index 1686a621f97..8fc9c48bce1 100644 --- a/src/preload/api/pty-bridge-part-2.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -2,7 +2,7 @@ import { ipcRenderer } from 'electron' import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' -export const ptyApiPart2 = { +export const ptyStreamAndSerializationApi = { inspectProcess: ( id: string ): Promise<{ diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts index 314bd6f993f..df080692178 100644 --- a/src/preload/api/pty-bridge.ts +++ b/src/preload/api/pty-bridge.ts @@ -1,4 +1,4 @@ -import { ptyApiPart1 } from './pty-bridge-part-1' -import { ptyApiPart2 } from './pty-bridge-part-2' +import { ptySessionControlApi } from './pty-bridge-session-control' +import { ptyStreamAndSerializationApi } from './pty-bridge-stream-and-serialization' -export const ptyApi = { ...ptyApiPart1, ...ptyApiPart2 } +export const ptyApi = { ...ptySessionControlApi, ...ptyStreamAndSerializationApi } diff --git a/src/preload/api/ui-bridge-part-4.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts similarity index 98% rename from src/preload/api/ui-bridge-part-4.ts rename to src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 2c5e2683931..867bd80026d 100644 --- a/src/preload/api/ui-bridge-part-4.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -13,7 +13,7 @@ import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import { subscribeNativeFileDrop } from '../preload-runtime-support' -export const uiApiPart4 = { +export const uiClipboardAndWindowControlsApi = { onOpenDiffFromMobile: ( callback: (data: { worktreeId: string @@ -191,5 +191,8 @@ export const uiApiPart4 = { }, confirmWindowClose: (): void => { ipcRenderer.send('window:confirm-close') + }, + notifyWindowRevealed: (): void => { + ipcRenderer.send('ui:window-revealed') } } diff --git a/src/preload/api/ui-bridge-part-5.ts b/src/preload/api/ui-bridge-part-5.ts deleted file mode 100644 index 7c4c10eb1c6..00000000000 --- a/src/preload/api/ui-bridge-part-5.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ipcRenderer } from 'electron' - -export const uiApiPart5 = { - notifyWindowRevealed: (): void => { - ipcRenderer.send('ui:window-revealed') - } -} diff --git a/src/preload/api/ui-bridge-part-1.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts similarity index 99% rename from src/preload/api/ui-bridge-part-1.ts rename to src/preload/api/ui-bridge-state-and-menu-commands.ts index b402b44b248..25476165cfb 100644 --- a/src/preload/api/ui-bridge-part-1.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -2,7 +2,7 @@ import { ipcRenderer } from 'electron' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' -export const uiApiPart1 = { +export const uiStateAndMenuCommandsApi = { get: () => ipcRenderer.invoke('ui:get'), set: (args) => ipcRenderer.invoke('ui:set', args), setWithAck: (args) => ipcRenderer.invoke('ui:set', args), diff --git a/src/preload/api/ui-bridge-part-2.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts similarity index 99% rename from src/preload/api/ui-bridge-part-2.ts rename to src/preload/api/ui-bridge-tab-and-browser-commands.ts index fdf8956622e..ccca9a24f5b 100644 --- a/src/preload/api/ui-bridge-part-2.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -7,7 +7,7 @@ import type { } from '../../shared/worktree/launch-types' import { browserFindSubscriptions } from '../preload-runtime-support' -export const uiApiPart2 = { +export const uiTabAndBrowserCommandsApi = { onRequestTabSetProfile: ( callback: (data: { requestId: string diff --git a/src/preload/api/ui-bridge-part-3.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts similarity index 99% rename from src/preload/api/ui-bridge-part-3.ts rename to src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 44669b67838..9de47cceb0c 100644 --- a/src/preload/api/ui-bridge-part-3.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -12,7 +12,7 @@ import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' -export const uiApiPart3 = { +export const uiTerminalAndSessionTabsApi = { onCreateTerminal: ( callback: (data: { requestId?: string diff --git a/src/preload/api/ui-bridge.ts b/src/preload/api/ui-bridge.ts index 97e199215c6..200d49ba1c1 100644 --- a/src/preload/api/ui-bridge.ts +++ b/src/preload/api/ui-bridge.ts @@ -1,14 +1,12 @@ import type { PreloadApi } from '../api-types' -import { uiApiPart1 } from './ui-bridge-part-1' -import { uiApiPart2 } from './ui-bridge-part-2' -import { uiApiPart3 } from './ui-bridge-part-3' -import { uiApiPart4 } from './ui-bridge-part-4' -import { uiApiPart5 } from './ui-bridge-part-5' +import { uiStateAndMenuCommandsApi } from './ui-bridge-state-and-menu-commands' +import { uiTabAndBrowserCommandsApi } from './ui-bridge-tab-and-browser-commands' +import { uiTerminalAndSessionTabsApi } from './ui-bridge-terminal-and-session-tabs' +import { uiClipboardAndWindowControlsApi } from './ui-bridge-clipboard-and-window-controls' export const uiApi = { - ...uiApiPart1, - ...uiApiPart2, - ...uiApiPart3, - ...uiApiPart4, - ...uiApiPart5 + ...uiStateAndMenuCommandsApi, + ...uiTabAndBrowserCommandsApi, + ...uiTerminalAndSessionTabsApi, + ...uiClipboardAndWindowControlsApi } satisfies PreloadApi['ui'] diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index af0df59ccd6..3ce49d84bf6 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -16,7 +16,7 @@ const Landing = lazy(() => import('../components/Landing')) const WorktreeCreationPanel = lazy( () => import('../components/worktree-creation/WorktreeCreationPanel') ) -const TaskPage = lazy(() => import('../components/TaskPage')) +const TaskPage = lazy(() => import('../components/task-page/TaskPage')) const AutomationsPage = lazy(() => import('../components/automations/AutomationsPage')) const ActivityPrototypePage = lazy(() => import('../components/activity/ActivityPrototypePage')) const Settings = lazy(() => import('../components/settings/Settings')) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx deleted file mode 100644 index fcd84c20856..00000000000 --- a/src/renderer/src/components/TaskPage.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react' -import { useTaskPageStoreBindings } from './use-task-page-store-bindings' -import { useTaskPageRepoSelection } from './use-task-page-repo-selection' -import { useTaskPageRuntimeHosts } from './use-task-page-runtime-hosts' -import { useTaskPageSourceAvailability } from './use-task-page-source-availability' -import { useTaskPageProviderState } from './use-task-page-provider-state' -import { useTaskPageGitHubListState } from './use-task-page-github-list-state' -import { useTaskPageGitHubDetail } from './use-task-page-github-detail' -import { useTaskPageGitHubCacheReconciliation } from './use-task-page-github-cache-reconciliation' -import { useTaskPageGitHubIssueDraft } from './use-task-page-github-issue-draft' -import { useTaskPageDetailRouting } from './use-task-page-detail-routing' -import { useTaskPageLinearViewState } from './use-task-page-linear-view-state' -import { useTaskPageJiraListState } from './use-task-page-jira-list-state' -import { useTaskPageResumeRestoration } from './use-task-page-resume-restoration' -import { useTaskPageProviderMetadata } from './use-task-page-provider-metadata' -import { useTaskPageGitLabLoading } from './use-task-page-gitlab-loading' -import { useTaskPageLinearListSelection } from './use-task-page-linear-list-selection' -import { useTaskPageLinearListProjection } from './use-task-page-linear-list-projection' -import { useTaskPageLinearBoard } from './use-task-page-linear-board' -import { useTaskPageJiraListProjection } from './use-task-page-jira-list-projection' -import { useTaskPageLinearCreationState } from './use-task-page-linear-creation-state' -import { useTaskPageGitHubMutationState } from './use-task-page-github-mutation-state' -import { useTaskPageJiraCreationState } from './use-task-page-jira-creation-state' -import { useTaskPageJiraCreationMetadata } from './use-task-page-jira-creation-metadata' -import { useTaskPageGitHubListProjection } from './use-task-page-github-list-projection' -import { useTaskPageGitHubSearchPagination } from './use-task-page-github-search-pagination' -import { useTaskPageGitHubLandingRefresh } from './use-task-page-github-landing-refresh' -import { useTaskPageGitHubQuietRefresh } from './use-task-page-github-quiet-refresh' -import { useTaskPageSearchActions } from './use-task-page-search-actions' -import { useTaskPageWorkspaceActions } from './use-task-page-workspace-actions' -import { useTaskPageGitHubIssueCreation } from './use-task-page-github-issue-creation' -import { useTaskPageLinearProjectCreation } from './use-task-page-linear-project-creation' -import { useTaskPageLinearIssueCreation } from './use-task-page-linear-issue-creation' -import { useTaskPageJiraIssueCreation } from './use-task-page-jira-issue-creation' -import { useTaskPageGlobalEffects } from './use-task-page-global-effects' -import { useTaskPageLinearListEffects } from './use-task-page-linear-list-effects' -import { useTaskPageLinearInOrcaEffects } from './use-task-page-linear-in-orca-effects' -import { useTaskPageLinearCollectionEffects } from './use-task-page-linear-collection-effects' -import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects' -import { useTaskPageComposerActions } from './use-task-page-composer-actions' -import { TaskPageSurface } from './TaskPageSurface' - -export default function TaskPage(): React.JSX.Element { - const stage1 = useTaskPageStoreBindings() - const stage2 = useTaskPageRepoSelection(stage1) - const stage3 = useTaskPageRuntimeHosts(stage2) - const stage4 = useTaskPageSourceAvailability(stage3) - const stage5 = useTaskPageProviderState(stage4) - const stage6 = useTaskPageGitHubListState(stage5) - const stage7 = useTaskPageGitHubDetail(stage6) - const stage8 = useTaskPageGitHubCacheReconciliation(stage7) - const stage9 = useTaskPageGitHubIssueDraft(stage8) - const stage10 = useTaskPageDetailRouting(stage9) - const stage11 = useTaskPageLinearViewState(stage10) - const stage12 = useTaskPageJiraListState(stage11) - const stage13 = useTaskPageResumeRestoration(stage12) - const stage14 = useTaskPageProviderMetadata(stage13) - const stage15 = useTaskPageGitLabLoading(stage14) - const stage16 = useTaskPageLinearListSelection(stage15) - const stage17 = useTaskPageLinearListProjection(stage16) - const stage18 = useTaskPageLinearBoard(stage17) - const stage19 = useTaskPageJiraListProjection(stage18) - const stage20 = useTaskPageLinearCreationState(stage19) - const stage21 = useTaskPageGitHubMutationState(stage20) - const stage22 = useTaskPageJiraCreationState(stage21) - const stage23 = useTaskPageJiraCreationMetadata(stage22) - const stage24 = useTaskPageGitHubListProjection(stage23) - const stage25 = useTaskPageGitHubSearchPagination(stage24) - const stage26 = useTaskPageGitHubLandingRefresh(stage25) - const stage27 = useTaskPageGitHubQuietRefresh(stage26) - const stage28 = useTaskPageSearchActions(stage27) - const stage29 = useTaskPageWorkspaceActions(stage28) - const stage30 = useTaskPageGitHubIssueCreation(stage29) - const stage31 = useTaskPageLinearProjectCreation(stage30) - const stage32 = useTaskPageLinearIssueCreation(stage31) - const stage33 = useTaskPageJiraIssueCreation(stage32) - const stage34 = useTaskPageGlobalEffects(stage33) - const stage35 = useTaskPageLinearListEffects(stage34) - const stage36 = useTaskPageLinearInOrcaEffects(stage35) - const stage37 = useTaskPageLinearCollectionEffects(stage36) - const stage38 = useTaskPageJiraListEffects(stage37) - const stage39 = useTaskPageComposerActions(stage38) - return -} diff --git a/src/renderer/src/components/TaskPageGitHubAvatars.tsx b/src/renderer/src/components/TaskPageGitHubAvatars.tsx deleted file mode 100644 index 14b8bacaee8..00000000000 --- a/src/renderer/src/components/TaskPageGitHubAvatars.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { GitHubPRPrimaryReviewer } from '@/components/github-pr-reviewer-display' -import React from 'react' -import { GitHubUserAvatar } from '@/components/github/github-user-avatar' -import { Users } from 'lucide-react' -import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' -export function ReviewChipAvatar({ - reviewer, - avatarHost -}: { - reviewer: GitHubPRPrimaryReviewer | null - avatarHost?: string -}): React.JSX.Element { - if (reviewer?.login) { - // Why: review requests may contain only logins; use the PR host before falling back to initials. - const avatarUrl = - reviewer.avatarUrl || `https://${avatarHost ?? 'github.com'}/${reviewer.login}.png?size=40` - return ( - - ) - } - return -} -export function GitHubAssigneeAvatar({ - assignee -}: { - assignee: GitHubAssignableUser -}): React.JSX.Element { - if (assignee.avatarUrl) { - return ( - {assignee.login} - ) - } - return ( - - {assignee.login.slice(0, 1).toUpperCase()} - - ) -} diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index b6842fd95fe..5c3145360f6 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -1,3 +1,4 @@ +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { WorktreeSplitSurface } from './TerminalWorktreeSplitSurface' import type { TerminalController } from './use-terminal-controller' @@ -22,12 +23,22 @@ export function TerminalSplitWorkspaceSurfaces({ renderedActiveWorktreeId, workspaceSurfaces } = controller + // Why: this and TerminalSurface are both strict ancestors of every browser , so a + // remote controller needs each to drop `hidden` — the per-worktree surface hatch below cannot + // override an ancestor that stopped compositing. + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!effectiveActiveLayout) if (!anyMountedWorktreeHasLayout) { return null } return (
{workspaceSurfaces .filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id)) diff --git a/src/renderer/src/components/TerminalSurface.tsx b/src/renderer/src/components/TerminalSurface.tsx index 66553037b42..023f19f7311 100644 --- a/src/renderer/src/components/TerminalSurface.tsx +++ b/src/renderer/src/components/TerminalSurface.tsx @@ -1,4 +1,5 @@ import EditorAutosaveController from './editor/EditorAutosaveController' +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' import { TerminalSplitWorkspaceSurfaces } from './TerminalSplitWorkspaceSurfaces' import { TerminalLegacyWorkspaceSurface } from './TerminalLegacyWorkspaceSurface' @@ -11,10 +12,17 @@ export function TerminalSurface({ controller: TerminalController }): React.JSX.Element { const { renderedActiveWorktreeId } = controller + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!renderedActiveWorktreeId) return (
diff --git a/src/renderer/src/components/TerminalTitlebarTabs.test.tsx b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx new file mode 100644 index 00000000000..e4f4162c97a --- /dev/null +++ b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientHostedBrowserRow } from '../../../shared/client-hosted-browser-rows' +import { + applyClientHostedBrowserRows, + getClientHostedBrowserRows +} from '@/lib/pane-manager/client-hosted-browser-row-state' +import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' +import type { TerminalController } from './use-terminal-controller' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + tabBarProps: [] as Record[] +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(mocks.state), { + getState: () => mocks.state + }) +})) +vi.mock('./tab-bar/TabBar', () => ({ + default: (props: Record) => { + mocks.tabBarProps.push(props) + return null + } +})) + +const WORKTREE_ID = 'repo-1::/repo/worktree' +const ROW: ClientHostedBrowserRow = { + browserPageId: 'page-1', + title: 'Client page', + url: 'https://example.com' +} as ClientHostedBrowserRow + +let titlebarTarget: HTMLElement +let container: HTMLElement + +function renderTitlebarTabs(): void { + const controller = { + activeBrowserTabId: null, + activeFileId: null, + activeTabId: null, + activeTabType: 'terminal', + effectiveActiveLayout: null, + expandedPaneByTabId: {}, + handleActivateBrowserTab: vi.fn(), + handleActivateTab: vi.fn(), + handleCloseAllFiles: vi.fn(), + handleCloseBrowserTab: vi.fn(), + handleCloseFile: vi.fn(), + handleCloseOthers: vi.fn(), + handleCloseTab: vi.fn(), + handleCloseTabsToLeft: vi.fn(), + handleCloseTabsToRight: vi.fn(), + handleDuplicateBrowserTab: vi.fn(), + handleNewBrowserTab: vi.fn(), + handleNewFile: vi.fn(), + handleNewSimulatorTab: vi.fn(), + handleNewTab: vi.fn(), + handleOpenEntry: vi.fn(), + handleTogglePaneExpand: vi.fn(), + makePreviewFilePermanent: vi.fn(), + mobileEmulatorEnabled: false, + pinFile: vi.fn(), + renderedActiveWorktreeId: WORKTREE_ID, + setActiveFile: vi.fn(), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabColor: vi.fn(), + setTabCustomTitle: vi.fn(), + tabBarOrder: [], + titlebarTabsTarget: titlebarTarget, + worktreeBrowserTabs: [], + // Mirrors the projection hook's derivation so the assertion follows the real row store. + worktreeClientHostedBrowserRows: getClientHostedBrowserRows(WORKTREE_ID), + worktreeFiles: [] + } as unknown as TerminalController + const root = createRoot(container) + act(() => root.render()) + act(() => root.unmount()) +} + +describe('TerminalTitlebarTabs', () => { + beforeEach(() => { + mocks.tabBarProps = [] + mocks.state = { tabsByWorktree: {}, unifiedTabsByWorktree: {}, getActiveTab: () => null } + titlebarTarget = document.createElement('div') + container = document.createElement('div') + document.body.append(titlebarTarget, container) + }) + + afterEach(() => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [] }) + titlebarTarget.remove() + container.remove() + }) + + it('forwards client-hosted browser rows to the titlebar tab bar', () => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [ROW] }) + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([ROW]) + }) + + it('passes no rows when the worktree has none', () => { + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/TerminalTitlebarTabs.tsx b/src/renderer/src/components/TerminalTitlebarTabs.tsx index a275da9d801..c2a5d8218e4 100644 --- a/src/renderer/src/components/TerminalTitlebarTabs.tsx +++ b/src/renderer/src/components/TerminalTitlebarTabs.tsx @@ -1,8 +1,20 @@ import { createPortal } from 'react-dom' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '../store' import TabBar from './tab-bar/TabBar' import type { TerminalController } from './use-terminal-controller' +const EMPTY_TERMINAL_TABS: TerminalTab[] = [] + +// Why: keeps title-only tab updates a leaf subscription so the Terminal root, +// which reads the topology projection, does not re-render on every rename. +function LiveTerminalTabBar( + props: Omit, 'tabs'> +): React.JSX.Element { + const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS) + return +} + export function TerminalTitlebarTabs({ controller }: { @@ -41,17 +53,16 @@ export function TerminalTitlebarTabs({ setTabColor, setTabCustomTitle, tabBarOrder, - tabs, titlebarTabsTarget, worktreeBrowserTabs, + worktreeClientHostedBrowserRows, worktreeFiles } = controller if (!renderedActiveWorktreeId || effectiveActiveLayout || !titlebarTabsTarget) { return null } return createPortal( - ([ ['components/TerminalWorkbenchContainer.tsx', ['useAnyBrowserGuestNeedsPaint']], + // The two outermost workbench wrappers: strict ancestors of every guest, so the per-worktree + // surface hatch below cannot rescue a guest either one parked with `hidden`. + ['components/TerminalSurface.tsx', ['useAnyBrowserGuestNeedsPaint']], + ['components/TerminalSplitWorkspaceSurfaces.tsx', ['useAnyBrowserGuestNeedsPaint']], ['components/TerminalWorktreeSplitSurface.tsx', ['useBrowserGuestPaintRetention']], [ 'components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx', diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 9842a75f7d7..4a02840098e 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -75,7 +75,7 @@ describe('feature interaction writer boundaries', () => { ).toContain(githubWriter) expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-use-item-actions.ts'), + componentSource('use-task-page-workspace-actions.ts'), 'const handleOpenOrUseGitHubWorkItem', 'const openComposerForGitLabItem' ) @@ -84,25 +84,25 @@ describe('feature interaction writer boundaries', () => { it('threads GitHub task source context through inline task mutations', () => { const sections = [ - componentSource('task-page/github/github-status-cell.tsx'), - componentSource('task-page/github/github-assignees-cell.tsx'), - componentSource('task-page/github/pr-review-cell.tsx'), - componentSource('task-page/github/pr-merge-cell.tsx'), + componentSource('task-page/github/StatusCell.tsx'), + componentSource('task-page/github/AssigneesCell.tsx'), + componentSource('task-page/github/ReviewCell.tsx'), + componentSource('task-page/github/MergeCell.tsx'), sourceBetween( - componentSource('task-page/hooks/use-task-page-create-github-submit.ts'), + componentSource('use-task-page-github-issue-creation.ts'), 'const handleCreateNewIssue', - 'return { handleCreateNewIssue }' + 'const nextModel' ) ] for (const section of sections) { expect(section).toContain('sourceContext') } - const rowSource = componentSource('task-page/github/github-work-item-row.tsx') - expect(rowSource).toContain( - "const rowSourceContext = getTaskPageRepoSourceContext(itemRepo, 'github')" - ) - expect(rowSource).toContain('sourceContext={rowSourceContext}') + const rowSource = componentSource('task-page/github/Rows.tsx') + // Rows inline the repo lookup per cell rather than hoisting one const. + expect( + rowSource.match(/sourceContext=\{getTaskPageRepoSourceContext\(itemRepo, 'github'\)\}/g) + ).toHaveLength(4) }) it('suppresses Tasks surface telemetry for in-page provider switches and detail opens', () => { @@ -120,7 +120,7 @@ describe('feature interaction writer boundaries', () => { 'const openRelatedLinearIssue' ), sourceBetween( - componentSource('task-page/chrome/task-page-source-toolbar.tsx'), + componentSource('task-page/SourceBar.tsx'), 'taskSourceManuallyChangedRef.current = true', 'void updateSettings' ) @@ -165,16 +165,16 @@ describe('feature interaction writer boundaries', () => { expect( sourceBetween( - componentSource('task-page/gitlab/gitlab-work-item-list.tsx'), + componentSource('task-page/gitlab/ItemList.tsx'), '{displayedGitLabItems.map((item) => (', 'handleUseGitLabItem(item)' ).match(/recordFeatureInteraction\('gitlab-tasks'\)/g) ).toHaveLength(2) expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-use-item-actions.ts'), + componentSource('use-task-page-workspace-actions.ts'), 'const handleUseGitLabItem', - 'return {' + 'const nextModel' ) ).toContain(gitlabWriter) @@ -200,18 +200,18 @@ describe('feature interaction writer boundaries', () => { it('keeps nested GitLab row actions from also opening task details by keyboard', () => { const rowSection = sourceBetween( - componentSource('task-page/gitlab/gitlab-work-item-list.tsx'), - 'onKeyDown={(event) => {', + componentSource('task-page/gitlab/ItemList.tsx'), + 'onKeyDown={(e) => {', 'className="grid w-full cursor-pointer' ) - expect(rowSection).toContain('event.target !== event.currentTarget') - expect(rowSection.indexOf('event.target !== event.currentTarget')).toBeLessThan( - rowSection.indexOf("event.key === 'Enter'") + expect(rowSection).toContain('e.target !== e.currentTarget') + expect(rowSection.indexOf('e.target !== e.currentTarget')).toBeLessThan( + rowSection.indexOf("e.key === 'Enter'") ) }) it('keys GitLab rows by repository and item identity across hosts', () => { - expect(componentSource('task-page/gitlab/gitlab-work-item-list.tsx')).toContain( + expect(componentSource('task-page/gitlab/ItemList.tsx')).toContain( 'key={`${item.repoId}:${item.id}`}' ) }) @@ -225,22 +225,22 @@ describe('feature interaction writer boundaries', () => { const taskPageSections = [ sourceBetween( - componentSource('task-page/linear/linear-state-cell.tsx'), + componentSource('task-page-linear-issue-model.tsx'), 'export function LinearStateCell', 'return (' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-linear-board.tsx'), + componentSource('use-task-page-linear-board.ts'), 'const handleLinearBoardDrop', 'const toggleLinearDisplayProperty' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-create-linear-submits.tsx'), + componentSource('use-task-page-linear-issue-creation.ts'), 'const handleCreateNewLinearIssue', - 'return {' + 'const nextModel' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-linear-actions.ts'), + componentSource('use-task-page-composer-actions.ts'), 'const handleUseLinearItem', 'const handleLinearWorkspaceChange' ) @@ -270,9 +270,9 @@ describe('feature interaction writer boundaries', () => { // marker) no longer exists in TaskPage. expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-jira-actions.ts'), + componentSource('use-task-page-composer-actions.ts'), 'const handleUseJiraItem', - 'return {' + 'const nextModel' ) ).toContain(jiraWriter) }) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 781948111c1..a60cdc7d93e 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import { getConnectionId } from '@/lib/connection-context' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { detectLanguage } from '@/lib/language-detect' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -74,6 +75,9 @@ export function useFloatingTerminalCreateActions({ ) const createFloatingBrowserTab = useCallback(() => { + if (!ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser')) { + return + } const url = browserDefaultUrl ?? 'about:blank' createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { title: translate( diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index 67e0bc79db3..b597810c0fd 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react' import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { matchFloatingWorkspacePanelOwnedAction, matchFloatingWorkspacePanelShortcut @@ -7,6 +8,7 @@ import { import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingContext, KeybindingMatchOptions } from '../../../../shared/keybindings' import type { FloatingPanelShortcutInput, @@ -123,6 +125,11 @@ export function useFloatingTerminalPanelShortcuts({ if (resolution.action === 'tab.newTerminal') { createFloatingTerminalTab() } else if (resolution.action === 'tab.newBrowser') { + if ( + !ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser') + ) { + return 'handled' + } createFloatingBrowserTab() } else if (resolution.action === 'tab.newMarkdown') { createFloatingMarkdownTab() diff --git a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts index aa90a447780..469991fc55e 100644 --- a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts +++ b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts @@ -16,8 +16,8 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('GitHub Enterprise slug routing boundaries', () => { it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => { - const statusSection = componentSource('task-page/github/github-status-cell.tsx') - const assigneeSection = componentSource('task-page/github/github-assignees-cell.tsx') + const statusSection = componentSource('task-page/github/StatusCell.tsx') + const assigneeSection = componentSource('task-page/github/AssigneesCell.tsx') expect(statusSection).toContain('host: githubProjectHost(parsedOwnerRepo.host)') expect(assigneeSection).toContain('parsed?.slug.host') @@ -25,11 +25,13 @@ describe('GitHub Enterprise slug routing boundaries', () => { }) it('uses URL-host fallback for TaskPage reviewer and merge mutations', () => { - const reviewSection = componentSource('task-page/github/pr-review-cell.tsx') - const mergeSection = componentSource('task-page/github/pr-merge-cell.tsx') + const reviewSection = componentSource('task-page/github/ReviewCell.tsx') + const reviewActionsSection = componentSource('task-page-github-reviewer-actions.ts') + const mergeSection = componentSource('task-page/github/MergeCell.tsx') expect(reviewSection).toContain('resolveTaskPullRequestRepo(item)') - expect(reviewSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) + expect(reviewSection).toContain('reviewRepo,') + expect(reviewActionsSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) expect(mergeSection).toContain('const prRepo = resolveTaskPullRequestRepo(item)') expect(mergeSection).not.toContain('prRepo: item.prRepo ?? null') }) diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts index c48cd53071a..8b33b3dc40c 100644 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -46,10 +46,7 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { // Why: author chip must not ignore API avatar_url and only pass login. expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) - const reviewChip = readFileSync( - join(__dirname, '../task-page/github/github-assignee-avatars.tsx'), - 'utf8' - ) + const reviewChip = readFileSync(join(__dirname, '../task-page/github/Avatars.tsx'), 'utf8') expect(reviewChip).toMatch(/GitHubUserAvatar/) // Why: list chip must not hardcode github.com/{login}.png. expect(reviewChip).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) @@ -77,20 +74,20 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { displayName: 'option.name' }, { - file: 'task-page/github/github-assignee-avatars.tsx', + file: 'task-page/github/Avatars.tsx', fn: 'GitHubAssigneeAvatar', login: 'assignee.login', displayName: 'assignee.name' }, { - file: 'task-page/github/github-assignees-cell.tsx', + file: 'task-page/github/AssigneesCell.tsx', fn: 'GHAssigneesCell', login: 'user.login', displayName: 'user.name' }, { - file: 'task-page/github/pr-review-picker-panel.tsx', - fn: 'PRReviewPickerPanel', + file: 'task-page/github/ReviewerPicker.tsx', + fn: 'TaskPageGitHubReviewerPicker', login: 'reviewer.login', displayName: 'reviewer.name' } diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index e1d58f70ded..4382cd1aeb0 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -21,23 +21,6 @@ const COPY_SOURCE = readSource('smart-workspace-name-field-copy.ts') const INPUT_SOURCE = readSource('smart-workspace-name-input-surface.tsx') const SURFACE_SOURCE = readSource('smart-workspace-name-field-surface.tsx') const DIALOG_SOURCE = readSource('smart-workspace-cross-repo-dialog.tsx') -const FIELD_SOURCES = [ - MODEL_SOURCE, - CONTROLLER_SOURCE, - FOUNDATION_SOURCE, - AVAILABILITY_SOURCE, - FOCUS_SOURCE, - STATE_SOURCE, - GITHUB_SOURCE, - GITLAB_SOURCE, - SECONDARY_SEARCH_SOURCE, - ACTIONS_SOURCE, - PRESENTATION_SOURCE, - COPY_SOURCE, - INPUT_SOURCE, - SURFACE_SOURCE, - DIALOG_SOURCE -].join('\n') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -72,7 +55,7 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(availableModesSection).toContain("item.id === 'jira'") expect(availableModesSection).toContain('return jiraSourceConnected') expect(availableModesSection).toContain('branchesEnabled && !repoBackedSourcesDisabled') - expect(FIELD_SOURCES).toContain('repoBackedSourcesDisabled') + expect(CONTROLLER_SOURCE).toContain('repoBackedSourcesDisabled') expect(CONTROLLER_SOURCE).toContain('foundation.gitlabSourceAvailable') const jiraLookupSection = sourceBetween( @@ -107,8 +90,13 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { }) it('searches repo-backed task sources through implicit repo targets instead of a menu', () => { - expect(FIELD_SOURCES).not.toContain('RepoBackedSourceMenu') - expect(FIELD_SOURCES).not.toContain('repoBackedSourceOptions') + // The menu declared a prop, derived a visibility flag, and rendered a control; implicit repo + // targets replaced all three, so each former host is pinned separately. + expect(MODEL_SOURCE).not.toContain('repoBackedSourceOptions') + expect(CONTROLLER_SOURCE).not.toContain('repoBackedSourceOptions') + expect(FOUNDATION_SOURCE).not.toContain('repoBackedSourceOptions') + expect(SURFACE_SOURCE).not.toContain('RepoBackedSourceMenu') + expect(INPUT_SOURCE).not.toContain('RepoBackedSourceMenu') expect(MODEL_SOURCE).toContain('repoBackedSearchRepos?: readonly RepoOption[]') const targetSection = sourceBetween( @@ -125,26 +113,27 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(CONTROLLER_SOURCE).toContain('foundation.repoBackedSearchTargets.length > 0') expect(GITHUB_SOURCE).toContain('fetchWorkItemsAcrossRepos') expect(GITHUB_SOURCE).toContain('repoBackedSearchTargets.map') + expect(GITLAB_SOURCE).toContain('repoBackedSearchTargets.map') }) it('does not fan decisive Linear URLs out to unrelated providers', () => { const githubGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGithub =', 'const shouldQueryLinear =' ) const branchGate = sourceBetween( - FIELD_SOURCES, + SECONDARY_SEARCH_SOURCE, 'const branchSearchRequest = useMemo', 'useEffect(() => {\n if (!branchSearchRequest)' ) const gitlabGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGitlab =', 'useSmartWorkspaceGitlabSearch({' ) - expect(FIELD_SOURCES).toContain( + expect(PRESENTATION_SOURCE).toContain( "linearUrlIntent !== null && (mode === 'smart' || mode === 'linear')" ) expect(githubGate).toContain('!linearUrlIntentOwnsInput') diff --git a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx b/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx deleted file mode 100644 index 6f74cba2d5e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import type { AppMemory, UsageValues } from '../../../../shared/process-stats-types' -import { translate } from '@/i18n/i18n' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { - return ( -
- {label} -
- - -
-
- ) -} - -export function ResourceUsageAppSection({ - app, - isCollapsed, - onToggle -}: { - app: AppMemory - isCollapsed: boolean - onToggle: () => void -}): React.JSX.Element { - return ( -
-
- -
- - {translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')} - -
- - - -
-
-
- {!isCollapsed && ( -
- - - {(app.other.cpu > 0 || app.other.memory > 0) && ( - - )} -
- )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx b/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx deleted file mode 100644 index fcb6beb6f2e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { LoaderCircle } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function ResourceUsageKillDialog({ - foundation, - actions -}: { - foundation: ResourceUsageFoundation - actions: ResourceUsageActions -}): React.JSX.Element { - const { killConfirm, killing, setKillConfirm } = foundation - const { runKillConfirmed } = actions - - return ( - { - if (next) { - return - } - if (killing) { - return - } - setKillConfirm(null) - }} - > - { - if (killing) { - event.preventDefault() - } - }} - onEscapeKeyDown={(event) => { - if (killing) { - event.preventDefault() - } - }} - > - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0', - 'Kill {{value0}}?', - { - value0: - killConfirm?.label ?? - translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80', - 'this session' - ) - } - )} - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49', - "Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone." - )} - - - - - - - - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx b/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx deleted file mode 100644 index 6353c41b761..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { memo, useMemo } from 'react' -import { cn } from '@/lib/utils' -import type { Metric } from './resource-usage-merge-types' - -export const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums' -export const CPU_COLUMN_CLS = 'w-12 text-right' -export const MEM_COLUMN_CLS = 'w-16 text-right' -export const ROW_TRAILING_GUTTER_CLS = 'w-5 shrink-0 flex items-center justify-end' - -export function formatMemory(bytes: number): string { - if (bytes < 1024 * 1024) { - return `${Math.round(bytes / 1024)} KB` - } - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` -} - -export function formatCpu(percent: number): string { - return `${percent.toFixed(1)}%` -} - -function formatMetricCpu(value: Metric): string { - return value === null ? '—' : formatCpu(value) -} - -function formatMetricMemory(value: Metric): string { - return value === null ? '—' : formatMemory(value) -} - -type SparklineProps = { - samples: number[] - width?: number - height?: number -} - -function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element { - const points = useMemo(() => { - const safe = Array.isArray(samples) ? samples : [] - if (safe.length < 2) { - const midY = (height / 2).toFixed(1) - return `0,${midY} ${width},${midY}` - } - - let min = safe[0] - let max = safe[0] - for (const value of safe) { - if (value < min) { - min = value - } - if (value > max) { - max = value - } - } - const range = max - min || 1 - const stepX = width / (safe.length - 1) - const out: string[] = [] - for (let index = 0; index < safe.length; index++) { - const x = (index * stepX).toFixed(1) - const y = (height - ((safe[index] - min) / range) * height).toFixed(1) - out.push(`${x},${y}`) - } - return out.join(' ') - }, [samples, width, height]) - - return ( - - - - ) -} - -export const ResourceUsageSparkline = memo(SparklineImpl, (left, right) => { - if (left.width !== right.width || left.height !== right.height) { - return false - } - const leftSamples = Array.isArray(left.samples) ? left.samples : [] - const rightSamples = Array.isArray(right.samples) ? right.samples : [] - if (leftSamples === rightSamples) { - return true - } - if (leftSamples.length !== rightSamples.length) { - return false - } - for (let index = 0; index < leftSamples.length; index++) { - if (leftSamples[index] !== rightSamples[index]) { - return false - } - } - return true -}) - -export function ResourceUsageMetricPair({ - cpu, - memory, - size = 'base' -}: { - cpu: Metric - memory: Metric - size?: 'base' | 'small' -}): React.JSX.Element { - const textClassName = size === 'small' ? 'text-[11px]' : 'text-xs' - const muted = cpu === null && memory === null - return ( -
- {formatMetricCpu(cpu)} - {formatMetricMemory(memory)} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx b/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx deleted file mode 100644 index 64ca6049363..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import React from 'react' -import { AlertTriangle, ChevronRight, MemoryStick, RotateCw, Trash2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { PopoverContent } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel' -import { ResourceUsageAppSection } from './ResourceUsageAppSection' -import { - CPU_COLUMN_CLS, - formatCpu, - formatMemory, - MEM_COLUMN_CLS, - METRIC_COLUMNS_CLS, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' -import { ResourceUsageTree } from './ResourceUsageTree' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsagePopoverContent({ - foundation, - projection, - actions -}: { - foundation: ResourceUsageFoundation - projection: ResourceUsageProjection - actions: ResourceUsageActions -}): React.JSX.Element { - const { - sortOption, - setSortOption, - daemonActions, - resourceSnapshot, - setPopoverBodyNode, - collapsedRepos, - collapsedWorktrees, - activeWorktreeId, - appCollapsed, - setAppCollapsed - } = foundation - const { - daemonUnreachable, - sessionsOnlyError, - totalCpu, - totalMemory, - memoryMetricCopy, - orphanCount, - unifiedRepos - } = projection - const { - toggleRepo, - toggleWorktree, - navigateToWorktree, - navigateToTab, - deleteWorktree, - handleKillSession, - handleOpenWorkspaceCleanup, - handleKillOrphans, - openSpaceResults - } = actions - - return ( - event.preventDefault()} - // Why: xterm focus must not dismiss the resource manager after tab activation. - onFocusOutside={(event) => event.preventDefault()} - > -
-
- - - {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')} - -
- -
- - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb', - 'Restart daemon' - )} - - - - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59', - 'Kill all sessions' - )} - - -
-
- - {daemonUnreachable && ( -
- -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4', - 'Daemon is not responding' - )} -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6', - 'Resource snapshots and terminal sessions are unavailable.' - )} -
-
- -
- )} - - {!daemonUnreachable && sessionsOnlyError && ( -
- - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78', - 'Terminal sessions unavailable. The list may be stale.' - )} - -
- )} - - {resourceSnapshot && ( -
-
- - - - {formatCpu(totalCpu)} - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae', - 'Combined CPU load. Values above 100% mean more than one core is working at once.' - )} - - - · - - - - {formatMemory(totalMemory)}{' '} - - {memoryMetricCopy.summaryLabel} - - - - - {memoryMetricCopy.description} - - -
- {orphanCount > 0 && ( - - {orphanCount === 1 - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31', - '{{value0}} orphan', - { value0: orphanCount } - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3', - '{{value0}} orphans', - { value0: orphanCount } - )} - - )} -
- )} - - {/* Why: fixed height prevents list expansion and polling from moving the popover. */} -
- {(unifiedRepos.length > 0 || resourceSnapshot) && ( -
- -
-
- - -
- -
-
- )} - -
- {unifiedRepos.length > 0 && ( - - )} - - {unifiedRepos.length === 0 && resourceSnapshot && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0', - 'Nothing running right now' - )} -
- )} - - {resourceSnapshot && ( - setAppCollapsed((value) => !value)} - /> - )} - - {!resourceSnapshot && !daemonUnreachable && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55', - 'Loading…' - )} -
- )} -
-
- -
- - {orphanCount > 0 ? ( - - ) : null} -
- - -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx b/src/renderer/src/components/status-bar/ResourceUsageTree.tsx deleted file mode 100644 index 3914bab48df..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useMemo } from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import { useWorktreeMap } from '../../store/selectors' -import { translate } from '@/i18n/i18n' -import type { - UnifiedProjectGroup, - UnifiedSessionRow, - UnifiedWorktreeRow -} from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { sortResourceUsageProjectGroups, sortResourceUsageWorktrees } from './resource-usage-sort' -import { ResourceUsageMetricPair, ROW_TRAILING_GUTTER_CLS } from './ResourceUsageMetrics' -import { ResourceUsageWorktreeRow } from './ResourceUsageWorktreeRow' - -export function ResourceUsageTree({ - repos, - sortOption, - collapsedRepos, - toggleRepo, - collapsedWorktrees, - activeWorktreeId, - toggleWorktree, - navigateToWorktree, - navigateToTab, - onDelete, - onKillSession -}: { - repos: UnifiedProjectGroup[] - sortOption: ResourceUsageSortOption - collapsedRepos: Set - toggleRepo: (repoId: string) => void - collapsedWorktrees: Set - activeWorktreeId: string | null - toggleWorktree: (worktreeId: string) => void - navigateToWorktree: (worktreeId: string) => void - navigateToTab: (tabId: string, paneKey: string | null) => void - onDelete: (worktreeId: string) => void - onKillSession: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const worktreeById = useWorktreeMap() - const sortedRepos = useMemo(() => { - const grouped = sortResourceUsageProjectGroups(repos, sortOption) - return grouped.map((repo) => ({ - ...repo, - worktrees: sortResourceUsageWorktrees(repo.worktrees, sortOption) - })) - }, [repos, sortOption]) - const renderWorktree = (worktree: UnifiedWorktreeRow): React.JSX.Element => { - const storeRecord = worktreeById.get(worktree.worktreeId) ?? null - return ( - toggleWorktree(worktree.worktreeId)} - onNavigate={() => navigateToWorktree(worktree.worktreeId)} - onDelete={() => onDelete(worktree.worktreeId)} - onKillSession={onKillSession} - navigateToTab={navigateToTab} - /> - ) - } - - if (sortedRepos.length === 1) { - return <>{sortedRepos[0].worktrees.map(renderWorktree)} - } - - return ( - <> - {sortedRepos.map((group) => { - const repoCollapsed = collapsedRepos.has(group.repoId) - return ( -
-
- -
- - - {group.repoName} - - {group.hasRemoteChildren && ( - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', - '· remote' - )} - - )} - -
- - -
-
-
- {!repoCollapsed && ( -
{group.worktrees.map(renderWorktree)}
- )} -
- ) - })} - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx b/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx deleted file mode 100644 index 567be1d8a4b..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react' -import { AlertTriangle, MemoryStick, Terminal } from 'lucide-react' -import { PopoverTrigger } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsageTrigger({ - iconOnly, - spaceScanReady, - projection -}: { - iconOnly: boolean - spaceScanReady: boolean - projection: ResourceUsageProjection -}): React.JSX.Element { - const { - daemonUnreachable, - resourceManagerAriaLabel, - memBadgeLabel, - triggerSessionCount, - orphanCount, - resourceManagerTooltipLines - } = projection - - return ( - - - - - - - -
- {resourceManagerTooltipLines.map((line) => ( -
- {line.text} -
- ))} -
-
-
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx b/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx deleted file mode 100644 index 741863d11f1..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight, Globe, Trash2, X } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' -import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' -import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' -import { isResourceSessionActivationKey } from './resource-session-navigation' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -export function ResourceUsageSessionRow({ - session, - worktreeId, - onNavigate, - onKill -}: { - session: UnifiedSessionRow - worktreeId: string - onNavigate: (tabId: string, paneKey: string | null) => void - onKill: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const clickable = session.tabId !== null && session.bound - const handleClick = (): void => { - if (clickable && session.tabId) { - onNavigate(session.tabId, session.paneKey) - } - } - - return ( -
{ - if (isResourceSessionActivationKey(event.key)) { - event.preventDefault() - handleClick() - } - } - : undefined - } - data-worktree-id={worktreeId} - > - - - {session.label} - - - {/* Why: the shared gutter aligns columns while keeping orphan kills visible. */} - - - -
- ) -} - -function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element { - const label = browser.title?.trim() || browser.label?.trim() || browser.url - return ( -
- - {label} - - -
- ) -} - -export function ResourceUsageWorktreeRow({ - worktree, - storeRecord, - activeWorktreeId, - isCollapsed, - onToggle, - onNavigate, - onDelete, - onKillSession, - navigateToTab -}: { - worktree: UnifiedWorktreeRow - storeRecord: Worktree | null - activeWorktreeId: string | null - isCollapsed: boolean - onToggle: () => void - onNavigate: () => void - onDelete: () => void - onKillSession: (session: UnifiedSessionRow) => void - navigateToTab: (tabId: string, paneKey: string | null) => void -}): React.JSX.Element { - const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0 - const isSynthetic = - worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID - const isNavigable = !isSynthetic - const showWorktreeActions = - !isSynthetic && storeRecord !== null && worktree.worktreeId !== activeWorktreeId - const isMainWorktree = storeRecord?.isMainWorktree ?? false - const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName - - return ( -
-
- {hasResources ? ( - - ) : ( - - )} - -
-
- - - - {showWorktreeActions && ( -
- - - - - - {isMainWorktree - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a', - 'The main workspace cannot be deleted.' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458', - 'Delete workspace.' - )} - - -
- )} -
- - -
-
- {!isCollapsed && - worktree.sessions.map((session) => ( - - ))} - {!isCollapsed && - worktree.browsers.map((browser) => )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/resource-usage-sort.ts b/src/renderer/src/components/status-bar/resource-usage-sort.ts deleted file mode 100644 index e66e5c8e458..00000000000 --- a/src/renderer/src/components/status-bar/resource-usage-sort.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Metric, UnifiedProjectGroup, UnifiedWorktreeRow } from './resource-usage-merge-types' - -export type ResourceUsageSortOption = 'memory' | 'cpu' | 'name' - -function compareMetricDesc(left: Metric, right: Metric): number { - // Why: remote null metrics stay behind sampled rows for every sort direction. - if (left === null && right === null) { - return 0 - } - if (left === null) { - return 1 - } - if (right === null) { - return -1 - } - return right - left -} - -export function sortResourceUsageWorktrees( - list: UnifiedWorktreeRow[], - sort: ResourceUsageSortOption -): UnifiedWorktreeRow[] { - const copy = [...list] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.worktreeName.localeCompare(right.worktreeName)) - } - return copy -} - -export function sortResourceUsageProjectGroups( - groups: UnifiedProjectGroup[], - sort: ResourceUsageSortOption -): UnifiedProjectGroup[] { - const copy = [...groups] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.repoName.localeCompare(right.repoName)) - } - return copy -} diff --git a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts b/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts deleted file mode 100644 index bfb0314cfca..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useAppStore } from '../../store' -import { useDaemonActions } from '../shared/useDaemonActions' -import type { UnifiedSessionRow } from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { - getResourceUsageAllWorktrees, - getResourceUsageBrowserTabsByWorktree, - getResourceUsageDeferredSshSessionIdsByTabId, - getResourceUsagePtyIdsByTabId, - getResourceUsageRepos, - getResourceUsageRuntimePaneTitlesByTabId, - getResourceUsageTerminalLayoutsByTabId, - getResourceUsageTabsByWorktree -} from './resource-usage-open-slices' -import { - resolveResourceUsageSpaceScanReady, - type ResourceUsageSpaceScanSnapshot -} from './resource-usage-space-scan-ready' -import type { ResourceSessionBindingInputs } from './resource-session-bindings' -import { useResourceSessionInventory } from './use-resource-session-inventory' - -const POLL_MS = 2_000 - -export function useResourceUsageFoundation() { - const snapshot = useAppStore((state) => state.memorySnapshot) - const memorySnapshotError = useAppStore((state) => state.memorySnapshotError) - const fetchSnapshot = useAppStore((state) => state.fetchMemorySnapshot) - const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const setActiveView = useAppStore((state) => state.setActiveView) - const openModal = useAppStore((state) => state.openModal) - const openSpacePage = useAppStore((state) => state.openSpacePage) - const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) - const activeView = useAppStore((state) => state.activeView) - const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const workspaceSpaceScannedAt = useAppStore( - (state) => state.workspaceSpaceAnalysis?.scannedAt ?? null - ) - const workspaceSpaceScanning = useAppStore((state) => state.workspaceSpaceScanning) - const [open, setOpen] = useState(false) - const [sortOption, setSortOption] = useState('memory') - const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) - const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) - const [appCollapsed, setAppCollapsed] = useState(true) - const { - sessionInventory, - sessionsError, - refreshSessions, - clearSessionsError, - removeSession, - removeSessions - } = useResourceSessionInventory(workspaceSessionReady) - const sessions = sessionInventory.sessions - const [killConfirm, setKillConfirm] = useState(null) - const [killing, setKilling] = useState(false) - const [spaceScanSnapshot, setSpaceScanSnapshot] = useState( - () => ({ - ready: false, - previousScanning: workspaceSpaceScanning, - lastSeenScannedAt: workspaceSpaceScannedAt - }) - ) - // Why: title and binding maps churn; the closed trigger selects stable sentinels. - const runtimePaneTitlesByTabId = useAppStore((state) => - getResourceUsageRuntimePaneTitlesByTabId(state, open) - ) - const repos = useAppStore((state) => getResourceUsageRepos(state, open)) - const allWorktrees = useAppStore((state) => getResourceUsageAllWorktrees(state, open)) - const tabsByWorktree = useAppStore((state) => getResourceUsageTabsByWorktree(state, open)) - const browserTabsByWorktree = useAppStore((state) => - getResourceUsageBrowserTabsByWorktree(state, open) - ) - const ptyIdsByTabId = useAppStore((state) => getResourceUsagePtyIdsByTabId(state, open)) - const terminalLayoutsByTabId = useAppStore((state) => - getResourceUsageTerminalLayoutsByTabId(state, open) - ) - const deferredSshSessionIdsByTabId = useAppStore((state) => - getResourceUsageDeferredSshSessionIdsByTabId(state, open) - ) - const resourceSnapshot = snapshot - const resourceSessionBindings = useMemo( - () => ({ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - }), - [ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - ] - ) - const popoverBodyRef = useRef(null) - const popoverBodyFocusFrameRef = useRef(null) - const mountedRef = useMountedRef() - const cancelPopoverBodyFocusFrame = useCallback((): void => { - if (popoverBodyFocusFrameRef.current === null) { - return - } - cancelAnimationFrame(popoverBodyFocusFrameRef.current) - popoverBodyFocusFrameRef.current = null - }, []) - const setPopoverBodyNode = useCallback( - (node: HTMLDivElement | null): void => { - if (!node) { - cancelPopoverBodyFocusFrame() - } - popoverBodyRef.current = node - }, - [cancelPopoverBodyFocusFrame] - ) - const daemonActions = useDaemonActions({ - onRestartSettled: () => { - clearSessionsError() - void fetchSnapshot() - void refreshSessions() - } - }) - const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({ - snapshot: spaceScanSnapshot, - open, - activeView, - scannedAt: workspaceSpaceScannedAt, - scanning: workspaceSpaceScanning - }) - if ( - nextSpaceScanSnapshot.ready !== spaceScanSnapshot.ready || - nextSpaceScanSnapshot.previousScanning !== spaceScanSnapshot.previousScanning || - nextSpaceScanSnapshot.lastSeenScannedAt !== spaceScanSnapshot.lastSeenScannedAt - ) { - setSpaceScanSnapshot(nextSpaceScanSnapshot) - } - const spaceScanReady = nextSpaceScanSnapshot.ready - - // Why: seed RAM after session restore so the closed badge does not require a click. - useEffect(() => { - if (workspaceSessionReady) { - void fetchSnapshot() - } - }, [workspaceSessionReady, fetchSnapshot]) - - useEffect(() => { - if (!open) { - return - } - void fetchSnapshot() - void refreshSessions() - const memTimer = window.setInterval(() => { - void fetchSnapshot() - }, POLL_MS) - return () => { - window.clearInterval(memTimer) - } - }, [open, fetchSnapshot, refreshSessions]) - - useEffect(() => { - if (!open) { - clearSessionsError() - } - }, [open, clearSessionsError]) - - return { - snapshot, - memorySnapshotError, - workspaceSessionReady, - setActiveView, - openModal, - openSpacePage, - recordFeatureInteraction, - activeWorktreeId, - open, - setOpen, - sortOption, - setSortOption, - collapsedRepos, - setCollapsedRepos, - collapsedWorktrees, - setCollapsedWorktrees, - appCollapsed, - setAppCollapsed, - sessionInventory, - sessionsError, - refreshSessions, - removeSession, - removeSessions, - sessions, - killConfirm, - setKillConfirm, - killing, - setKilling, - runtimePaneTitlesByTabId, - repos, - allWorktrees, - tabsByWorktree, - browserTabsByWorktree, - resourceSnapshot, - resourceSessionBindings, - popoverBodyRef, - popoverBodyFocusFrameRef, - mountedRef, - cancelPopoverBodyFocusFrame, - setPopoverBodyNode, - daemonActions, - spaceScanReady - } -} - -export type ResourceUsageFoundation = ReturnType diff --git a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts b/src/renderer/src/components/status-bar/use-resource-usage-projection.ts deleted file mode 100644 index 5b8145d58e0..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { countEstimatedInactiveWorkspaces } from '../workspace-cleanup/inactive-workspace-estimate' -import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' -import { countUnboundDaemonSessions } from './resource-session-bindings' -import { - getResourceManagerAriaLabel, - getResourceManagerTooltipLines -} from './resource-manager-terminal-copy' -import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' -import { formatMemory } from './ResourceUsageMetrics' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function useResourceUsageProjection(foundation: ResourceUsageFoundation) { - const { - repos, - allWorktrees, - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - browserTabsByWorktree, - workspaceSessionReady, - sessionInventory, - sessionsError, - memorySnapshotError, - snapshot, - spaceScanReady - } = foundation - - const repoDisplayNameById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const display = repo.displayName?.trim() - if (display) { - map.set(repo.id, display) - } - } - return map - }, [repos]) - - // Why: connectionId is the only honest signal that a repo runs over SSH. - const repoConnectionIdById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - map.set(repo.id, repo.connectionId ?? null) - } - return map - }, [repos]) - - const repoRuntimeScopedById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - map.set(repo.id, parsed?.kind === 'runtime') - } - return map - }, [repos]) - - const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) - const worktreeById = useMemo( - () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), - [allWorktrees] - ) - const [oldWorkspaceCount, setOldWorkspaceCount] = useState(0) - useEffect(() => { - setOldWorkspaceCount(countEstimatedInactiveWorkspaces(allWorktrees, repoById, Date.now())) - }, [allWorktrees, repoById]) - - // Why: the closed segment must not merge on keystroke-driven store updates. - const unifiedRepos = useMemo( - () => - open - ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { - ...resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - }) - : [], - [ - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - ] - ) - - const orphanCount = useMemo(() => { - if (!open || !workspaceSessionReady) { - return 0 - } - return countUnboundDaemonSessions(sessions, resourceSessionBindings) - }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) - - const triggerSessionCount = sessionInventory.count - const memoryMetricCopy = getResourceMemoryMetricCopy( - resourceSnapshot?.processMemoryMetric ?? 'rss' - ) - const { totalMemory, totalCpu, memBadgeLabel } = useMemo(() => { - const memory = resourceSnapshot?.totalMemory ?? 0 - const cpu = resourceSnapshot?.totalCpu ?? 0 - return { - totalMemory: memory, - totalCpu: cpu, - memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—' - } - }, [resourceSnapshot]) - - const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) - const sessionsOnlyError = sessionsError && memorySnapshotError === null - const resourceManagerTooltipLines = getResourceManagerTooltipLines({ - memoryLabel: resourceSnapshot - ? `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}` - : memBadgeLabel, - sessionCount: triggerSessionCount, - spaceScanReady - }) - const resourceManagerAriaLabel = getResourceManagerAriaLabel({ - sessionCount: triggerSessionCount, - spaceScanReady - }) - - return { - oldWorkspaceCount, - unifiedRepos, - orphanCount, - triggerSessionCount, - memoryMetricCopy, - totalMemory, - totalCpu, - memBadgeLabel, - daemonUnreachable, - sessionsOnlyError, - resourceManagerTooltipLines, - resourceManagerAriaLabel - } -} - -export type ResourceUsageProjection = ReturnType diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx new file mode 100644 index 00000000000..52f1f167e50 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { WorkspaceSpaceBreakdownList } from './workspace-space-breakdown-list' +import type { + WorkspaceSpaceItem, + WorkspaceSpaceWorktree +} from '../../../../shared/workspace-space-types' + +function item(name: string, sizeBytes: number): WorkspaceSpaceItem { + return { name, path: `/workspace/${name}`, kind: 'directory', sizeBytes } +} + +function worktree(overrides: Partial): WorkspaceSpaceWorktree { + return { + worktreeId: 'wt', + repoId: 'repo', + repoDisplayName: 'repo', + repoPath: '/repo', + displayName: 'workspace', + path: '/workspace', + branch: 'refs/heads/main', + isMainWorktree: false, + isRemote: false, + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 0, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0, + ...overrides + } +} + +function renderedRowNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('span.font-medium')).map( + (node) => node.textContent ?? '' + ) +} + +afterEach(cleanup) + +describe('WorkspaceSpaceBreakdownList', () => { + it('renders one row per counted top-level item, including the omitted aggregate', () => { + const { container } = render( + + ) + + const names = renderedRowNames(container) + expect(names).toEqual(['node_modules', 'src', 'Other top-level items (7)']) + // The header count labels this list: the 7 omitted items are one aggregate row. + expect(container.textContent).toContain('9 top-level items') + expect(names.length - 1 + 7).toBe(9) + }) + + it('scales the size bars against the omitted aggregate when it is the largest item', () => { + const { container } = render( + + ) + + const widths = Array.from(container.querySelectorAll('div[style]')).map( + (node) => node.style.width + ) + expect(widths).toEqual(['25%', '100%']) + }) + + it('omits the aggregate row when nothing was omitted', () => { + const { container } = render( + + ) + + expect(renderedRowNames(container)).toEqual(['src']) + expect(container.textContent).toContain('1 top-level items') + }) + + it('shows the omitted aggregate rather than an empty state when every item was omitted', () => { + const { container } = render( + + ) + + expect(container.textContent).not.toContain('No files found.') + expect(renderedRowNames(container)).toEqual(['Other top-level items (4)']) + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx index cda1a638c04..01530e17ba8 100644 --- a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx @@ -48,8 +48,24 @@ export function WorkspaceSpaceBreakdownList({ ) } - const maxChildSize = getLargestWorkspaceSpaceItemSize(worktree.topLevelItems) + const maxChildSize = Math.max( + getLargestWorkspaceSpaceItemSize(worktree.topLevelItems), + worktree.omittedTopLevelSizeBytes + ) const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount + const omittedItem: WorkspaceSpaceItem | null = + worktree.omittedTopLevelItemCount > 0 + ? { + name: translate( + 'components.status.bar.workspaceSpace.otherTopLevelItems', + 'Other top-level items ({{value0}})', + { value0: worktree.omittedTopLevelItemCount } + ), + path: '', + kind: 'other', + sizeBytes: worktree.omittedTopLevelSizeBytes + } + : null return (
@@ -86,7 +102,7 @@ export function WorkspaceSpaceBreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -99,6 +115,7 @@ export function WorkspaceSpaceBreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 666db63968f..08b4b4bc9d9 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -19,7 +19,7 @@ import { sortWorkspaceSpaceRows } from './workspace-space-presentation' import { getWorkspaceSpaceGitStatusRefreshCandidates } from './workspace-space-git-status-order' -import { getWorkspaceDecisionDetails } from './WorkspaceSpaceManagerPanel' +import { getWorkspaceDecisionDetails } from './workspace-space-decision-details' import { getWorkspaceSpaceDeleteState, getWorkspaceSpaceGitStatusForScan @@ -452,6 +452,117 @@ describe('workspace space presentation helpers', () => { expect(details.reviewLabel).toBeNull() }) + it('hides only a matching suppressed GitHub review from workspace decisions', () => { + const matching = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 12, + state: 'open', + status: 'success', + title: 'Suppressed PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const different = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 13, + state: 'open', + status: 'success', + title: 'Different PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + + expect(matching.reviewLabel).toBeNull() + expect(different.reviewLabel).toBe('PR #13 Open, success') + }) + + it('preserves explicit links and non-GitHub reviews in workspace decisions', () => { + const cachedReview = { + number: 12, + state: 'open', + status: 'success', + title: 'Review' + } + const explicit = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'github' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: 12, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const gitLab = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'gitlab' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12, + linkedGitLabMR: 12 + }) + ] + ]) + }) + ) + + expect(explicit.reviewLabel).toBe('PR #12 Open, success') + expect(gitLab.reviewLabel).toBe('PR #12 Open, success') + }) + it('counts migration-unsupported agent entries by worktree id', () => { const count = countWorkspaceSpaceActiveAgents({ worktreeId: 'wt', @@ -484,6 +595,27 @@ describe('workspace space presentation helpers', () => { ).toEqual(rows.map((item) => item.worktreeId)) }) + it('orders git-status refreshes active first, then visible, then the rest', () => { + const rows = [ + row({ worktreeId: 'rest-a', executionHostId: 'local' }), + row({ worktreeId: 'visible-a', executionHostId: 'local' }), + row({ worktreeId: 'active', executionHostId: 'ssh:builder' }), + row({ worktreeId: 'visible-b', executionHostId: 'local' }), + row({ worktreeId: 'rest-b', executionHostId: 'local' }) + ] + const visibleWorktreeIdentities = new Set( + [rows[1], rows[3]].map(getWorkspaceSpaceWorktreeIdentity) + ) + + expect( + getWorkspaceSpaceGitStatusRefreshCandidates(rows, { + activeWorktreeId: 'active', + activeExecutionHostId: 'ssh:builder', + visibleWorktreeIdentities + }).map((item) => item.worktreeId) + ).toEqual(['active', 'visible-a', 'visible-b', 'rest-a', 'rest-b']) + }) + it('resolves inspected worktree ids from the current scan rows', () => { const rows = [ row({ worktreeId: 'errored', status: 'error' }), diff --git a/src/renderer/src/components/task-page-github-issue-creation.test.ts b/src/renderer/src/components/task-page-github-issue-creation.test.ts index 8dacd66f084..bacc3988bb8 100644 --- a/src/renderer/src/components/task-page-github-issue-creation.test.ts +++ b/src/renderer/src/components/task-page-github-issue-creation.test.ts @@ -3,8 +3,12 @@ import { readTaskPageSource } from './task-page-source-family.test-support' import { readFileSync } from 'node:fs' const taskPageSource = readTaskPageSource('use-task-page-github-issue-creation.ts') -const newIssueStateSource = readFileSync( - new URL('./task-page/hooks/use-task-page-github-new-issue-state.ts', import.meta.url), +const newIssueDraftSource = readFileSync( + new URL('./use-task-page-github-issue-draft.ts', import.meta.url), + 'utf8' +) +const newIssueRepoResetSource = readFileSync( + new URL('./task-page-new-issue-draft.ts', import.meta.url), 'utf8' ) @@ -18,8 +22,12 @@ function issueCreationSection(): string { describe('TaskPage GitHub issue creation', () => { it('keeps issue creation targeted to the first selected repo on a fresh mount', () => { - expect(newIssueStateSource).toContain('(selectedRepos[0]?.id ?? null)') - expect(newIssueStateSource).toContain('newIssueRepoId !== null') + expect(newIssueDraftSource).toContain( + 'selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null' + ) + // Why: a repo leaving the selection mid-draft must reset to the first selected repo. + expect(newIssueRepoResetSource).toContain('newIssueRepoId === null') + expect(newIssueRepoResetSource).toContain('return { repoId: selectedRepoIds[0] ?? null }') }) it('covers the complete remote oversized-body recovery timeout envelope', () => { diff --git a/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts b/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts index 9f557a10b7d..785f503436f 100644 --- a/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts +++ b/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts @@ -6,7 +6,7 @@ import { startGitHubListScrollRestore, supersedeGitHubListScrollRestore, type GitHubListRestoreWrite -} from './task-page/github/github-list-scroll-restore' +} from './task-page-github-list-scroll-restore' type FakeResizeObserver = { targets: Set diff --git a/src/renderer/src/components/task-page/github/github-list-scroll-restore.ts b/src/renderer/src/components/task-page-github-list-scroll-restore.ts similarity index 100% rename from src/renderer/src/components/task-page/github/github-list-scroll-restore.ts rename to src/renderer/src/components/task-page-github-list-scroll-restore.ts diff --git a/src/renderer/src/components/task-page-github-review-model.tsx b/src/renderer/src/components/task-page-github-review-model.tsx index 5a3bc1ce053..db4b9e4c5ce 100644 --- a/src/renderer/src/components/task-page-github-review-model.tsx +++ b/src/renderer/src/components/task-page-github-review-model.tsx @@ -41,10 +41,12 @@ export function mergeReviewerSuggestions( byLogin.set(key, user) continue } - if (!existing.avatarUrl && user.avatarUrl) { + // Why: seeds carry login only; backfill display fields from the metadata query. + if ((!existing.avatarUrl && user.avatarUrl) || (!existing.name && user.name)) { byLogin.set(key, { ...existing, - avatarUrl: user.avatarUrl + avatarUrl: existing.avatarUrl || user.avatarUrl, + name: existing.name ?? user.name }) } } diff --git a/src/renderer/src/components/task-page/github/github-reviewer-suggestions.test.ts b/src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts similarity index 98% rename from src/renderer/src/components/task-page/github/github-reviewer-suggestions.test.ts rename to src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts index a4dbc006031..7e3f0cec98a 100644 --- a/src/renderer/src/components/task-page/github/github-reviewer-suggestions.test.ts +++ b/src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts @@ -4,7 +4,7 @@ import { mergeReviewerSuggestions, resolveTaskPullRequestRepo, sameOptionalGitHubOwnerRepo -} from './github-reviewer-suggestions' +} from './task-page-github-review-model' describe('sameOptionalGitHubOwnerRepo', () => { it('treats two missing values as equal', () => { diff --git a/src/renderer/src/components/task-page/gitlab/gitlab-task-filters.test.ts b/src/renderer/src/components/task-page-gitlab-task-filters.test.ts similarity index 91% rename from src/renderer/src/components/task-page/gitlab/gitlab-task-filters.test.ts rename to src/renderer/src/components/task-page-gitlab-task-filters.test.ts index efa44bd02d2..55dec516171 100644 --- a/src/renderer/src/components/task-page/gitlab/gitlab-task-filters.test.ts +++ b/src/renderer/src/components/task-page-gitlab-task-filters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isGitLabIssueFilter, isGitLabMRFilter } from './gitlab-task-filters' +import { isGitLabIssueFilter, isGitLabMRFilter } from './task-page-source-context' describe('isGitLabMRFilter', () => { it('accepts MR statuses including all', () => { diff --git a/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts b/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts index f9ebe0ac6fb..cea31abe302 100644 --- a/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts @@ -2,9 +2,8 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const newLinearIssueDialogSources = [ - './task-page/dialogs/new-linear-issue-dialog.tsx', - './task-page/dialogs/new-linear-issue-more-pickers.tsx', - './task-page/dialogs/new-linear-issue-status-assignee.tsx' + './task-page/linear/IssueDialog.tsx', + './task-page/linear/IssueAttributes.tsx' ].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')) /** The extracted Linear "New Issue" dialog and its attribute pickers. */ diff --git a/src/renderer/src/components/task-page/linear/linear-issue-grouping.test.ts b/src/renderer/src/components/task-page-linear-issue-grouping.test.ts similarity index 96% rename from src/renderer/src/components/task-page/linear/linear-issue-grouping.test.ts rename to src/renderer/src/components/task-page-linear-issue-grouping.test.ts index e0e120dcd91..59356b33f07 100644 --- a/src/renderer/src/components/task-page/linear/linear-issue-grouping.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-grouping.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from 'vitest' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import { + findLinearWorkflowStateForStatus, + getLinearStatusSectionState, + mergeLinearCollectionResults +} from './task-page-linear-issue-model' import { compareLinearIssues, - findLinearWorkflowStateForStatus, getLinearIssueGridTemplate, getLinearPriorityRank, - getLinearStatusSectionState, - groupLinearIssues, - mergeLinearCollectionResults -} from './linear-issue-grouping' + groupLinearIssues +} from './task-page-linear-jira-list-model' function issue( overrides: Partial & Pick diff --git a/src/renderer/src/components/task-page/source/repo-source-context.test.ts b/src/renderer/src/components/task-page-repo-source-context.test.ts similarity index 93% rename from src/renderer/src/components/task-page/source/repo-source-context.test.ts rename to src/renderer/src/components/task-page-repo-source-context.test.ts index e2cd60dfae2..4a90e04f352 100644 --- a/src/renderer/src/components/task-page/source/repo-source-context.test.ts +++ b/src/renderer/src/components/task-page-repo-source-context.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildGitLabProviderIdentity, getTaskPageRepoCacheInput } from './repo-source-context' -import type { Repo } from '../../../../../shared/repo-types' +import { buildGitLabProviderIdentity, getTaskPageRepoCacheInput } from './task-page-source-context' +import type { Repo } from '../../../shared/repo-types' describe('buildGitLabProviderIdentity', () => { it('splits namespace and project and builds the web URL', () => { diff --git a/src/renderer/src/components/task-page/source/repo-source-divergence.test.ts b/src/renderer/src/components/task-page-repo-source-divergence.test.ts similarity index 98% rename from src/renderer/src/components/task-page/source/repo-source-divergence.test.ts rename to src/renderer/src/components/task-page-repo-source-divergence.test.ts index eecbab425de..e80633c3d01 100644 --- a/src/renderer/src/components/task-page/source/repo-source-divergence.test.ts +++ b/src/renderer/src/components/task-page-repo-source-divergence.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' -import { hasDivergentSources, hasUpstreamCandidateDivergence } from './repo-source-divergence' +import { hasDivergentSources, hasUpstreamCandidateDivergence } from './task-page-draft-storage' function state( sources: Partial> diff --git a/src/renderer/src/components/task-page-source-family.test-support.ts b/src/renderer/src/components/task-page-source-family.test-support.ts index 11dd0634272..2253f11295e 100644 --- a/src/renderer/src/components/task-page-source-family.test-support.ts +++ b/src/renderer/src/components/task-page-source-family.test-support.ts @@ -7,7 +7,8 @@ import { readHookDefinitions } from './refactor-hook-contract.test-support' -const TASK_PAGE_SOURCE_PATTERN = /^(?:TaskPage.*\.tsx|use-task-page-.*\.ts|task-page-.*\.tsx?)$/ +const TASK_PAGE_FLAT_SOURCE_PATTERN = /^(?:use-task-page-.*\.ts|task-page-.*\.tsx?)$/ +const TASK_PAGE_DIRECTORY = 'task-page' const TASK_PAGE_EXTRACTED_LOWERCASE_FILES = new Set([ 'task-page-draft-storage.tsx', 'task-page-github-landing-refresh-run.tsx', @@ -19,18 +20,34 @@ const TASK_PAGE_EXTRACTED_LOWERCASE_FILES = new Set([ 'task-page-source-context.tsx' ]) -export const TASK_PAGE_SOURCE_FILES = readdirSync(__dirname) - .filter( - (name) => - TASK_PAGE_SOURCE_PATTERN.test(name) && - !name.includes('.test.') && - !name.includes('.test-support.') +function isSourceFile(name: string): boolean { + return !name.includes('.test.') && !name.includes('.test-support.') +} + +// Why: the components/task-page tree nests by provider, so a flat readdir would silently +// return an empty family and turn every ratchet built on it into a no-op. +function readTaskPageDirectory(relativeDirectory: string): string[] { + return readdirSync(join(__dirname, relativeDirectory), { withFileTypes: true }).flatMap( + (entry) => { + const relativePath = `${relativeDirectory}/${entry.name}` + if (entry.isDirectory()) { + return readTaskPageDirectory(relativePath) + } + return /\.tsx?$/.test(entry.name) && isSourceFile(entry.name) ? [relativePath] : [] + } ) - .sort() +} + +export const TASK_PAGE_SOURCE_FILES = [ + ...readdirSync(__dirname).filter( + (name) => TASK_PAGE_FLAT_SOURCE_PATTERN.test(name) && isSourceFile(name) + ), + ...readTaskPageDirectory(TASK_PAGE_DIRECTORY) +].sort() export const TASK_PAGE_REFACTOR_SOURCE_FILES = TASK_PAGE_SOURCE_FILES.filter( (name) => - /^TaskPage.*\.tsx$/.test(name) || + (name.startsWith(`${TASK_PAGE_DIRECTORY}/`) && name.endsWith('.tsx')) || /^use-task-page-.*\.ts$/.test(name) || TASK_PAGE_EXTRACTED_LOWERCASE_FILES.has(name) ) diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts index 5465926518c..ae3bc60df21 100644 --- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { readTaskPageSource } from './task-page-source-family.test-support' -const CONTENT_SOURCE = readTaskPageSource('TaskPageContent.tsx') -const SOURCE_BAR_SOURCE = readTaskPageSource('TaskPageSourceBar.tsx') +const CONTENT_SOURCE = readTaskPageSource('task-page/Content.tsx') +const SOURCE_BAR_SOURCE = readTaskPageSource('task-page/SourceBar.tsx') const SOURCE_CONTEXT_SOURCE = readTaskPageSource('task-page-source-context.tsx') const RUNTIME_HOSTS_SOURCE = readTaskPageSource('use-task-page-runtime-hosts.ts') const GITHUB_DETAIL_SOURCE = readTaskPageSource('use-task-page-github-detail.ts') diff --git a/src/renderer/src/components/task-page-task-creation-drafts.test.ts b/src/renderer/src/components/task-page-task-creation-drafts.test.ts index d69257347d7..969c1b0c373 100644 --- a/src/renderer/src/components/task-page-task-creation-drafts.test.ts +++ b/src/renderer/src/components/task-page-task-creation-drafts.test.ts @@ -7,19 +7,19 @@ import { const taskPageSource = readTaskPageSourceFamily() const linearCreateDialogsSource = readFileSync( - new URL('./task-page/hooks/use-task-page-linear-create-dialogs.ts', import.meta.url), + new URL('./use-task-page-linear-creation-state.ts', import.meta.url), 'utf8' ) const jiraCreateDialogSource = readFileSync( - new URL('./task-page/hooks/use-task-page-jira-create-dialog.tsx', import.meta.url), + new URL('./use-task-page-jira-creation-state.ts', import.meta.url), 'utf8' ) const jiraCreateSubmitSource = readFileSync( - new URL('./task-page/hooks/use-task-page-create-jira-submit.ts', import.meta.url), + new URL('./use-task-page-jira-issue-creation.ts', import.meta.url), 'utf8' ) const draftWriterSource = readFileSync( - new URL('./task-page/dialogs/task-creation-draft-writers.ts', import.meta.url), + new URL('./task-page-draft-storage.tsx', import.meta.url), 'utf8' ) const draftRetentionSource = [linearCreateDialogsSource, jiraCreateDialogSource].join('\n') @@ -49,11 +49,11 @@ describe('TaskPage Linear/Jira creation drafts', () => { it('restores dismissed typed text when each dialog reopens', () => { const linearFilters = readFileSync( - new URL('./task-page/chrome/task-page-linear-filters.tsx', import.meta.url), + new URL('./task-page/linear/Filters.tsx', import.meta.url), 'utf8' ) const jiraFilters = readFileSync( - new URL('./task-page/chrome/task-page-jira-filters.tsx', import.meta.url), + new URL('./task-page/jira/Filters.tsx', import.meta.url), 'utf8' ) expect(linearFilters).toContain("setNewLinearProjectName(draft?.name ?? '')") @@ -92,7 +92,7 @@ describe('TaskPage Linear/Jira creation drafts', () => { const jiraIssueSection = sectionBetween( jiraCreateSubmitSource, 'const handleCreateNewJiraIssue', - 'return { handleCreateNewJiraIssue }' + 'const nextModel' ) expect(jiraIssueSection).toContain('} catch (error) {') expect(jiraIssueSection).toContain( diff --git a/src/renderer/src/components/task-page/source/task-source-host-availability.test.ts b/src/renderer/src/components/task-page-task-source-host-availability.test.ts similarity index 91% rename from src/renderer/src/components/task-page/source/task-source-host-availability.test.ts rename to src/renderer/src/components/task-page-task-source-host-availability.test.ts index e0a0d595e8e..3fe0261beaf 100644 --- a/src/renderer/src/components/task-page/source/task-source-host-availability.test.ts +++ b/src/renderer/src/components/task-page-task-source-host-availability.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version' -import type { ExecutionHostRegistryEntry } from '../../../../../shared/execution-host-registry' -import { getTaskSourceHostAvailabilityForHost } from './task-source-host-availability' +import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { getTaskSourceHostAvailabilityForHost } from './task-page-source-context' // Why: a complete typed entry keeps these cases honest when the registry shape changes. function hostEntry( diff --git a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts index 7d1c9625efb..a16128eee76 100644 --- a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts +++ b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts @@ -9,10 +9,6 @@ import { const TASK_PAGE_SOURCE = readTaskPageSourceFamily() const WORKSPACE_ACTIONS_SOURCE = readTaskPageSource('use-task-page-workspace-actions.ts') const COMPOSER_ACTIONS_SOURCE = readTaskPageSource('use-task-page-composer-actions.ts') -const USE_ITEM_ACTIONS_SOURCE = readFileSync( - join(__dirname, 'task-page/hooks/use-task-page-use-item-actions.ts'), - 'utf8' -) const PROJECT_VIEW_SOURCE = readFileSync( join(__dirname, 'github-project', 'ProjectViewWrapper.tsx'), 'utf8' @@ -79,7 +75,7 @@ describe('TaskPage workspace creation source boundaries', () => { expect(section).toContain("recordFeatureInteraction('github-tasks')") expect(section).toContain('openComposerForItem(item)') expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground') - expect(USE_ITEM_ACTIONS_SOURCE).not.toContain('@/lib/github-work-item-background-create') + expect(WORKSPACE_ACTIONS_SOURCE).not.toContain('@/lib/github-work-item-background-create') expect(TASK_PAGE_SOURCE).not.toContain('@/lib/github-work-item-background-create') }) @@ -118,11 +114,8 @@ describe('TaskPage workspace creation source boundaries', () => { }) it('uses the shared composer handler from GitHub detail and start-new actions', () => { - const detail = readFileSync(join(__dirname, 'task-page/github/github-detail-host.tsx'), 'utf8') - const actions = readFileSync( - join(__dirname, 'task-page/github/github-work-item-row-actions.tsx'), - 'utf8' - ) + const detail = readFileSync(join(__dirname, 'task-page/Content.tsx'), 'utf8') + const actions = readFileSync(join(__dirname, 'task-page/github/Rows.tsx'), 'utf8') expect(detail.match(/onUse=\{\(item\) => \{/g)).toHaveLength(2) expect(actions.match(/onSelect=\{\(\) => handleUseWorkItem\(item\)\}/g)).toHaveLength(2) }) diff --git a/src/renderer/src/components/TaskPageContent.tsx b/src/renderer/src/components/task-page/Content.tsx similarity index 87% rename from src/renderer/src/components/TaskPageContent.tsx rename to src/renderer/src/components/task-page/Content.tsx index 7c6419c6742..ebe40e3ff28 100644 --- a/src/renderer/src/components/TaskPageContent.tsx +++ b/src/renderer/src/components/task-page/Content.tsx @@ -1,11 +1,11 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' import PullRequestPage from '@/components/PullRequestPage' import GitHubItemDialog from '@/components/GitHubItemDialog' import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper' -import { TaskPageGitHubList } from './TaskPageGitHubList' -import { TaskPageGitLabTodoList } from './TaskPageGitLabTodoList' -import { TaskPageGitLabItemList } from './TaskPageGitLabItemList' -import { TaskPageJiraContent } from './TaskPageJiraContent' +import { TaskPageGitHubList } from './github/List' +import { TaskPageGitLabTodoList } from './gitlab/TodoList' +import { TaskPageGitLabItemList } from './gitlab/ItemList' +import { TaskPageJiraContent } from './jira/Content' export function TaskPageContent({ model }: { diff --git a/src/renderer/src/components/TaskPageFrame.tsx b/src/renderer/src/components/task-page/Frame.tsx similarity index 73% rename from src/renderer/src/components/TaskPageFrame.tsx rename to src/renderer/src/components/task-page/Frame.tsx index 07d86bc07e2..5bcd2b05530 100644 --- a/src/renderer/src/components/TaskPageFrame.tsx +++ b/src/renderer/src/components/task-page/Frame.tsx @@ -1,6 +1,6 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' -import { TaskPageListChrome } from './TaskPageListChrome' -import { TaskPageContent } from './TaskPageContent' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import { TaskPageListChrome } from './ListChrome' +import { TaskPageContent } from './Content' export function TaskPageFrame({ model }: { diff --git a/src/renderer/src/components/TaskPageListChrome.tsx b/src/renderer/src/components/task-page/ListChrome.tsx similarity index 80% rename from src/renderer/src/components/TaskPageListChrome.tsx rename to src/renderer/src/components/task-page/ListChrome.tsx index cf3e441b428..b103fae7624 100644 --- a/src/renderer/src/components/TaskPageListChrome.tsx +++ b/src/renderer/src/components/task-page/ListChrome.tsx @@ -1,9 +1,9 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' import { cn } from '@/lib/utils' -import { TaskPageSourceBar } from './TaskPageSourceBar' +import { TaskPageSourceBar } from './SourceBar' import { AlertCircle } from 'lucide-react' -import { TaskPageGitHubModeControls } from './TaskPageGitHubModeControls' -import { TaskPageProviderFilters } from './TaskPageProviderFilters' +import { TaskPageGitHubModeControls } from './github/ModeControls' +import { TaskPageProviderFilters } from './ProviderFilters' export function TaskPageListChrome({ model }: { diff --git a/src/renderer/src/components/TaskPagePaginationBar.tsx b/src/renderer/src/components/task-page/PaginationBar.tsx similarity index 100% rename from src/renderer/src/components/TaskPagePaginationBar.tsx rename to src/renderer/src/components/task-page/PaginationBar.tsx diff --git a/src/renderer/src/components/TaskPageProviderFilters.tsx b/src/renderer/src/components/task-page/ProviderFilters.tsx similarity index 68% rename from src/renderer/src/components/TaskPageProviderFilters.tsx rename to src/renderer/src/components/task-page/ProviderFilters.tsx index 99dd252a051..41da2eec700 100644 --- a/src/renderer/src/components/TaskPageProviderFilters.tsx +++ b/src/renderer/src/components/task-page/ProviderFilters.tsx @@ -1,8 +1,8 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' -import { TaskPageGitHubFilters } from './TaskPageGitHubFilters' -import { TaskPageLinearFilters } from './TaskPageLinearFilters' -import { TaskPageJiraFilters } from './TaskPageJiraFilters' -import { TaskPageGitLabFilters } from './TaskPageGitLabFilters' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import { TaskPageGitHubFilters } from './github/Filters' +import { TaskPageLinearFilters } from './linear/Filters' +import { TaskPageJiraFilters } from './jira/Filters' +import { TaskPageGitLabFilters } from './gitlab/Filters' export function TaskPageProviderFilters({ model }: { diff --git a/src/renderer/src/components/TaskPageSourceBar.tsx b/src/renderer/src/components/task-page/SourceBar.tsx similarity index 99% rename from src/renderer/src/components/TaskPageSourceBar.tsx rename to src/renderer/src/components/task-page/SourceBar.tsx index b60c5eb0b66..8bcedea82a5 100644 --- a/src/renderer/src/components/TaskPageSourceBar.tsx +++ b/src/renderer/src/components/task-page/SourceBar.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/TaskPageSurface.tsx b/src/renderer/src/components/task-page/Surface.tsx similarity index 50% rename from src/renderer/src/components/TaskPageSurface.tsx rename to src/renderer/src/components/task-page/Surface.tsx index d8dfd1f570d..790dab21bcd 100644 --- a/src/renderer/src/components/TaskPageSurface.tsx +++ b/src/renderer/src/components/task-page/Surface.tsx @@ -1,12 +1,12 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' -import { TaskPageFrame } from './TaskPageFrame' -import { TaskPageGitHubIssueDialog } from './TaskPageGitHubIssueDialog' -import { TaskPageLinearProjectDialog } from './TaskPageLinearProjectDialog' -import { TaskPageLinearIssueDialog } from './TaskPageLinearIssueDialog' -import { TaskPageJiraIssueDialog } from './TaskPageJiraIssueDialog' -import { TaskPageGitLabDialog } from './TaskPageGitLabDialog' -import { TaskPageLinearConnectDialog } from './TaskPageLinearConnectDialog' -import { TaskPageJiraConnectDialog } from './TaskPageJiraConnectDialog' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import { TaskPageFrame } from './Frame' +import { TaskPageGitHubIssueDialog } from './github/IssueDialog' +import { TaskPageLinearProjectDialog } from './linear/ProjectDialog' +import { TaskPageLinearIssueDialog } from './linear/IssueDialog' +import { TaskPageJiraIssueDialog } from './jira/IssueDialog' +import { TaskPageGitLabDialog } from './gitlab/Dialog' +import { TaskPageLinearConnectDialog } from './linear/ConnectDialog' +import { TaskPageJiraConnectDialog } from './jira/ConnectDialog' export function TaskPageSurface({ model }: { diff --git a/src/renderer/src/components/task-page/TaskPage.tsx b/src/renderer/src/components/task-page/TaskPage.tsx new file mode 100644 index 00000000000..c80e7aa8f38 --- /dev/null +++ b/src/renderer/src/components/task-page/TaskPage.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { useTaskPageStoreBindings } from '../use-task-page-store-bindings' +import { useTaskPageRepoSelection } from '../use-task-page-repo-selection' +import { useTaskPageRuntimeHosts } from '../use-task-page-runtime-hosts' +import { useTaskPageSourceAvailability } from '../use-task-page-source-availability' +import { useTaskPageProviderState } from '../use-task-page-provider-state' +import { useTaskPageGitHubListState } from '../use-task-page-github-list-state' +import { useTaskPageGitHubDetail } from '../use-task-page-github-detail' +import { useTaskPageGitHubCacheReconciliation } from '../use-task-page-github-cache-reconciliation' +import { useTaskPageGitHubIssueDraft } from '../use-task-page-github-issue-draft' +import { useTaskPageDetailRouting } from '../use-task-page-detail-routing' +import { useTaskPageLinearViewState } from '../use-task-page-linear-view-state' +import { useTaskPageJiraListState } from '../use-task-page-jira-list-state' +import { useTaskPageResumeRestoration } from '../use-task-page-resume-restoration' +import { useTaskPageProviderMetadata } from '../use-task-page-provider-metadata' +import { useTaskPageGitLabLoading } from '../use-task-page-gitlab-loading' +import { useTaskPageLinearListSelection } from '../use-task-page-linear-list-selection' +import { useTaskPageLinearListProjection } from '../use-task-page-linear-list-projection' +import { useTaskPageLinearBoard } from '../use-task-page-linear-board' +import { useTaskPageJiraListProjection } from '../use-task-page-jira-list-projection' +import { useTaskPageLinearCreationState } from '../use-task-page-linear-creation-state' +import { useTaskPageGitHubMutationState } from '../use-task-page-github-mutation-state' +import { useTaskPageJiraCreationState } from '../use-task-page-jira-creation-state' +import { useTaskPageJiraCreationMetadata } from '../use-task-page-jira-creation-metadata' +import { useTaskPageGitHubListProjection } from '../use-task-page-github-list-projection' +import { useTaskPageGitHubSearchPagination } from '../use-task-page-github-search-pagination' +import { useTaskPageGitHubLandingRefresh } from '../use-task-page-github-landing-refresh' +import { useTaskPageGitHubQuietRefresh } from '../use-task-page-github-quiet-refresh' +import { useTaskPageSearchActions } from '../use-task-page-search-actions' +import { useTaskPageWorkspaceActions } from '../use-task-page-workspace-actions' +import { useTaskPageGitHubIssueCreation } from '../use-task-page-github-issue-creation' +import { useTaskPageLinearProjectCreation } from '../use-task-page-linear-project-creation' +import { useTaskPageLinearIssueCreation } from '../use-task-page-linear-issue-creation' +import { useTaskPageJiraIssueCreation } from '../use-task-page-jira-issue-creation' +import { useTaskPageGlobalEffects } from '../use-task-page-global-effects' +import { useTaskPageLinearListEffects } from '../use-task-page-linear-list-effects' +import { useTaskPageLinearInOrcaEffects } from '../use-task-page-linear-in-orca-effects' +import { useTaskPageLinearCollectionEffects } from '../use-task-page-linear-collection-effects' +import { useTaskPageJiraListEffects } from '../use-task-page-jira-list-effects' +import { useTaskPageComposerActions } from '../use-task-page-composer-actions' +import { TaskPageSurface } from './Surface' + +export default function TaskPage(): React.JSX.Element { + const stage1 = useTaskPageStoreBindings() + const stage2 = useTaskPageRepoSelection(stage1) + const stage3 = useTaskPageRuntimeHosts(stage2) + const stage4 = useTaskPageSourceAvailability(stage3) + const stage5 = useTaskPageProviderState(stage4) + const stage6 = useTaskPageGitHubListState(stage5) + const stage7 = useTaskPageGitHubDetail(stage6) + const stage8 = useTaskPageGitHubCacheReconciliation(stage7) + const stage9 = useTaskPageGitHubIssueDraft(stage8) + const stage10 = useTaskPageDetailRouting(stage9) + const stage11 = useTaskPageLinearViewState(stage10) + const stage12 = useTaskPageJiraListState(stage11) + const stage13 = useTaskPageResumeRestoration(stage12) + const stage14 = useTaskPageProviderMetadata(stage13) + const stage15 = useTaskPageGitLabLoading(stage14) + const stage16 = useTaskPageLinearListSelection(stage15) + const stage17 = useTaskPageLinearListProjection(stage16) + const stage18 = useTaskPageLinearBoard(stage17) + const stage19 = useTaskPageJiraListProjection(stage18) + const stage20 = useTaskPageLinearCreationState(stage19) + const stage21 = useTaskPageGitHubMutationState(stage20) + const stage22 = useTaskPageJiraCreationState(stage21) + const stage23 = useTaskPageJiraCreationMetadata(stage22) + const stage24 = useTaskPageGitHubListProjection(stage23) + const stage25 = useTaskPageGitHubSearchPagination(stage24) + const stage26 = useTaskPageGitHubLandingRefresh(stage25) + const stage27 = useTaskPageGitHubQuietRefresh(stage26) + const stage28 = useTaskPageSearchActions(stage27) + const stage29 = useTaskPageWorkspaceActions(stage28) + const stage30 = useTaskPageGitHubIssueCreation(stage29) + const stage31 = useTaskPageLinearProjectCreation(stage30) + const stage32 = useTaskPageLinearIssueCreation(stage31) + const stage33 = useTaskPageJiraIssueCreation(stage32) + const stage34 = useTaskPageGlobalEffects(stage33) + const stage35 = useTaskPageLinearListEffects(stage34) + const stage36 = useTaskPageLinearInOrcaEffects(stage35) + const stage37 = useTaskPageLinearCollectionEffects(stage36) + const stage38 = useTaskPageJiraListEffects(stage37) + const stage39 = useTaskPageComposerActions(stage38) + return +} diff --git a/src/renderer/src/components/task-page/chrome/task-page-github-item-filters.tsx b/src/renderer/src/components/task-page/chrome/task-page-github-item-filters.tsx deleted file mode 100644 index cf6b3b0585f..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-github-item-filters.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import React from 'react' -import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react' - -import { useAppStore } from '@/store' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' -import IssueSourceIndicator from '@/components/github/IssueSourceIndicator' -import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' -import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFilterDropdowns' -import { - getGitHubTaskKindPresets, - type GitHubTaskKind -} from '@/components/task-page-localized-options' -import { resolveNewIssueOpenSeed } from '@/components/task-page-new-issue-draft' -import type { TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { - GitHubAssignableUser, - GitHubOwnerRepo -} from '../../../../../shared/github/pull-request-types' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { IssueSourcePreference, Repo } from '../../../../../shared/repo-types' -import type { ParsedTaskQuery } from '../../../../../shared/task-query' -import type { TaskResumeState, TaskViewPresetId } from '../../../../../shared/ui-chrome-types' -import { - hasDivergentSources, - hasUpstreamCandidateDivergence -} from '@/components/task-page/source/repo-source-divergence' - -export type TaskPageGithubItemFiltersProps = { - activeGithubTaskKind: GitHubTaskKind - activeTaskPreset: TaskViewPresetId | null - setTaskSearchInput: (value: string) => void - setAppliedTaskSearch: (value: string) => void - setActiveTaskPreset: (preset: TaskViewPresetId | null) => void - setTaskResumeState: (updates: Partial) => void - setTaskRefreshNonce: React.Dispatch> - handleSetDefaultTaskPreset: (presetId: TaskViewPresetId) => void - appliedTaskQuery: ParsedTaskQuery - loadedGitHubAuthorLogins: string[] - primaryGithubFilterSlug: GitHubOwnerRepo | null - settings: Pick | null - applyPRFilterChange: (change: PRFilterChange) => void - taskSearchInputRef: React.RefObject - taskSearchInput: string - handleTaskSearchChange: (event: React.ChangeEvent) => void - handleTaskSearchKeyDown: (event: React.KeyboardEvent) => void - appliedTaskSearch: string - handleResetGithubTaskSearch: () => void - selectedRepos: Repo[] - setNewIssueTitle: (value: string) => void - setNewIssueBody: (value: string) => void - setNewIssueLabels: (labels: string[]) => void - setNewIssueAssignees: (assignees: GitHubAssignableUser[]) => void - setNewIssueRepoId: (repoId: string | null) => void - setNewIssueOpen: (open: boolean) => void - newIssueTargetRepo: Repo | null - handleRefreshGithubTasks: () => void - githubTasksBusy: boolean - perRepoSourceState: TaskPageRepoSourceState[] - setIssueSourcePreference: ( - repoId: string, - repoPath: string, - preference: IssueSourcePreference - ) => Promise -} - -export function TaskPageGithubItemFilters({ - activeGithubTaskKind, - activeTaskPreset, - setTaskSearchInput, - setAppliedTaskSearch, - setActiveTaskPreset, - setTaskResumeState, - setTaskRefreshNonce, - handleSetDefaultTaskPreset, - appliedTaskQuery, - loadedGitHubAuthorLogins, - primaryGithubFilterSlug, - settings, - applyPRFilterChange, - taskSearchInputRef, - taskSearchInput, - handleTaskSearchChange, - handleTaskSearchKeyDown, - appliedTaskSearch, - handleResetGithubTaskSearch, - selectedRepos, - setNewIssueTitle, - setNewIssueBody, - setNewIssueLabels, - setNewIssueAssignees, - setNewIssueRepoId, - setNewIssueOpen, - newIssueTargetRepo, - handleRefreshGithubTasks, - githubTasksBusy, - perRepoSourceState, - setIssueSourcePreference -}: TaskPageGithubItemFiltersProps): React.JSX.Element { - return ( - // Why: top of the joined GitHub list card — pairs with the - // table shell below (rounded-t-none border-t-0) as one surface. -
-
- {getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => { - const active = activeTaskPreset === option.id - return ( - - ) - })} -
-
- applyPRFilterChange(change)} - /> -
- - - {taskSearchInput || appliedTaskSearch ? ( - - ) : null} -
-
- - - - - - {translate('auto.components.TaskPage.d3d0998b7d', 'New GitHub issue')} - - - - - - - - {githubTasksBusy - ? translate('auto.components.TaskPage.31f81cc334', 'Refreshing GitHub work…') - : translate('auto.components.TaskPage.ff53631e6f', 'Refresh GitHub work')} - - -
-
- - {(() => { - // Why: show the source-slug chip only when the selector can't render (no upstream to toggle); otherwise it duplicates the selector. - const rows = perRepoSourceState.filter( - (s) => hasUpstreamCandidateDivergence(s) || hasDivergentSources(s) - ) - if (rows.length === 0) { - return null - } - return ( -
- {rows.map((s) => { - const repo = selectedRepos.find((r) => r.id === s.repoId) - const showRepoBadgeLabel = selectedRepos.length > 1 && repo - const selectorRenderable = hasUpstreamCandidateDivergence(s) - // Why: render the indicator standalone — it has its own chip styles, so nesting it in our chip would double-border it. - if (!selectorRenderable && hasDivergentSources(s)) { - return ( - - ) - } - if (!selectorRenderable || !repo) { - return null - } - // Why:
not — the child selector renders a block
(div-in-span is invalid HTML); inline-flex class looks identical. - return ( -
- {showRepoBadgeLabel ? ( - - ) : null} - { - void setIssueSourcePreference(repo.id, repo.path, next) - }} - /> -
- ) - })} -
- ) - })()} -
- ) -} diff --git a/src/renderer/src/components/task-page/chrome/task-page-github-mode-bar.tsx b/src/renderer/src/components/task-page/chrome/task-page-github-mode-bar.tsx deleted file mode 100644 index bd875ed948c..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-github-mode-bar.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React from 'react' -import { ExternalLink } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' -import { normalizeTaskRepoSelection } from '@/components/task-page-default-repo-selection' -import type { TaskProjectPickerGroup } from '@/components/task-page-default-repo-selection' -import type { GitHubModeButton, GitHubTaskKind } from '@/components/task-page-localized-options' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { Repo } from '../../../../../shared/repo-types' -import type { TaskResumeState } from '../../../../../shared/ui-chrome-types' - -export type TaskPageGithubModeBarProps = { - projectModeVisible: boolean - githubModeButtons: GitHubModeButton[] - githubMode: 'items' | 'project' - activeGithubTaskKind: GitHubTaskKind - setGithubMode: (mode: 'items' | 'project') => void - setTaskResumeState: (updates: Partial) => void - handleSelectGithubTaskKind: (kind: GitHubTaskKind) => void - taskPickerGroups: TaskProjectPickerGroup[] - repoSelection: ReadonlySet - getTaskPickerRepoHostLabel: (repo: Repo) => string | null | undefined - eligibleRepos: Repo[] - setRepoSelection: (next: Set) => void - updateSettings: (updates: Partial) => Promise - taskPickerRepos: Repo[] - selectedGitHubRepoExternalLink: { url: string; label: string } | null -} - -export function TaskPageGithubModeBar({ - projectModeVisible, - githubModeButtons, - githubMode, - activeGithubTaskKind, - setGithubMode, - setTaskResumeState, - handleSelectGithubTaskKind, - taskPickerGroups, - repoSelection, - getTaskPickerRepoHostLabel, - eligibleRepos, - setRepoSelection, - updateSettings, - taskPickerRepos, - selectedGitHubRepoExternalLink -}: TaskPageGithubModeBarProps): React.JSX.Element { - return ( -
- {projectModeVisible ? ( -
- {githubModeButtons.map((mode) => { - const active = - mode.id === 'project' - ? githubMode === 'project' - : githubMode === 'items' && activeGithubTaskKind === mode.id - return ( - - ) - })} -
- ) : null} - {/* Why: Project rows are repo-scoped, so the selection must stay visible in both GitHub modes. */} -
- { - const normalized = normalizeTaskRepoSelection(eligibleRepos, next) - setRepoSelection(normalized) - void updateSettings({ defaultRepoSelection: [...normalized] }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) - ) - }) - }} - onSelectAll={() => { - const allIds = new Set(taskPickerRepos.map((r) => r.id)) - setRepoSelection(allIds) - void updateSettings({ defaultRepoSelection: null }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) - ) - }) - }} - triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" - /> -
- - - - - - {selectedGitHubRepoExternalLink - ? translate('auto.components.TaskPage.8d1e17a3ef', 'Open {{value0}} in GitHub', { - value0: selectedGitHubRepoExternalLink.label - }) - : translate( - 'auto.components.TaskPage.bc46d8204e', - 'Select one project to open in GitHub' - )} - - -
- ) -} diff --git a/src/renderer/src/components/task-page/chrome/task-page-gitlab-filters.tsx b/src/renderer/src/components/task-page/chrome/task-page-gitlab-filters.tsx deleted file mode 100644 index 3552d414fa7..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-gitlab-filters.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React from 'react' -import { LoaderCircle, RefreshCw } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' -import { normalizeTaskRepoSelection } from '@/components/task-page-default-repo-selection' -import type { TaskProjectPickerGroup } from '@/components/task-page-default-repo-selection' -import type { GitLabIssueFilter, GitLabTaskFilter } from '@/components/task-page-localized-options' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { Repo } from '../../../../../shared/repo-types' - -export type TaskPageGitlabFiltersProps = { - gitlabView: 'issues' | 'mrs' | 'todos' - setGitlabView: (view: 'issues' | 'mrs' | 'todos') => void - taskPickerGroups: TaskProjectPickerGroup[] - repoSelection: ReadonlySet - getTaskPickerRepoHostLabel: (repo: Repo) => string | null | undefined - eligibleRepos: Repo[] - setRepoSelection: (next: Set) => void - updateSettings: (updates: Partial) => Promise - taskPickerRepos: Repo[] - gitLabIssueFilters: { id: GitLabIssueFilter; label: string }[] - gitLabMRFilters: { id: GitLabTaskFilter; label: string }[] - activeGitlabFilter: GitLabTaskFilter | GitLabIssueFilter - setGitlabFilter: (id: GitLabTaskFilter | GitLabIssueFilter) => void - setGitlabRefreshNonce: React.Dispatch> - gitlabLoading: boolean - gitlabTodosLoading: boolean -} - -export function TaskPageGitlabFilters({ - gitlabView, - setGitlabView, - taskPickerGroups, - repoSelection, - getTaskPickerRepoHostLabel, - eligibleRepos, - setRepoSelection, - updateSettings, - taskPickerRepos, - gitLabIssueFilters, - gitLabMRFilters, - activeGitlabFilter, - setGitlabFilter, - setGitlabRefreshNonce, - gitlabLoading, - gitlabTodosLoading -}: TaskPageGitlabFiltersProps): React.JSX.Element { - return ( - <> -
-
- {(['issues', 'mrs', 'todos'] as const).map((view) => { - const active = gitlabView === view - const label = - view === 'issues' - ? translate('auto.components.TaskPage.dfc0c79bd8', 'Issues') - : view === 'mrs' - ? translate( - 'auto.components.task.page.chrome.task.page.gitlab.filters.e157d7ce4d', - 'MRs' - ) - : translate( - 'auto.components.task.page.chrome.task.page.gitlab.filters.2328f6a40c', - 'My Todos' - ) - return ( - - ) - })} -
-
- { - const normalized = normalizeTaskRepoSelection(eligibleRepos, next) - setRepoSelection(normalized) - void updateSettings({ defaultRepoSelection: [...normalized] }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) - ) - }) - }} - onSelectAll={() => { - const allIds = new Set(taskPickerRepos.map((r) => r.id)) - setRepoSelection(allIds) - void updateSettings({ defaultRepoSelection: null }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) - ) - }) - }} - triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" - /> -
-
-
-
-
-
- {gitlabView === 'issues' || gitlabView === 'mrs' - ? (gitlabView === 'issues' ? gitLabIssueFilters : gitLabMRFilters).map( - ({ id, label }) => { - const active = activeGitlabFilter === id - return ( - - ) - } - ) - : null} -
-
-
- - - - - - {gitlabView === 'todos' - ? translate('auto.components.TaskPage.c679af7ad9', 'Refresh My Todos') - : translate('auto.components.TaskPage.d4c2830063', 'Refresh GitLab work items')} - - -
-
-
- - ) -} diff --git a/src/renderer/src/components/task-page/chrome/task-page-jira-filters.tsx b/src/renderer/src/components/task-page/chrome/task-page-jira-filters.tsx deleted file mode 100644 index c133a5b18bf..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-jira-filters.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React from 'react' -import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react' - -import { useAppStore } from '@/store' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection' -import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { JiraProject } from '../../../../../shared/jira-types' -import type { TaskResumeState } from '../../../../../shared/ui-chrome-types' -import type { JiraPreset } from '@/components/task-page-localized-options' - -export type TaskPageJiraFiltersProps = { - jiraPresets: JiraPreset[] - jiraSearchInput: string - activeJiraPreset: JiraPreset['id'] - setJiraSearchInput: (value: string) => void - setAppliedJiraSearch: (value: string) => void - setActiveJiraPreset: (preset: JiraPreset['id']) => void - setTaskResumeState: (updates: Partial) => void - setJiraRefreshNonce: React.Dispatch> - sortedAvailableJiraProjects: JiraProject[] - setNewJiraIssueTitle: (value: string) => void - setNewJiraIssueBody: (value: string) => void - setNewJiraIssueProjectId: (id: string | null) => void - setNewJiraIssueProjectQuery: (value: string) => void - setNewJiraIssueProjectCommandValue: (value: string) => void - setNewJiraIssueTypeId: (id: string | null) => void - setNewJiraIssueOpen: (open: boolean) => void - jiraProjectsLoading: boolean - jiraLoading: boolean -} - -export function TaskPageJiraFilters({ - jiraPresets, - jiraSearchInput, - activeJiraPreset, - setJiraSearchInput, - setAppliedJiraSearch, - setActiveJiraPreset, - setTaskResumeState, - setJiraRefreshNonce, - sortedAvailableJiraProjects, - setNewJiraIssueTitle, - setNewJiraIssueBody, - setNewJiraIssueProjectId, - setNewJiraIssueProjectQuery, - setNewJiraIssueProjectCommandValue, - setNewJiraIssueTypeId, - setNewJiraIssueOpen, - jiraProjectsLoading, - jiraLoading -}: TaskPageJiraFiltersProps): React.JSX.Element { - return ( -
-
-
- {jiraPresets.map((preset) => { - const active = !jiraSearchInput && activeJiraPreset === preset.id - return ( - - ) - })} -
-
- - - - - - {translate('auto.components.TaskPage.0c11ca0b6d', 'New Jira issue')} - - - - - - - - {translate('auto.components.TaskPage.2ff9fd71fd', 'Refresh Jira issues')} - - -
-
-
-
- - setJiraSearchInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - if ( - shouldSuppressEnterSubmit( - { isComposing: e.nativeEvent.isComposing, shiftKey: e.shiftKey }, - false - ) - ) { - return - } - e.preventDefault() - const trimmed = jiraSearchInput.trim() - setJiraSearchInput(trimmed) - setAppliedJiraSearch(trimmed) - setTaskResumeState({ jiraQuery: trimmed }) - setJiraRefreshNonce((n) => n + 1) - } - }} - placeholder={translate( - 'auto.components.TaskPage.99c2755218', - 'Jira JQL, e.g. project = ABC AND statusCategory != Done' - )} - className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs" - /> - {jiraSearchInput ? ( - - ) : null} -
-
-
- ) -} diff --git a/src/renderer/src/components/task-page/chrome/task-page-linear-filters.tsx b/src/renderer/src/components/task-page/chrome/task-page-linear-filters.tsx deleted file mode 100644 index 56c734aaf21..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-linear-filters.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import React from 'react' -import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react' - -import { useAppStore } from '@/store' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' -import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' -import type { LinearProjectSummary } from '../../../../../shared/linear/project-types' -import type { LinearTeam } from '../../../../../shared/linear/workspace-types' -import type { TaskResumeState } from '../../../../../shared/ui-chrome-types' -import type { TaskSourceContext } from '../../../../../shared/task-source-context' -import type { LinearMode } from '@/components/task-page-localized-options' -import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' - -export type TaskPageLinearFiltersProps = { - linearModeOptions: { id: LinearMode; label: string }[] - linearMode: LinearMode - selectLinearMode: (mode: LinearMode) => void - selectedLinearProject: LinearProjectSummary | null - availableTeams: LinearTeam[] - setNewLinearProjectName: (value: string) => void - setNewLinearProjectDescription: (value: string) => void - setNewLinearProjectContent: (value: string) => void - setNewLinearProjectTeamId: (id: string | null) => void - setNewLinearProjectLeadId: (id: string | null) => void - setNewLinearProjectMemberIds: (ids: string[]) => void - setNewLinearProjectLabelIds: (ids: string[]) => void - setNewLinearProjectPriority: (priority: number) => void - setNewLinearProjectStartDate: (value: string) => void - setNewLinearProjectTargetDate: (value: string) => void - setNewLinearProjectOpen: (open: boolean) => void - setNewLinearIssueTitle: (value: string) => void - setNewLinearIssueBody: (value: string) => void - setNewLinearIssueTeamId: (id: string | null) => void - setNewLinearIssueProjectId: (id: string | null) => void - setNewLinearIssueOpen: (open: boolean) => void - setLinearRefreshNonce: React.Dispatch> - linearLoading: boolean - linearProjectsLoading: boolean - linearProjectDetailLoading: boolean - linearCustomViewsLoading: boolean - linearCustomViewContentsLoading: boolean - showLinearAttributeFilters: boolean - linearAttributeFilter: LinearIssueAttributeFilter - applyLinearAttributeFilter: (next: LinearIssueAttributeFilter) => void - linearAttributeFilterWorkspaceId: string | null - linearAttributePrimaryTeam: LinearTeam | null - linearTeamSelection: ReadonlySet - linearTeamOptions: LinearTeam[] - linearTaskSourceContext: TaskSourceContext | null - settings: RuntimeLinearSettings | null - linearSearchInput: string - setLinearSearchInput: (value: string) => void - setAppliedLinearSearch: (value: string) => void - setTaskResumeState: (updates: Partial) => void - linearProjectSearchInput: string - setLinearProjectSearchInput: (value: string) => void - setAppliedLinearProjectSearch: (value: string) => void -} - -export function TaskPageLinearFilters(props: TaskPageLinearFiltersProps): React.JSX.Element { - const { - linearModeOptions, - linearMode, - selectLinearMode, - selectedLinearProject, - availableTeams, - setNewLinearProjectName, - setNewLinearProjectDescription, - setNewLinearProjectContent, - setNewLinearProjectTeamId, - setNewLinearProjectLeadId, - setNewLinearProjectMemberIds, - setNewLinearProjectLabelIds, - setNewLinearProjectPriority, - setNewLinearProjectStartDate, - setNewLinearProjectTargetDate, - setNewLinearProjectOpen, - setNewLinearIssueTitle, - setNewLinearIssueBody, - setNewLinearIssueTeamId, - setNewLinearIssueProjectId, - setNewLinearIssueOpen, - setLinearRefreshNonce, - linearLoading, - linearProjectsLoading, - linearProjectDetailLoading, - linearCustomViewsLoading, - linearCustomViewContentsLoading, - showLinearAttributeFilters, - linearAttributeFilter, - applyLinearAttributeFilter, - linearAttributeFilterWorkspaceId, - linearAttributePrimaryTeam, - linearTeamSelection, - linearTeamOptions, - linearTaskSourceContext, - settings, - linearSearchInput, - setLinearSearchInput, - setAppliedLinearSearch, - setTaskResumeState, - linearProjectSearchInput, - setLinearProjectSearchInput, - setAppliedLinearProjectSearch - } = props - - return ( -
-
-
- {linearModeOptions.map((mode) => { - const active = linearMode === mode.id - const buttonClassName = cn( - 'rounded-md border px-2 py-1 text-xs transition', - active - ? 'border-border/50 bg-foreground/90 text-background' - : 'border-border/50 bg-transparent text-foreground hover:bg-muted/50' - ) - if (mode.id === 'in-orca') { - return ( - - - - - - {translate( - 'auto.components.TaskPage.linearModeHasWorktreeTooltip', - 'Linear tickets linked to an Orca workspace' - )} - - - ) - } - return ( - - ) - })} -
-
- - - - - - {linearMode === 'projects' && !selectedLinearProject - ? translate('auto.components.TaskPage.1361275ec3', 'New Linear project') - : translate('auto.components.TaskPage.3feb524d42', 'New Linear issue')} - - - - - - - - {translate('auto.components.TaskPage.8964184a8b', 'Refresh Linear')} - - -
-
- - {linearMode === 'issues' || linearMode === 'in-orca' ? ( -
- {showLinearAttributeFilters ? ( - 0} - settings={linearTaskSourceContext ?? settings} - /> - ) : null} -
- - setLinearSearchInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - if ( - shouldSuppressEnterSubmit( - { isComposing: e.nativeEvent.isComposing, shiftKey: e.shiftKey }, - false - ) - ) { - return - } - e.preventDefault() - const trimmed = linearSearchInput.trim() - setLinearSearchInput(trimmed) - setAppliedLinearSearch(trimmed) - setTaskResumeState({ - linearQuery: trimmed, - linearMode: linearMode === 'in-orca' ? 'in-orca' : 'issues' - }) - if (linearMode !== 'in-orca') { - setLinearRefreshNonce((n) => n + 1) - } - } - }} - placeholder={ - linearMode === 'in-orca' - ? translate( - 'auto.components.TaskPage.linearHasWorktreeSearchPlaceholder', - 'Filter issues linked to an Orca workspace...' - ) - : translate('auto.components.TaskPage.eec0c5c079', 'Search Linear issues...') - } - className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs" - /> - {linearSearchInput ? ( - - ) : null} -
-
- ) : linearMode === 'projects' && !selectedLinearProject ? ( -
-
- - setLinearProjectSearchInput(e.target.value)} - placeholder={translate( - 'auto.components.TaskPage.0b65d3fb2c', - 'Search Linear projects...' - )} - className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs" - /> - {linearProjectSearchInput ? ( - - ) : null} -
-
- ) : null} -
- ) -} diff --git a/src/renderer/src/components/task-page/chrome/task-page-source-toolbar.tsx b/src/renderer/src/components/task-page/chrome/task-page-source-toolbar.tsx deleted file mode 100644 index 2f6909abc8b..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-source-toolbar.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import React from 'react' -import { AlertCircle, ExternalLink, X } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { LinearScopeSelector } from '@/components/linear-scope-selector' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { JiraIssue, JiraSite } from '../../../../../shared/jira-types' -import type { - LinearTeam, - LinearWorkspace, - LinearWorkspaceSelection -} from '../../../../../shared/linear/workspace-types' -import type { TaskProvider } from '../../../../../shared/task-providers' -import type { SourceOption } from '@/components/task-page-localized-options' -import type { TaskPageJiraLoadError } from '@/components/task-page-jira-load-state' -import type { - TaskSourceAvailabilityNotice, - TaskSourceContextSummary -} from '@/components/task-source-context-summary' - -export type TaskPageSourceToolbarProps = { - closeTaskPage: () => void - visibleSourceOptions: SourceOption[] - taskSource: TaskProvider - taskSourceAvailabilityNoticeByProvider: Partial< - Record - > - taskSourceManuallyChangedRef: React.MutableRefObject - openTaskPage: ( - data?: { taskSource?: TaskProvider }, - options?: { recordTasksInteraction?: boolean } - ) => void - updateSettings: (updates: Partial) => Promise - taskSourceContextSummary: TaskSourceContextSummary - linearConnected: boolean - linearWorkspaces: LinearWorkspace[] - selectedLinearWorkspaceId: LinearWorkspaceSelection | null - linearTeamOptions: LinearTeam[] - linearTeamSelection: ReadonlySet - defaultLinearTeamSelection: string[] | null | undefined - onLinearWorkspaceChange: (workspaceId: LinearWorkspaceSelection) => void - onLinearTeamSelectionChange: (next: ReadonlySet, persisted: string[] | null) => void - onOpenLinearConnect: () => void - onLinearScopeOpen: () => void - selectedLinearTeamForExternalLink: LinearTeam | null - jiraConnected: boolean - jiraSites: JiraSite[] - selectedJiraSiteId: string | null - selectJiraSite: (siteId: string) => Promise - setSelectedJiraIssueKey: (key: string | null) => void - setSelectedJiraIssueFallback: (issue: JiraIssue | null) => void - setJiraIssues: (issues: JiraIssue[]) => void - setJiraError: (error: TaskPageJiraLoadError | null) => void - setJiraLoading: (loading: boolean) => void - taskSourceAvailabilityNotice: TaskSourceAvailabilityNotice | null -} - -export function TaskPageSourceToolbar({ - closeTaskPage, - visibleSourceOptions, - taskSource, - taskSourceAvailabilityNoticeByProvider, - taskSourceManuallyChangedRef, - openTaskPage, - updateSettings, - taskSourceContextSummary, - linearConnected, - linearWorkspaces, - selectedLinearWorkspaceId, - linearTeamOptions, - linearTeamSelection, - defaultLinearTeamSelection, - onLinearWorkspaceChange, - onLinearTeamSelectionChange, - onOpenLinearConnect, - onLinearScopeOpen, - selectedLinearTeamForExternalLink, - jiraConnected, - jiraSites, - selectedJiraSiteId, - selectJiraSite, - setSelectedJiraIssueKey, - setSelectedJiraIssueFallback, - setJiraIssues, - setJiraError, - setJiraLoading, - taskSourceAvailabilityNotice -}: TaskPageSourceToolbarProps): React.JSX.Element { - return ( - <> -
-
- {/* Why: Close is anchored left with the source icons for one compact band, clear of the app sidebar on the right. */} - - - - - - {translate('auto.components.TaskPage.4826fd1ad8', 'Close · Esc')} - - -
- {visibleSourceOptions.map((source) => { - const active = taskSource === source.id - const sourceAvailabilityNotice = - taskSourceAvailabilityNoticeByProvider[source.id] ?? null - const sourceDisabled = source.disabled || sourceAvailabilityNotice?.blocking - return ( - - - - - - {sourceAvailabilityNotice?.label ?? source.label} - - - ) - })} -
- {taskSourceContextSummary.label} -
-
- {taskSource === 'linear' && linearConnected ? ( -
- - - - - - - {selectedLinearTeamForExternalLink - ? translate('auto.components.TaskPage.246bd64aed', 'Open {{value0}} in Linear', { - value0: selectedLinearTeamForExternalLink.name - }) - : translate( - 'auto.components.TaskPage.2af3ab5c58', - 'Select one team to open in Linear' - )} - - -
- ) : null} - {taskSource === 'jira' && jiraConnected ? ( -
- {jiraSites.length > 1 ? ( - - ) : null} -
- ) : null} -
- - {taskSourceAvailabilityNotice ? ( -
- - {taskSourceAvailabilityNotice.label} -
- ) : null} - - ) -} diff --git a/src/renderer/src/components/task-page/dialogs/new-github-issue-dialog.tsx b/src/renderer/src/components/task-page/dialogs/new-github-issue-dialog.tsx deleted file mode 100644 index 400b972ed2f..00000000000 --- a/src/renderer/src/components/task-page/dialogs/new-github-issue-dialog.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import React from 'react' -import { LoaderCircle } from 'lucide-react' - -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' -import IssueSourceSelector from '@/components/github/IssueSourceSelector' -import { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' -import type { TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' -import { resolveUserRepoSwitchReset } from '@/components/task-page-new-issue-draft' -import type { MetadataListState } from '@/hooks/useMetadataListRequest' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { translate } from '@/i18n/i18n' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' -import type { IssueSourcePreference, Repo } from '../../../../../shared/repo-types' -import { GitHubIssueAssigneeSelector } from '../github/github-issue-assignee-selector' -import { GitHubIssueLabelSelector } from '../github/github-issue-label-selector' - -export type NewGithubIssueDialogProps = { - newIssueOpen: boolean - newIssueSubmitting: boolean - setNewIssueOpen: (open: boolean) => void - handleCreateNewIssue: () => Promise | void - newIssueTargetRepo: Repo | null - perRepoSourceState: TaskPageRepoSourceState[] - setIssueSourcePreference: ( - repoId: string, - repoPath: string, - preference: IssueSourcePreference - ) => Promise - selectedRepos: readonly Repo[] - newIssueRepoId: string | null - setNewIssueRepoId: (id: string | null) => void - setNewIssueLabels: (labels: string[]) => void - setNewIssueAssignees: (assignees: GitHubAssignableUser[]) => void - newIssueTitle: string - setNewIssueTitle: (value: string) => void - newIssueBody: string - setNewIssueBody: (value: string) => void - newIssueRepoLabels: MetadataListState - newIssueLabels: string[] - newIssueRepoAssignees: MetadataListState - newIssueAssignees: GitHubAssignableUser[] - submitShortcutLabel: string -} - -export function NewGithubIssueDialog({ - newIssueOpen, - newIssueSubmitting, - setNewIssueOpen, - handleCreateNewIssue, - newIssueTargetRepo, - perRepoSourceState, - setIssueSourcePreference, - selectedRepos, - newIssueRepoId, - setNewIssueRepoId, - setNewIssueLabels, - setNewIssueAssignees, - newIssueTitle, - setNewIssueTitle, - newIssueBody, - setNewIssueBody, - newIssueRepoLabels, - newIssueLabels, - newIssueRepoAssignees, - newIssueAssignees, - submitShortcutLabel -}: NewGithubIssueDialogProps): React.JSX.Element { - return ( - { - if (!newIssueSubmitting) { - setNewIssueOpen(open) - } - }} - > - { - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - void handleCreateNewIssue() - } - }} - > - - - {translate('auto.components.TaskPage.d3d0998b7d', 'New GitHub issue')} - - {(() => { - // Why: inline the resolved {owner}/{repo} slug as the source indicator; fall back to displayName when unresolved. - const entry = newIssueTargetRepo - ? perRepoSourceState.find((s) => s.repoId === newIssueTargetRepo.id) - : undefined - const issuesSlug = entry?.sources?.issues - ? `${entry.sources.issues.owner}/${entry.sources.issues.repo}` - : null - const fallback = - newIssueTargetRepo?.displayName ?? - translate( - 'auto.components.task.page.dialogs.new.github.issue.dialog.e02508846c', - 'this repository' - ) - return ( - - {translate('auto.components.TaskPage.9f2b4c03a6', 'Filing in')}{' '} - {issuesSlug ?? fallback} - - ) - })()} - {(() => { - // Why: mirror the Tasks-view target selector so a fork contributor can flip target at filing time (fork-routing regression #1076). - // Sibling (not nested) because DialogDescription renders a

and the selector a

— nesting is invalid HTML. - if (!newIssueTargetRepo) { - return null - } - const entry = perRepoSourceState.find((s) => s.repoId === newIssueTargetRepo.id) - if (!entry || !entry.sources?.upstreamCandidate || !entry.sources?.originCandidate) { - return null - } - if ( - sameGitHubOwnerRepo(entry.sources.originCandidate, entry.sources.upstreamCandidate) - ) { - return null - } - return ( -
- { - void setIssueSourcePreference( - newIssueTargetRepo.id, - newIssueTargetRepo.path, - next - ) - }} - /> -
- ) - })()} - -
- {selectedRepos.length > 1 ? ( -
- - -
- ) : null} -
- - setNewIssueTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { - e.preventDefault() - void handleCreateNewIssue() - } - }} - placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')} - disabled={newIssueSubmitting} - /> -
-
- - void handleCreateNewIssue()} - /> -
-
- - -
-

- {submitShortcutLabel} {translate('auto.components.TaskPage.fc0d8a1fa4', 'to submit.')} -

-
- - - - - -
- ) -} diff --git a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx deleted file mode 100644 index 84ef5250c76..00000000000 --- a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react' - -import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { - getJiraCreateAllowedValueLabel, - isJiraScalarUserCreateField -} from '@/components/task-page-jira-create-fields' -import { JiraUserPicker } from '@/components/jira-user-picker' -import { translate } from '@/i18n/i18n' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { JiraCreateField, JiraUser } from '../../../../../shared/jira-types' -import type { TaskSourceContext } from '../../../../../shared/task-source-context' - -/** Renders the required create fields, choosing a picker, select, or text input per schema. */ -export function NewJiraIssueCustomFields({ - visibleJiraCreateFields, - newJiraIssueCustomFieldValues, - setNewJiraIssueCustomFieldValues, - newJiraIssueSubmitting, - jiraUserFieldSelections, - setJiraUserFieldSelections, - providerSettings, - siteId -}: { - visibleJiraCreateFields: JiraCreateField[] - newJiraIssueCustomFieldValues: Record - setNewJiraIssueCustomFieldValues: React.Dispatch>> - newJiraIssueSubmitting: boolean - jiraUserFieldSelections: Record - setJiraUserFieldSelections: React.Dispatch>> - providerSettings: TaskSourceContext | GlobalSettings | null - siteId?: string | null -}): React.JSX.Element | null { - if (visibleJiraCreateFields.length === 0) { - return null - } - return ( -
- {visibleJiraCreateFields.map((field) => { - const fieldValue = newJiraIssueCustomFieldValues[field.key] ?? '' - return ( -
- - {isJiraScalarUserCreateField(field) && !field.allowedValues?.length ? ( - { - setNewJiraIssueCustomFieldValues((prev) => ({ - ...prev, - [field.key]: user.accountId - })) - setJiraUserFieldSelections((prev) => ({ ...prev, [field.key]: user })) - }} - disabled={newJiraIssueSubmitting} - label={field.name} - /> - ) : field.allowedValues?.length && field.schema?.type !== 'array' ? ( - - ) : ( - - setNewJiraIssueCustomFieldValues((prev) => ({ - ...prev, - [field.key]: event.target.value - })) - } - type={field.schema?.type === 'number' ? 'number' : 'text'} - placeholder={ - field.schema?.type === 'array' - ? translate('auto.components.TaskPage.56cdb413a2', 'Comma-separated values') - : translate('auto.components.TaskPage.919a20dd5b', 'Enter {{value0}}', { - value0: field.name - }) - } - disabled={newJiraIssueSubmitting} - aria-label={field.name} - /> - )} -
- ) - })} -
- ) -} diff --git a/src/renderer/src/components/task-page/dialogs/new-jira-issue-dialog.tsx b/src/renderer/src/components/task-page/dialogs/new-jira-issue-dialog.tsx deleted file mode 100644 index 8462493fa9a..00000000000 --- a/src/renderer/src/components/task-page/dialogs/new-jira-issue-dialog.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import React from 'react' -import { Check, ChevronDown, LoaderCircle } from 'lucide-react' - -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Textarea } from '@/components/ui/textarea' -import { getJiraProjectPickerDisplayLabel as getJiraProjectDisplayLabel } from '@/components/jira-project-picker-filter' -import { NewJiraIssueCustomFields } from './new-jira-issue-custom-fields' -import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { - JiraCreateField, - JiraIssueType, - JiraProject, - JiraUser -} from '../../../../../shared/jira-types' -import type { TaskSourceContext } from '../../../../../shared/task-source-context' - -export type NewJiraIssueDialogProps = { - newJiraIssueOpen: boolean - newJiraIssueSubmitting: boolean - setNewJiraIssueOpen: (open: boolean) => void - handleCreateNewJiraIssue: () => Promise | void - newJiraIssueTargetProject: JiraProject | null - includeJiraSiteNameInProjectLabel: boolean - newJiraIssueProjectCommandValue: string - setNewJiraIssueProjectCommandValue: (value: string) => void - newJiraIssueProjectSearchInputRef: React.RefObject - newJiraIssueProjectQuery: string - setNewJiraIssueProjectQuery: (value: string) => void - filteredNewJiraIssueProjects: JiraProject[] - sortedAvailableJiraProjects: JiraProject[] - newJiraIssueTargetProjectSelectionKey: string | null - handleNewJiraIssueProjectSelect: (selectionKey: string) => void - handleNewJiraIssueProjectComboboxOpenChange: (open: boolean) => void - handleNewJiraIssueProjectTriggerKeyDown: (event: React.KeyboardEvent) => void - newJiraIssueProjectComboboxOpen: boolean - newJiraIssueTypeId: string | null - newJiraIssueTargetType: JiraIssueType | null - setNewJiraIssueTypeId: (id: string | null) => void - jiraIssueTypesLoading: boolean - availableJiraIssueTypes: JiraIssueType[] - newJiraIssueTitle: string - setNewJiraIssueTitle: (value: string) => void - newJiraIssueBody: string - setNewJiraIssueBody: (value: string) => void - jiraCreateFieldsLoading: boolean - jiraCreateFieldsError: string | null - visibleJiraCreateFields: JiraCreateField[] - newJiraIssueCustomFieldValues: Record - setNewJiraIssueCustomFieldValues: React.Dispatch>> - jiraUserFieldSelections: Record - setJiraUserFieldSelections: React.Dispatch>> - jiraProviderSettings: TaskSourceContext | GlobalSettings | null - hasMissingJiraCreateField: boolean - submitShortcutLabel: string -} - -/** Dialog for creating a Jira issue and opening a workspace for it. */ -export function NewJiraIssueDialog(props: NewJiraIssueDialogProps): React.JSX.Element { - const { - newJiraIssueOpen, - newJiraIssueSubmitting, - setNewJiraIssueOpen, - handleCreateNewJiraIssue, - newJiraIssueTargetProject, - includeJiraSiteNameInProjectLabel, - newJiraIssueProjectCommandValue, - setNewJiraIssueProjectCommandValue, - newJiraIssueProjectSearchInputRef, - newJiraIssueProjectQuery, - setNewJiraIssueProjectQuery, - filteredNewJiraIssueProjects, - sortedAvailableJiraProjects, - newJiraIssueTargetProjectSelectionKey, - handleNewJiraIssueProjectSelect, - handleNewJiraIssueProjectComboboxOpenChange, - handleNewJiraIssueProjectTriggerKeyDown, - newJiraIssueProjectComboboxOpen, - newJiraIssueTypeId, - newJiraIssueTargetType, - setNewJiraIssueTypeId, - jiraIssueTypesLoading, - availableJiraIssueTypes, - newJiraIssueTitle, - setNewJiraIssueTitle, - newJiraIssueBody, - setNewJiraIssueBody, - jiraCreateFieldsLoading, - jiraCreateFieldsError, - visibleJiraCreateFields, - newJiraIssueCustomFieldValues, - setNewJiraIssueCustomFieldValues, - jiraUserFieldSelections, - setJiraUserFieldSelections, - jiraProviderSettings, - hasMissingJiraCreateField, - submitShortcutLabel - } = props - return ( - { - if (!newJiraIssueSubmitting) { - setNewJiraIssueOpen(open) - } - }} - > - { - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - void handleCreateNewJiraIssue() - } - }} - > - - - {translate('auto.components.TaskPage.0c11ca0b6d', 'New Jira issue')} - - - {newJiraIssueTargetProject - ? translate( - 'auto.components.TaskPage.0f7b0d964a', - 'Creates a new issue in {{value0}}.', - { value0: newJiraIssueTargetProject.key } - ) - : translate( - 'auto.components.TaskPage.e178c0a953', - 'Choose a Jira project before creating the issue.' - )} - - -
-
-
- - - - - - event.preventDefault()} - > - - - - - {translate('auto.components.TaskPage.93c57f15e5', 'No projects found.')} - - {filteredNewJiraIssueProjects.map((project) => { - const selectionKey = getJiraProjectSelectionKey(project) - const selected = selectionKey === newJiraIssueTargetProjectSelectionKey - return ( - handleNewJiraIssueProjectSelect(selectionKey)} - className="items-center gap-2 px-3 py-2 text-xs" - > - - - {getJiraProjectDisplayLabel( - project, - includeJiraSiteNameInProjectLabel - )} - - - ) - })} - - - - -
-
- - -
-
-
- - setNewJiraIssueTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { - e.preventDefault() - void handleCreateNewJiraIssue() - } - }} - placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')} - disabled={newJiraIssueSubmitting} - /> -
-
- -