mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
Merge origin/main into native-chat-entrypoint-routing
This commit is contained in:
@@ -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 = []
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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 = `<!DOCTYPE html>
|
||||
export const TERMINAL_HTML_DOCUMENT_SHELL = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
+2
-1
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
+1
-42
@@ -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.
|
||||
`
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
+79
-95
@@ -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);
|
||||
}
|
||||
|
||||
`
|
||||
+2
-1
@@ -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');
|
||||
+2
-1
@@ -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) {
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
`
|
||||
+1
-79
@@ -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) {
|
||||
+91
-1
@@ -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);
|
||||
|
||||
`
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
`
|
||||
+1
-1
@@ -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;
|
||||
@@ -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'
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> = 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<string, unknown> = 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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
@@ -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<string, string> = {
|
||||
'.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<boolean> {
|
||||
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()
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'.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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getLocalAgentRuntimeTarget,
|
||||
getLocalTextGenerationTarget,
|
||||
getRepoForSourceControlAi
|
||||
} from './filesystem-worktree-helpers'
|
||||
} from './filesystem-source-control-ai-targets'
|
||||
|
||||
export function registerFilesystemGitCommitGenerationHandlers(
|
||||
context: FilesystemHandlerContext
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
getLocalAgentRuntimeTarget,
|
||||
getLocalTextGenerationTarget,
|
||||
getRepoForSourceControlAi
|
||||
} from './filesystem-worktree-helpers'
|
||||
} from './filesystem-source-control-ai-targets'
|
||||
|
||||
export function registerFilesystemGitPullRequestGenerationHandlers(
|
||||
context: FilesystemHandlerContext
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
-8
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<typeof RelayInstallMarkerModule>()),
|
||||
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<typeof vi.spyOn>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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: {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
|
||||
export const browserApiPart2 = {
|
||||
export const browserPageInteractionAndSessionsApi = {
|
||||
onContextMenuRequested: (
|
||||
callback: (event: {
|
||||
browserPageId: string
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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<unknown> => ipcRenderer.invoke('gh:viewer'),
|
||||
repoSlug: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:repoSlug', args),
|
||||
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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<{
|
||||
@@ -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 }
|
||||
|
||||
+4
-1
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
|
||||
export const uiApiPart5 = {
|
||||
notifyWindowRevealed: (): void => {
|
||||
ipcRenderer.send('ui:window-revealed')
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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),
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -12,7 +12,7 @@ import type {
|
||||
RuntimeTerminalPresentation
|
||||
} from '../../shared/runtime-types'
|
||||
|
||||
export const uiApiPart3 = {
|
||||
export const uiTerminalAndSessionTabsApi = {
|
||||
onCreateTerminal: (
|
||||
callback: (data: {
|
||||
requestId?: string
|
||||
@@ -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']
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 <TaskPageSurface model={stage39} />
|
||||
}
|
||||
@@ -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 (
|
||||
<GitHubUserAvatar
|
||||
login={reviewer.login}
|
||||
name={reviewer.name}
|
||||
avatarUrl={avatarUrl}
|
||||
title={reviewer.name ? `${reviewer.name} (${reviewer.login})` : reviewer.login}
|
||||
className="size-5"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <Users className="size-5 shrink-0" />
|
||||
}
|
||||
export function GitHubAssigneeAvatar({
|
||||
assignee
|
||||
}: {
|
||||
assignee: GitHubAssignableUser
|
||||
}): React.JSX.Element {
|
||||
if (assignee.avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={assignee.avatarUrl}
|
||||
alt={assignee.login}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
title={assignee.name ? `${assignee.name} (${assignee.login})` : assignee.login}
|
||||
className="size-5 rounded-full border border-border/40 bg-muted object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
title={assignee.login}
|
||||
className="inline-flex size-5 items-center justify-center rounded-full border border-border/40 bg-muted text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{assignee.login.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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 <webview>, 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 (
|
||||
<div
|
||||
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${effectiveActiveLayout ? '' : ' hidden'}`}
|
||||
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${
|
||||
effectiveActiveLayout
|
||||
? ''
|
||||
: retainBrowserGuestPaint
|
||||
? ' opacity-0 pointer-events-none'
|
||||
: ' hidden'
|
||||
}`}
|
||||
>
|
||||
{workspaceSurfaces
|
||||
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
// Why: already out of flow via the workbench container when hidden, so retention only
|
||||
// has to drop `hidden` — it does not need to leave the flex column a second time.
|
||||
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
|
||||
renderedActiveWorktreeId ? '' : ' hidden'
|
||||
renderedActiveWorktreeId
|
||||
? ''
|
||||
: retainBrowserGuestPaint
|
||||
? ' opacity-0 pointer-events-none'
|
||||
: ' hidden'
|
||||
}`}
|
||||
data-rendered-active-worktree-id={renderedActiveWorktreeId ?? undefined}
|
||||
>
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
tabBarProps: [] as Record<string, unknown>[]
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(mocks.state), {
|
||||
getState: () => mocks.state
|
||||
})
|
||||
}))
|
||||
vi.mock('./tab-bar/TabBar', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
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(<TerminalTitlebarTabs controller={controller} />))
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<React.ComponentProps<typeof TabBar>, 'tabs'>
|
||||
): React.JSX.Element {
|
||||
const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS)
|
||||
return <TabBar {...props} tabs={tabs} />
|
||||
}
|
||||
|
||||
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(
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
<LiveTerminalTabBar
|
||||
activeTabId={activeTabId}
|
||||
worktreeId={renderedActiveWorktreeId}
|
||||
onActivate={handleActivateTab}
|
||||
@@ -71,6 +82,7 @@ export function TerminalTitlebarTabs({
|
||||
onTogglePaneExpand={handleTogglePaneExpand}
|
||||
editorFiles={worktreeFiles}
|
||||
browserTabs={worktreeBrowserTabs}
|
||||
clientHostedBrowserRows={worktreeClientHostedBrowserRows}
|
||||
activeFileId={activeFileId}
|
||||
activeBrowserTabId={activeBrowserTabId}
|
||||
activeSimulatorTabId={
|
||||
|
||||
+4
@@ -56,6 +56,10 @@ const RETENTION_HELPER_SYMBOLS = [
|
||||
// Every place that decides whether a browser guest keeps painting, and the helper it must use.
|
||||
const RETENTION_SITES = new Map<string, readonly string[]>([
|
||||
['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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
+13
-24
@@ -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')
|
||||
|
||||
@@ -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 (
|
||||
<div className="px-3 py-1.5 pl-6 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ResourceUsageMetricPair cpu={values.cpu} memory={values.memory} size="small" />
|
||||
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResourceUsageAppSection({
|
||||
app,
|
||||
isCollapsed,
|
||||
onToggle
|
||||
}: {
|
||||
app: AppMemory
|
||||
isCollapsed: boolean
|
||||
onToggle: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="border-t border-border/50">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
|
||||
aria-label={
|
||||
isCollapsed
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.e419d27083',
|
||||
'Expand Orca'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.53dd5560ae',
|
||||
'Collapse Orca'
|
||||
)
|
||||
}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
|
||||
{translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ResourceUsageSparkline samples={app.history} />
|
||||
<ResourceUsageMetricPair cpu={app.cpu} memory={app.memory} />
|
||||
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-border/30">
|
||||
<AppSubRow
|
||||
label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.81cd37af99',
|
||||
'Main'
|
||||
)}
|
||||
values={app.main}
|
||||
/>
|
||||
<AppSubRow
|
||||
label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.d406915b78',
|
||||
'Renderer'
|
||||
)}
|
||||
values={app.renderer}
|
||||
/>
|
||||
{(app.other.cpu > 0 || app.other.memory > 0) && (
|
||||
<AppSubRow
|
||||
label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.0f9e50eb07',
|
||||
'Other'
|
||||
)}
|
||||
values={app.other}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
open={killConfirm !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (next) {
|
||||
return
|
||||
}
|
||||
if (killing) {
|
||||
return
|
||||
}
|
||||
setKillConfirm(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
showCloseButton={!killing}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (killing) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (killing) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0',
|
||||
'Kill {{value0}}?',
|
||||
{
|
||||
value0:
|
||||
killConfirm?.label ??
|
||||
translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80',
|
||||
'this session'
|
||||
)
|
||||
}
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49',
|
||||
"Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setKillConfirm(null)} disabled={killing}>
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.946d9f94d0',
|
||||
'Cancel'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void runKillConfirmed()} disabled={killing}>
|
||||
{killing ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
{killing
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.41ae4fa725',
|
||||
'Killing…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.b10695d6ce',
|
||||
'Kill session'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
aria-hidden
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
strokeWidth={1}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="stroke-muted-foreground/70"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
METRIC_COLUMNS_CLS,
|
||||
textClassName,
|
||||
muted ? 'text-muted-foreground/50' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={CPU_COLUMN_CLS}>{formatMetricCpu(cpu)}</span>
|
||||
<span className={MEM_COLUMN_CLS}>{formatMetricMemory(memory)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
|
||||
className="w-[26rem] max-w-[calc(100vw-2rem)] p-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
// Why: xterm focus must not dismiss the resource manager after tab activation.
|
||||
onFocusOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-foreground">
|
||||
<MemoryStick className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => daemonActions.setPending('restart')}
|
||||
disabled={daemonActions.isBusy}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
|
||||
'Restart daemon'
|
||||
)}
|
||||
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<RotateCw className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
|
||||
'Restart daemon'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => daemonActions.setPending('killAll')}
|
||||
disabled={daemonActions.isBusy}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
|
||||
'Kill all sessions'
|
||||
)}
|
||||
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
|
||||
'Kill all sessions'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daemonUnreachable && (
|
||||
<div className="flex items-start gap-2 border-b border-border bg-yellow-500/10 px-3 py-2 text-[11px] text-foreground">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0 text-yellow-500" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4',
|
||||
'Daemon is not responding'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6',
|
||||
'Resource snapshots and terminal sessions are unavailable.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => daemonActions.setPending('restart')}
|
||||
disabled={daemonActions.isBusy}
|
||||
>
|
||||
<RotateCw className="mr-1 size-3" />
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.93b0de3c21',
|
||||
'Restart'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!daemonUnreachable && sessionsOnlyError && (
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<AlertTriangle className="size-3 shrink-0 text-yellow-500" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78',
|
||||
'Terminal sessions unavailable. The list may be stale.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resourceSnapshot && (
|
||||
<div className="px-3 py-2 border-b border-border flex items-baseline justify-between gap-3 text-xs tabular-nums">
|
||||
<div className="flex items-baseline gap-3 min-w-0">
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
|
||||
>
|
||||
{formatCpu(totalCpu)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae',
|
||||
'Combined CPU load. Values above 100% mean more than one core is working at once.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
|
||||
>
|
||||
{formatMemory(totalMemory)}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{memoryMetricCopy.summaryLabel}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
|
||||
{memoryMetricCopy.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{orphanCount > 0 && (
|
||||
<span className="shrink-0 text-yellow-500" aria-live="polite">
|
||||
{orphanCount === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31',
|
||||
'{{value0}} orphan',
|
||||
{ value0: orphanCount }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3',
|
||||
'{{value0}} orphans',
|
||||
{ value0: orphanCount }
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Why: fixed height prevents list expansion and polling from moving the popover. */}
|
||||
<div ref={setPopoverBodyNode} tabIndex={-1} className="flex h-[420px] flex-col outline-none">
|
||||
{(unifiedRepos.length > 0 || resourceSnapshot) && (
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-muted/30 border-b border-border/50 text-[10px] uppercase tracking-wide shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOption('name')}
|
||||
className={cn(
|
||||
'hover:text-foreground transition-colors',
|
||||
sortOption === 'name' ? 'font-semibold text-foreground' : 'text-muted-foreground/80'
|
||||
)}
|
||||
aria-pressed={sortOption === 'name'}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.2aa2de6cb9',
|
||||
'Name'
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className={cn(METRIC_COLUMNS_CLS, 'text-[10px]')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOption('cpu')}
|
||||
className={cn(
|
||||
CPU_COLUMN_CLS,
|
||||
'hover:text-foreground transition-colors',
|
||||
sortOption === 'cpu'
|
||||
? 'font-semibold text-foreground'
|
||||
: 'text-muted-foreground/80'
|
||||
)}
|
||||
aria-pressed={sortOption === 'cpu'}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.298f4be7f2',
|
||||
'CPU'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOption('memory')}
|
||||
className={cn(
|
||||
MEM_COLUMN_CLS,
|
||||
'hover:text-foreground transition-colors',
|
||||
sortOption === 'memory'
|
||||
? 'font-semibold text-foreground'
|
||||
: 'text-muted-foreground/80'
|
||||
)}
|
||||
aria-pressed={sortOption === 'memory'}
|
||||
>
|
||||
{memoryMetricCopy.columnLabel}
|
||||
</button>
|
||||
</div>
|
||||
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto scrollbar-sleek">
|
||||
{unifiedRepos.length > 0 && (
|
||||
<ResourceUsageTree
|
||||
repos={unifiedRepos}
|
||||
sortOption={sortOption}
|
||||
collapsedRepos={collapsedRepos}
|
||||
toggleRepo={toggleRepo}
|
||||
collapsedWorktrees={collapsedWorktrees}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
toggleWorktree={toggleWorktree}
|
||||
navigateToWorktree={navigateToWorktree}
|
||||
navigateToTab={navigateToTab}
|
||||
onDelete={deleteWorktree}
|
||||
onKillSession={handleKillSession}
|
||||
/>
|
||||
)}
|
||||
|
||||
{unifiedRepos.length === 0 && resourceSnapshot && (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0',
|
||||
'Nothing running right now'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resourceSnapshot && (
|
||||
<ResourceUsageAppSection
|
||||
app={resourceSnapshot.app}
|
||||
isCollapsed={appCollapsed}
|
||||
onToggle={() => setAppCollapsed((value) => !value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!resourceSnapshot && !daemonUnreachable && (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
|
||||
'Loading…'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 px-3 py-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWorkspaceCleanup}
|
||||
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<span className="min-w-0 truncate px-4 text-center">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
|
||||
'Clean up workspaces'
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight className="absolute right-2.5 size-3.5 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
{orphanCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleKillOrphans()}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
{orphanCount === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
|
||||
'Kill {{value0}} orphan terminal',
|
||||
{ value0: orphanCount }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
|
||||
'Kill {{value0}} orphan terminals',
|
||||
{ value0: orphanCount }
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
@@ -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<string>
|
||||
toggleRepo: (repoId: string) => void
|
||||
collapsedWorktrees: Set<string>
|
||||
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 (
|
||||
<ResourceUsageWorktreeRow
|
||||
key={worktree.worktreeId}
|
||||
worktree={worktree}
|
||||
storeRecord={storeRecord}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
isCollapsed={collapsedWorktrees.has(worktree.worktreeId)}
|
||||
onToggle={() => 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 (
|
||||
<div key={group.repoId} className="border-b border-border/50 last:border-b-0">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRepo(group.repoId)}
|
||||
className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
|
||||
aria-label={
|
||||
repoCollapsed
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.b12e31dfcb',
|
||||
'Expand repo'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.73a3fd68a9',
|
||||
'Collapse repo'
|
||||
)
|
||||
}
|
||||
>
|
||||
{repoCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
|
||||
{group.repoName}
|
||||
</span>
|
||||
{group.hasRemoteChildren && (
|
||||
<span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/70">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
|
||||
'· remote'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ResourceUsageMetricPair cpu={group.cpu} memory={group.memory} />
|
||||
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!repoCollapsed && (
|
||||
<div className="border-t border-border/30">{group.worktrees.map(renderWorktree)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
|
||||
className="relative inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
|
||||
aria-label={
|
||||
daemonUnreachable
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.59f178fe11',
|
||||
'{{value0}}, daemon unreachable',
|
||||
{ value0: resourceManagerAriaLabel }
|
||||
)
|
||||
: resourceManagerAriaLabel
|
||||
}
|
||||
>
|
||||
{spaceScanReady ? (
|
||||
<span
|
||||
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<MemoryStick className="size-3 text-muted-foreground" />
|
||||
{!iconOnly && (
|
||||
<>
|
||||
<span className="text-[11px] font-medium tabular-nums text-muted-foreground">
|
||||
{memBadgeLabel}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<Terminal className="size-3 text-muted-foreground" />
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{triggerSessionCount}
|
||||
{orphanCount > 0 && (
|
||||
<span className="text-yellow-500 ml-0.5">({orphanCount})</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{iconOnly && triggerSessionCount > 0 && (
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{triggerSessionCount}
|
||||
</span>
|
||||
)}
|
||||
{daemonUnreachable && (
|
||||
<AlertTriangle
|
||||
className="size-3 text-yellow-500"
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.ca95d077db',
|
||||
'Daemon unreachable'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
<div className="space-y-0.5">
|
||||
{resourceManagerTooltipLines.map((line) => (
|
||||
<div key={line.id} className={line.emphasized ? 'text-primary' : ''}>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'group/sessrow flex items-center gap-2 pl-10 pr-3 py-1.5',
|
||||
clickable && 'cursor-pointer hover:bg-accent/40'
|
||||
)}
|
||||
onClick={clickable ? handleClick : undefined}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : -1}
|
||||
onKeyDown={
|
||||
clickable
|
||||
? (event) => {
|
||||
if (isResourceSessionActivationKey(event.key)) {
|
||||
event.preventDefault()
|
||||
handleClick()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
data-worktree-id={worktreeId}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
session.bound ? 'bg-emerald-500' : 'bg-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground truncate min-w-0 flex-1">
|
||||
{session.label}
|
||||
</span>
|
||||
<ResourceUsageMetricPair cpu={session.cpu} memory={session.memory} size="small" />
|
||||
{/* Why: the shared gutter aligns columns while keeping orphan kills visible. */}
|
||||
<span className={ROW_TRAILING_GUTTER_CLS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onKill(session)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-0.5 text-muted-foreground transition-opacity hover:bg-destructive/10 hover:text-destructive',
|
||||
session.bound &&
|
||||
'can-hover:opacity-0 group-hover/sessrow:opacity-100 group-focus-within/sessrow:opacity-100 focus-visible:opacity-100'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.fa6d36758d',
|
||||
'Kill session {{value0}}',
|
||||
{ value0: session.sessionId }
|
||||
)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element {
|
||||
const label = browser.title?.trim() || browser.label?.trim() || browser.url
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-10 pr-3 py-1.5">
|
||||
<Globe className="size-3 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">{label}</span>
|
||||
<ResourceUsageMetricPair cpu={null} memory={null} size="small" />
|
||||
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="border-b border-border/20 last:border-b-0">
|
||||
<div className="group/wtrow flex items-center ml-2 transition-colors hover:bg-muted/60">
|
||||
{hasResources ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="pl-2 py-2 pr-0.5 shrink-0"
|
||||
aria-label={
|
||||
isCollapsed
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c4a8968bdd',
|
||||
'Expand workspace'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.bbcd9b7b85',
|
||||
'Collapse workspace'
|
||||
)
|
||||
}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="pl-2 py-2 pr-0.5 shrink-0 w-[calc(0.5rem+0.75rem+0.125rem)]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigate}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.d659d71d2d',
|
||||
'Resume workspace {{value0}}',
|
||||
{ value0: rowLabel }
|
||||
)}
|
||||
className="flex-1 min-w-0 py-2 pr-2 pl-1 text-left flex items-center gap-1.5"
|
||||
disabled={!isNavigable}
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{rowLabel}</span>
|
||||
{worktree.isRemote && (
|
||||
<span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/70">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
|
||||
'· remote'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 shrink-0 pr-3">
|
||||
<div className="relative">
|
||||
<span
|
||||
className={cn(
|
||||
'block transition-opacity',
|
||||
showWorktreeActions &&
|
||||
'group-hover/wtrow:opacity-0 group-hover/wtrow:pointer-events-none group-focus-within/wtrow:opacity-0 group-focus-within/wtrow:pointer-events-none [@media(hover:none)]:opacity-0 [@media(hover:none)]:pointer-events-none'
|
||||
)}
|
||||
aria-hidden={showWorktreeActions ? undefined : true}
|
||||
>
|
||||
<ResourceUsageSparkline samples={worktree.history} />
|
||||
</span>
|
||||
{showWorktreeActions && (
|
||||
<div className="absolute inset-0 flex items-center justify-end gap-0.5 can-hover:opacity-0 can-hover:pointer-events-none transition-opacity group-hover/wtrow:opacity-100 group-hover/wtrow:pointer-events-auto group-focus-within/wtrow:opacity-100 group-focus-within/wtrow:pointer-events-auto">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isMainWorktree}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.16bc3c998a',
|
||||
'Delete workspace {{value0}}',
|
||||
{ value0: rowLabel }
|
||||
)}
|
||||
className={cn(
|
||||
'p-0.5 rounded text-muted-foreground transition-colors',
|
||||
isMainWorktree
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: 'hover:bg-destructive/10 hover:text-destructive'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={4}
|
||||
className="z-[70] max-w-[200px] text-pretty"
|
||||
>
|
||||
{isMainWorktree
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a',
|
||||
'The main workspace cannot be deleted.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458',
|
||||
'Delete workspace.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ResourceUsageMetricPair cpu={worktree.cpu} memory={worktree.memory} />
|
||||
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
worktree.sessions.map((session) => (
|
||||
<ResourceUsageSessionRow
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
worktreeId={worktree.worktreeId}
|
||||
onNavigate={navigateToTab}
|
||||
onKill={onKillSession}
|
||||
/>
|
||||
))}
|
||||
{!isCollapsed &&
|
||||
worktree.browsers.map((browser) => <BrowserRow key={browser.id} browser={browser} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ResourceUsageSortOption>('memory')
|
||||
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
|
||||
const [collapsedWorktrees, setCollapsedWorktrees] = useState<Set<string>>(new Set())
|
||||
const [appCollapsed, setAppCollapsed] = useState(true)
|
||||
const {
|
||||
sessionInventory,
|
||||
sessionsError,
|
||||
refreshSessions,
|
||||
clearSessionsError,
|
||||
removeSession,
|
||||
removeSessions
|
||||
} = useResourceSessionInventory(workspaceSessionReady)
|
||||
const sessions = sessionInventory.sessions
|
||||
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
|
||||
const [killing, setKilling] = useState(false)
|
||||
const [spaceScanSnapshot, setSpaceScanSnapshot] = useState<ResourceUsageSpaceScanSnapshot>(
|
||||
() => ({
|
||||
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<ResourceSessionBindingInputs>(
|
||||
() => ({
|
||||
ptyIdsByTabId,
|
||||
tabsByWorktree,
|
||||
terminalLayoutsByTabId,
|
||||
deferredSshSessionIdsByTabId,
|
||||
workspaceSessionReady
|
||||
}),
|
||||
[
|
||||
ptyIdsByTabId,
|
||||
tabsByWorktree,
|
||||
terminalLayoutsByTabId,
|
||||
deferredSshSessionIdsByTabId,
|
||||
workspaceSessionReady
|
||||
]
|
||||
)
|
||||
const popoverBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const popoverBodyFocusFrameRef = useRef<number | null>(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<typeof useResourceUsageFoundation>
|
||||
@@ -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<string, string>()
|
||||
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<string, string | null>()
|
||||
for (const repo of repos) {
|
||||
map.set(repo.id, repo.connectionId ?? null)
|
||||
}
|
||||
return map
|
||||
}, [repos])
|
||||
|
||||
const repoRuntimeScopedById = useMemo(() => {
|
||||
const map = new Map<string, boolean>()
|
||||
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<typeof useResourceUsageProjection>
|
||||
@@ -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>): 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(
|
||||
<WorkspaceSpaceBreakdownList
|
||||
isScanning={false}
|
||||
worktree={worktree({
|
||||
topLevelItems: [item('node_modules', 400), item('src', 100)],
|
||||
omittedTopLevelItemCount: 7,
|
||||
omittedTopLevelSizeBytes: 900
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<WorkspaceSpaceBreakdownList
|
||||
isScanning={false}
|
||||
worktree={worktree({
|
||||
topLevelItems: [item('src', 250)],
|
||||
omittedTopLevelItemCount: 3,
|
||||
omittedTopLevelSizeBytes: 1000
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
const widths = Array.from(container.querySelectorAll<HTMLElement>('div[style]')).map(
|
||||
(node) => node.style.width
|
||||
)
|
||||
expect(widths).toEqual(['25%', '100%'])
|
||||
})
|
||||
|
||||
it('omits the aggregate row when nothing was omitted', () => {
|
||||
const { container } = render(
|
||||
<WorkspaceSpaceBreakdownList
|
||||
isScanning={false}
|
||||
worktree={worktree({ topLevelItems: [item('src', 250)] })}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<WorkspaceSpaceBreakdownList
|
||||
isScanning={false}
|
||||
worktree={worktree({ omittedTopLevelItemCount: 4, omittedTopLevelSizeBytes: 80 })}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container.textContent).not.toContain('No files found.')
|
||||
expect(renderedRowNames(container)).toEqual(['Other top-level items (4)'])
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div className="min-h-72 rounded-lg border border-border/70 bg-background/35">
|
||||
<div className="border-b border-border/60 px-4 py-3">
|
||||
@@ -86,7 +102,7 @@ export function WorkspaceSpaceBreakdownList({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : worktree.topLevelItems.length === 0 ? (
|
||||
) : topLevelItemCount === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079',
|
||||
@@ -99,6 +115,7 @@ export function WorkspaceSpaceBreakdownList({
|
||||
{worktree.topLevelItems.slice(0, 12).map((item) => (
|
||||
<BreakdownRow key={`${item.path}:${item.name}`} item={item} maxSize={maxChildSize} />
|
||||
))}
|
||||
{omittedItem ? <BreakdownRow item={omittedItem} maxSize={maxChildSize} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<Element>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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', () => {
|
||||
+1
-1
@@ -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', () => {
|
||||
@@ -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. */
|
||||
|
||||
+8
-6
@@ -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<LinearIssue> & Pick<LinearIssue, 'id' | 'identifier'>
|
||||
+2
-2
@@ -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', () => {
|
||||
+1
-1
@@ -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<NonNullable<TaskPageRepoSourceState['sources']>>
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
+3
-3
@@ -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(
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
+5
-5
@@ -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
|
||||
}: {
|
||||
+3
-3
@@ -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
|
||||
}: {
|
||||
+4
-4
@@ -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
|
||||
}: {
|
||||
+5
-5
@@ -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
|
||||
}: {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user