mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 00:02:48 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/feature-interaction-state
# Conflicts: # src/renderer/src/store/slices/terminals.ts
This commit is contained in:
@@ -31,11 +31,11 @@ jobs:
|
||||
os: [ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- if: runner.os == 'Linux'
|
||||
@@ -60,11 +60,11 @@ jobs:
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
@@ -80,11 +80,11 @@ jobs:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
ACCESSIBILITY_ENABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
|
||||
@@ -105,11 +105,11 @@ jobs:
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
@@ -37,12 +37,12 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
- name: Upload E2E build output
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
# re-running locally.
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-${{ matrix.shard_name }}
|
||||
path: test-results/
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout orca
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve tag and version
|
||||
id: tag
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
# installation token can.
|
||||
- name: Generate buf0-bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: 2590194
|
||||
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
repositories: homebrew-orca
|
||||
|
||||
- name: Checkout tap
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: stablyai/homebrew-orca
|
||||
path: tap
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Apply OS label from issue form
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -23,15 +23,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
run: cd android && ./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-mobile-apk
|
||||
path: mobile/android/app/build/outputs/apk/release/*.apk
|
||||
|
||||
@@ -20,15 +20,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -14,18 +14,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -64,13 +64,13 @@ jobs:
|
||||
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
|
||||
steps:
|
||||
- name: Checkout ref
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -424,7 +424,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
@@ -492,18 +492,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -526,7 +526,7 @@ jobs:
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ matrix.eb_cache_path }}
|
||||
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -538,7 +538,7 @@ jobs:
|
||||
# occasionally returns 504s that fail the whole release. Retry on
|
||||
# failure so transient network errors don't require a manual re-run.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v3
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
@@ -629,7 +629,7 @@ jobs:
|
||||
# don't require a manual re-run.
|
||||
- name: Publish release artifacts (macOS)
|
||||
if: matrix.platform == 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
@@ -645,7 +645,7 @@ jobs:
|
||||
|
||||
- name: Publish release artifacts
|
||||
if: matrix.platform != 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
@@ -705,10 +705,10 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ jobs:
|
||||
steps:
|
||||
- name: Generate bufo-bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: 2590194
|
||||
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
|
||||
- name: Add PR to project
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
PROJECT_OWNER: stablyai
|
||||
PROJECT_NUMBER: '13'
|
||||
|
||||
@@ -108,7 +108,7 @@ Control your agents from your phone.
|
||||
</p>
|
||||
|
||||
- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9)
|
||||
- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -73,6 +73,43 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
|
||||
var DEFAULT_FILE = 'sh';
|
||||
var DEFAULT_NAME = 'xterm';
|
||||
var DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
|
||||
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
|
||||
--- a/lib/conpty_console_list_agent.js
|
||||
+++ b/lib/conpty_console_list_agent.js
|
||||
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var utils_1 = require("./utils");
|
||||
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
var shellPid = parseInt(process.argv[2], 10);
|
||||
-var consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+var consoleProcessList;
|
||||
+try {
|
||||
+ consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+}
|
||||
+catch (_a) {
|
||||
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
|
||||
+ consoleProcessList = [shellPid];
|
||||
+}
|
||||
process.send({ consoleProcessList: consoleProcessList });
|
||||
process.exit(0);
|
||||
//# sourceMappingURL=conpty_console_list_agent.js.map
|
||||
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
|
||||
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
|
||||
--- a/src/conpty_console_list_agent.ts
|
||||
+++ b/src/conpty_console_list_agent.ts
|
||||
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
|
||||
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
const shellPid = parseInt(process.argv[2], 10);
|
||||
-const consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+let consoleProcessList: number[];
|
||||
+try {
|
||||
+ consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+} catch {
|
||||
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
|
||||
+ consoleProcessList = [shellPid];
|
||||
+}
|
||||
process.send!({ consoleProcessList });
|
||||
process.exit(0);
|
||||
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
|
||||
--- a/src/unix/pty.cc
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { platform as osPlatform, tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
@@ -21,7 +23,6 @@ const electronPackageDir = resolve(projectDir, 'node_modules/electron')
|
||||
const electronRequire = createRequire(resolve(electronPackageDir, 'package.json'))
|
||||
const { version: electronVersion } = electronRequire('./package.json')
|
||||
const { downloadArtifact } = electronRequire('@electron/get')
|
||||
const extract = electronRequire('extract-zip')
|
||||
const platformPath = getElectronPlatformPath()
|
||||
|
||||
try {
|
||||
@@ -116,7 +117,7 @@ async function installElectronPackageBinary() {
|
||||
|
||||
// Why: CI has observed partial extracts directly under node_modules/electron
|
||||
// that leave only dist/locales. Verify in temp before replacing package dist.
|
||||
await extract(zipPath, { dir: extractDir })
|
||||
extractElectronArchive(zipPath, extractDir)
|
||||
const extractedExecutable = resolve(extractDir, platformPath)
|
||||
if (!existsSync(extractedExecutable)) {
|
||||
console.error('[electron-package] Electron archive extract did not contain executable.')
|
||||
@@ -126,8 +127,7 @@ async function installElectronPackageBinary() {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(electronDistDir, { recursive: true, force: true })
|
||||
cpSync(extractDir, electronDistDir, { recursive: true })
|
||||
moveExtractedElectronDist(extractDir, electronDistDir)
|
||||
|
||||
const srcTypeDefPath = resolve(electronDistDir, 'electron.d.ts')
|
||||
if (existsSync(srcTypeDefPath)) {
|
||||
@@ -138,6 +138,90 @@ async function installElectronPackageBinary() {
|
||||
}
|
||||
}
|
||||
|
||||
function extractElectronArchive(zipPath, extractDir) {
|
||||
mkdirSync(extractDir, { recursive: true })
|
||||
// Why: extract-zip/Electron install.js can leave Node 24 with an unsettled
|
||||
// promise and no active handles on CI. Host unzip tools fail synchronously.
|
||||
const command = getExtractorCommand(zipPath, extractDir)
|
||||
const result = spawnSync(command.file, command.args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(formatExtractorFailure(command, result))
|
||||
}
|
||||
}
|
||||
|
||||
function moveExtractedElectronDist(extractDir, electronDistDir) {
|
||||
rmSync(electronDistDir, { recursive: true, force: true })
|
||||
try {
|
||||
// Why: macOS Electron archives rely on framework symlinks. Moving the
|
||||
// verified tree preserves them exactly; copying has broken them in CI.
|
||||
renameSync(extractDir, electronDistDir)
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err?.code !== 'EXDEV') {
|
||||
throw err
|
||||
}
|
||||
cpSync(extractDir, electronDistDir, {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
verbatimSymlinks: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getExtractorCommand(zipPath, extractDir) {
|
||||
if (process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR) {
|
||||
return {
|
||||
file: process.execPath,
|
||||
args: [process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR, zipPath, extractDir],
|
||||
label: `node ${process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR}`
|
||||
}
|
||||
}
|
||||
|
||||
if (osPlatform() === 'win32') {
|
||||
return {
|
||||
file: process.env.ORCA_POWERSHELL_BIN || 'powershell',
|
||||
args: [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
[
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
`Expand-Archive -LiteralPath ${quotePowerShellLiteral(zipPath)} -DestinationPath ${quotePowerShellLiteral(extractDir)} -Force`
|
||||
].join('; ')
|
||||
],
|
||||
label: 'powershell Expand-Archive'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: process.env.ORCA_UNZIP_BIN || 'unzip',
|
||||
args: ['-q', zipPath, '-d', extractDir],
|
||||
label: 'unzip'
|
||||
}
|
||||
}
|
||||
|
||||
function quotePowerShellLiteral(value) {
|
||||
return `'${String(value).replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function formatExtractorFailure(command, result) {
|
||||
return [
|
||||
`[electron-package] ${command.label} failed with status ${result.status}.`,
|
||||
result.stdout ? `stdout:\n${result.stdout.trim()}` : '',
|
||||
result.stderr ? `stderr:\n${result.stderr.trim()}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function shouldUseRemoteChecksums() {
|
||||
return Boolean(
|
||||
process.env.electron_use_remote_checksums ||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -25,7 +25,7 @@ describe('install-electron-package-binary', () => {
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractZip(projectDir, { createExecutable: true })
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir)
|
||||
|
||||
@@ -36,6 +36,13 @@ describe('install-electron-package-binary', () => {
|
||||
expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe(
|
||||
'electron'
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(
|
||||
lstatSync(
|
||||
join(projectDir, 'node_modules', 'electron', 'dist', 'version-link')
|
||||
).isSymbolicLink()
|
||||
).toBe(true)
|
||||
}
|
||||
expect(result.stdout).toContain('Repaired Electron path.txt -> electron')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
@@ -48,7 +55,7 @@ describe('install-electron-package-binary', () => {
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractZip(projectDir, { createExecutable: false })
|
||||
writeFakeExtractor(projectDir, { createExecutable: false })
|
||||
mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), {
|
||||
recursive: true
|
||||
})
|
||||
@@ -64,13 +71,13 @@ describe('install-electron-package-binary', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not exit successfully when Electron extract never settles', () => {
|
||||
it('does not exit successfully when Electron download never settles', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractZip(projectDir, { createExecutable: false, neverSettles: true })
|
||||
writeFakeElectronGet(projectDir, { downloadNeverSettles: true })
|
||||
writeFakeExtractor(projectDir, { createExecutable: false })
|
||||
|
||||
const result = runInstallScript(projectDir)
|
||||
|
||||
@@ -100,7 +107,8 @@ function runInstallScript(projectDir) {
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_platform: 'linux',
|
||||
npm_config_arch: 'x64'
|
||||
npm_config_arch: 'x64',
|
||||
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -127,7 +135,7 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeElectronGet(projectDir) {
|
||||
function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -137,6 +145,9 @@ const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
appendFileSync('electron-get.log', 'cacheRoot=' + details.cacheRoot + '\\n')
|
||||
if (${JSON.stringify(downloadNeverSettles)}) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
mkdirSync(details.cacheRoot, { recursive: true })
|
||||
const artifactPath = join(details.cacheRoot, 'electron.zip')
|
||||
writeFileSync(artifactPath, 'fake zip')
|
||||
@@ -146,25 +157,21 @@ exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeExtractZip(projectDir, { createExecutable, neverSettles = false }) {
|
||||
const extractDir = join(projectDir, 'node_modules', 'electron', 'node_modules', 'extract-zip')
|
||||
mkdirSync(extractDir, { recursive: true })
|
||||
function writeFakeExtractor(projectDir, { createExecutable }) {
|
||||
writeFileSync(
|
||||
join(extractDir, 'index.js'),
|
||||
join(projectDir, 'fake-extractor.cjs'),
|
||||
`
|
||||
const { mkdirSync, writeFileSync } = require('node:fs')
|
||||
const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
module.exports = async function extract(_zipPath, options) {
|
||||
mkdirSync(join(options.dir, 'locales'), { recursive: true })
|
||||
if (${JSON.stringify(neverSettles)}) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (${JSON.stringify(createExecutable)}) {
|
||||
writeFileSync(join(options.dir, 'electron'), '')
|
||||
writeFileSync(join(options.dir, 'version'), 'v41.5.0')
|
||||
const extractDir = process.argv[3]
|
||||
mkdirSync(join(extractDir, 'locales'), { recursive: true })
|
||||
if (${JSON.stringify(createExecutable)}) {
|
||||
writeFileSync(join(extractDir, 'electron'), '')
|
||||
writeFileSync(join(extractDir, 'version'), 'v41.5.0')
|
||||
if (process.platform !== 'win32') {
|
||||
symlinkSync('version', join(extractDir, 'version-link'))
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
chmodSync(join(extractDir, 'index.js'), 0o755)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Orchestration Reset Scope Validation
|
||||
|
||||
## Problem
|
||||
|
||||
`orchestration.reset` silently clears all orchestration state when no scope is provided. The RPC schema accepts every scope as optional at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:139), and the handler falls through to `db.resetAll()` at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:585). Existing reset tests only cover explicit single scopes at [src/main/runtime/rpc/methods/orchestration.test.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.test.ts:1024).
|
||||
|
||||
## Root Cause
|
||||
|
||||
`ResetParams` models `all`, `tasks`, and `messages` as independent optional booleans. The handler then chooses the first truthy scope and treats zero truthy scopes as `all`, so `{}` wipes everything and contradictory inputs such as `{ tasks: true, messages: true }` partially apply the first truthy branch.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Redesign orchestration storage or reset semantics.
|
||||
- Add confirmation prompts to the RPC protocol.
|
||||
- Change `resetAll`, `resetTasks`, or `resetMessages` database behavior.
|
||||
- Change unrelated orchestration commands or provider-specific behavior.
|
||||
|
||||
## Design
|
||||
|
||||
1. Enforce exactly one truthy reset scope at the `ResetParams` schema using `superRefine`.
|
||||
2. Remove the handler fallthrough to `db.resetAll()` so only validated explicit scopes can mutate state.
|
||||
3. Preserve the existing CLI no-flag shortcut by having [src/cli/handlers/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/cli/handlers/orchestration.ts:440) pass `all: true` when no reset scope flag is present. This keeps CLI compatibility explicit while preventing ambiguous direct RPC calls.
|
||||
4. Let explicit multi-flag CLI invocations reach RPC validation and fail with the shared invalid-argument path. Duplicating scope validation in the CLI is unnecessary because all CLI calls already pass through this RPC method.
|
||||
5. Add RPC regression tests that seed one message and one task, call invalid reset params, assert rejection, and assert both seeded records remain.
|
||||
6. Add CLI parser tests that `orca orchestration reset` calls RPC with `all: true`, and that explicit flags are passed through unchanged for RPC validation.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- CLI no-flag path: `orca orchestration reset` -> CLI handler sends `{ all: true }` -> RPC schema validates -> handler calls `resetAll`.
|
||||
- CLI explicit-flag path: CLI handler forwards the provided flags -> RPC schema validates exactly one truthy scope -> handler calls one database reset method or rejects before side effects.
|
||||
- Direct RPC path: caller params -> RPC schema validates exactly one scope -> invalid params fail before handler side effects.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- `{}` rejects and leaves messages and tasks unchanged.
|
||||
- `{ all: false }` rejects and leaves messages and tasks unchanged.
|
||||
- `{ tasks: true, messages: true }` rejects and leaves messages and tasks unchanged.
|
||||
- `{ all: true, tasks: true }` rejects and leaves messages and tasks unchanged.
|
||||
- `{ all: false, tasks: true }` is valid and resets tasks only; false values are not selected scopes.
|
||||
- Non-boolean values such as `{ all: "true" }` are transformed to `undefined` by `OptionalBoolean` and must reject unless exactly one real boolean `true` is present.
|
||||
- Explicit `{ all: true }`, `{ tasks: true }`, and `{ messages: true }` continue to work.
|
||||
- Remote and SSH callers are covered because the validation sits behind the shared RPC method, not local CLI process state.
|
||||
- Unknown keys do not select a scope. If strict unknown-key rejection is desired, that is a separate RPC schema policy change and should not be bundled into this fix.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit/RPC: extend `src/main/runtime/rpc/methods/orchestration.test.ts` with invalid reset scope tests covering empty params and multiple scopes with preservation assertions.
|
||||
- Unit/RPC: keep existing single-scope tests green to prove no regression to valid reset behavior.
|
||||
- CLI parser: extend `src/cli/index.test.ts` to assert no-flag `orchestration reset` sends `all: true` explicitly.
|
||||
- CLI parser: assert explicit `--tasks`, `--messages`, and multi-flag invocations are represented faithfully rather than silently normalized by the CLI.
|
||||
- Type/lint: run `pnpm typecheck`, `pnpm lint`, and targeted Vitest tests for the touched RPC and CLI files.
|
||||
- Electron/e2e: not required for golden behavior because this is non-UI RPC/CLI validation; Stage 6 should validate via tests and local CLI/RPC behavior instead of app screenshots.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
Not UI-visible.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
No user-visible UI states. Screenshot artifacts are not required; the validation notes should state that UI screenshot review was skipped because the changed behavior is headless RPC/CLI behavior.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Tighten `ResetParams` validation.
|
||||
2. Simplify the reset handler to trust validated single-scope params.
|
||||
3. Make CLI no-flag reset pass `all: true` explicitly.
|
||||
4. Add RPC regression tests for invalid params preserving state.
|
||||
5. Add CLI parser coverage for the explicit no-flag shortcut and explicit/multi-flag passthrough.
|
||||
6. Run targeted tests, typecheck, and lint.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to reset RPC validation, CLI argument shaping, and regression tests; no storage or UI changes.
|
||||
- Architecture/data flow: Validation belongs at the shared RPC boundary so CLI, remote, SSH, and direct runtime callers get the same safety contract. CLI no-flag compatibility remains a CLI concern by passing `all: true`.
|
||||
- Failure modes covered:
|
||||
- Empty reset params cannot mutate state.
|
||||
- Contradictory scopes cannot partially apply the first truthy branch.
|
||||
- Failed validation happens before any database reset method runs.
|
||||
- CLI shorthand cannot rely on a dangerous RPC fallback.
|
||||
- CLI multi-flag input fails through the same RPC validation as any other caller.
|
||||
- Test coverage required:
|
||||
- `src/main/runtime/rpc/methods/orchestration.test.ts`: reject empty, false-only, and multi-scope params while preserving seeded message/task state.
|
||||
- `src/main/runtime/rpc/methods/orchestration.test.ts`: existing valid single-scope tests continue passing.
|
||||
- `src/cli/index.test.ts`: no-flag CLI reset passes `all: true`; explicit flags and multi-flag input remain directly represented.
|
||||
- Performance/blast radius: No material concern. One tiny schema refinement and branch simplification run only when reset is invoked.
|
||||
- UI quality bar: Not UI-visible.
|
||||
- Required review screenshots: None; final validation notes should explain that screenshot capture is skipped for headless RPC/CLI behavior.
|
||||
- Residual risks: CLI users may still accidentally clear all state with `orca orchestration reset` because the compatibility shortcut remains, but direct RPC callers can no longer do so accidentally and CLI behavior is now explicit in the caller.
|
||||
- Concurrency/consistency: This change prevents invalid reset requests from entering the mutation path, but it does not make the existing multi-statement reset methods transactional or coordinate concurrent reset/create/send calls across windows. That is acceptable for the issue scope because valid resets are still destructive administrative operations; adding reset serialization would be a separate storage-level change.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Show Full Tab Title Tooltip
|
||||
|
||||
## Problem
|
||||
|
||||
Issue #2966 reports that long tab titles are truncated with an ellipsis but do not expose the full name on hover or focus.
|
||||
|
||||
- Terminal tab labels render in `src/renderer/src/components/tab-bar/SortableTab.tsx:324` as `truncate max-w-[72px]` with no tooltip.
|
||||
- Browser tab labels render in `src/renderer/src/components/tab-bar/BrowserTab.tsx:163` as `truncate max-w-[100px]` with no tooltip.
|
||||
- Editor tab labels render in `src/renderer/src/components/tab-bar/EditorFileTab.tsx:289` as `truncate max-w-[80px]` with no tooltip.
|
||||
- The app already has a Radix-backed shadcn tooltip wrapper in `src/renderer/src/components/ui/tooltip.tsx:23`.
|
||||
- The app-level `TooltipProvider` is mounted in `src/renderer/src/App.tsx` with `delayDuration={400}`; this feature should use that provider, not add another one.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The tab components render plain text spans for their ellipsized labels. The full title exists in component state, but the focusable tab root is not wired to the shared `Tooltip`/`TooltipTrigger`/`TooltipContent` primitive, so neither hover nor keyboard focus exposes it.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change tab width, scrolling, drag-and-drop behavior, context menus, title generation, or rename semantics.
|
||||
- Do not add persistence, IPC, or main-process behavior.
|
||||
- Do not replace the tab bar layout or introduce a new tooltip primitive.
|
||||
- Do not add custom overflow measurement in this pass unless a reviewed implementation finds the always-available tooltip materially harmful.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add full-title tooltips to non-editing tabs in the existing tab components.
|
||||
- Terminal: display `tab.customTitle ?? tab.title`.
|
||||
- Browser: display `getBrowserTabLabel(tab)`.
|
||||
- Editor: display `getEditorDisplayLabel(file)`, preserving preview italic and external mutation/status adornments outside the label.
|
||||
2. Use the existing shadcn tooltip primitive from `@/components/ui/tooltip`.
|
||||
- Do not add a nested `TooltipProvider`; the app root already supplies the 400ms delay required by the style guide.
|
||||
- In non-rename states, wrap the sortable tab root with `<Tooltip><TooltipTrigger asChild>...` so both pointer hover and keyboard focus on the dnd-kit focusable tab (`role`, `tabIndex=0`) surface the tooltip.
|
||||
- Keep the tooltip gated off while a context menu is open. Right-click opens a Radix menu from the same root hover area, so the full-title tooltip must not remain visible over the menu.
|
||||
- Do not make the label span itself the only trigger: it is not focusable today, and adding `tabIndex` to the label would create a second keyboard stop inside each tab.
|
||||
- Render `<TooltipContent side="bottom" sideOffset={6} className="max-w-80 whitespace-normal break-words text-left">full label</TooltipContent>`.
|
||||
- `side="bottom"` is intentional because the tab strip sits at the top edge; Radix collision handling can still flip/shift when needed.
|
||||
- Keep tooltip content non-interactive; it is only a label.
|
||||
3. Keep edit states out of the tooltip path.
|
||||
- Terminal rename input and editor rename input should remain direct inputs with no tooltip wrapper. Editor rename is nested inside an otherwise sortable root, so `isRenaming` must also disable the root tooltip, not only the label span tooltip.
|
||||
- Double-click handlers for rename/pin must keep their current event behavior.
|
||||
4. Preserve drag, activation, and close controls.
|
||||
- `TooltipTrigger asChild` must attach to the existing sortable root element without introducing a new DOM wrapper, so `setNodeRef`, dnd-kit attributes/listeners, activation, context-menu capture, middle-click close, and close/collapse buttons keep their current event paths.
|
||||
- Because wrapping the root makes the entire tab chrome a tooltip trigger, Electron validation must check the tooltip does not obscure or compete with close/collapse/context-menu affordances. The shared `TooltipContent` is already `pointer-events-none`, but visual overlap and native `title` on the terminal collapse button still need checking.
|
||||
- The outer tab remains the sortable/activation target.
|
||||
5. Add focused regression coverage for the render contract.
|
||||
- Unit tests should assert that terminal, browser, and editor rendered tab roots are passed through the tooltip primitive with the correct full label.
|
||||
- Tests can mock tooltip components to avoid Radix portal timing and assert structural wiring. Because these components call hooks and the Vitest environment is Node-only, render them with `react-dom/server` or another existing local pattern, and mock `@dnd-kit/sortable` so the root exposes deterministic `role`/`tabIndex` attributes.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Store/runtime tab state updates title fields.
|
||||
- `TabBar` maps current visible tabs to `SortableTab`, `BrowserTab`, and `EditorFileTab`.
|
||||
- Each tab component computes the displayed label.
|
||||
- The non-editing sortable root remains the trigger so pointer hover and keyboard focus share the same tooltip path.
|
||||
- Hover/focus opens Radix tooltip content containing the same full label after the app's normal 400ms delay.
|
||||
- Title changes from runtime/browser/editor state re-render the same component; do not cache tooltip text outside the render path.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Terminal custom title overrides the runtime title in both visible label and tooltip.
|
||||
- Terminal title updates from remote/SSH PTY state should update the tooltip through normal React props.
|
||||
- Browser tab with missing/blank title should show the URL-derived fallback or `New Tab`, matching the visible label.
|
||||
- Browser tooltip text must use `getBrowserTabLabel(tab)` from the same `tab` prop as the visible label, not `getLiveBrowserUrl` or the redacted context-menu URL; otherwise live URL drift can make the tooltip disagree with the tab.
|
||||
- Editor preview, dirty, git-status, conflict-review, diff, markdown-preview, and external-mutation labels should keep existing styling and adjacent badges.
|
||||
- Rename modes must not show stale title tooltips over the input.
|
||||
- Keyboard focus on a terminal, browser, or editor tab must show the same tooltip as hover. The existing dnd-kit root provides the focus target; do not add focusable descendants for tooltip-only behavior.
|
||||
- Context menu, middle-click close, drag start, and split drop indicators must still work because the sortable root and pointer handlers remain unchanged.
|
||||
- Very long tooltip text should wrap within a bounded width and stay readable in light/dark themes.
|
||||
- Context-menu open must force the corresponding tooltip closed for terminal, browser, and editor tabs.
|
||||
- Hovering root-child controls such as close, terminal collapse, loading indicators, dirty dots, and status badges may enter the root trigger; the tooltip must remain non-blocking and should not create duplicate/conflicting hover copy that makes those controls harder to use.
|
||||
- Multi-window and web/SSH remote-client paths should need no IPC or persistence work because this is renderer-only display of existing tab state. The implementation must still derive text from current props every render so external title/file mutations do not leave stale tooltip content while a tab remains mounted.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: add or update tab-bar component tests that mock tooltip primitives and assert:
|
||||
- terminal tab label tooltip content uses custom title when present;
|
||||
- browser tab label tooltip content uses `getBrowserTabLabel`;
|
||||
- editor tab label tooltip content uses `getEditorDisplayLabel`;
|
||||
- the tooltip trigger is the sortable root in non-rename mode, preserving dnd-kit `role`/`tabIndex` focusability;
|
||||
- rename/context-menu suppression is unit-tested only where the implementation exposes that state through a small testable render path; do not contort Node-only server-render tests to fake private interactive state.
|
||||
- Integration/Electron: create long terminal/browser/editor tab labels, hover each tab, wait for the app's 400ms tooltip delay, and verify a tooltip with the full label appears while tab activation/close still works.
|
||||
- Integration/Electron: keyboard-focus one terminal, browser, and editor tab and verify the tooltip appears without adding an extra tab stop inside the label.
|
||||
- Integration/Electron: enter terminal/editor rename modes and open terminal/browser/editor context menus, then verify the full-title tooltip is absent or dismissed.
|
||||
- Regression: run `pnpm typecheck`, `pnpm lint`, and the relevant tab-bar Vitest tests via `pnpm test -- <test-file>` if focused files are added.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
- Tooltip appears near the hovered/focused truncated label without covering the close button unnecessarily.
|
||||
- Tooltip uses existing Orca token-based styling through `TooltipContent`; no new colors, shadows, or typography.
|
||||
- Long text wraps at a readable max width, including long path/URL segments, and does not clip off-screen in a normal desktop viewport.
|
||||
- Tab dimensions, hover colors, active indicator, unread bell, dirty dot, git status, and close-button reveal behavior do not shift.
|
||||
- Light and dark theme surfaces remain legible.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. Terminal tab with a long custom or runtime title hovered, showing the full title tooltip.
|
||||
2. Browser tab with a long page title or URL hovered, showing the full title tooltip.
|
||||
3. Editor tab with a long filename/path-derived label hovered, showing the full title tooltip while dirty/status adornments still look unchanged.
|
||||
4. Adjacent smoke state: a keyboard-focused tab shows the tooltip, and close/context-menu affordances still work after tooltip wiring.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add tooltip imports and wrap the non-editing sortable tab roots in `SortableTab`, `BrowserTab`, and `EditorFileTab`.
|
||||
2. Add focused tests for tooltip label wiring.
|
||||
3. Run relevant tests, typecheck, and lint.
|
||||
4. Validate in Electron with hover screenshots for terminal, browser, editor, and adjacent tab controls.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: kept to renderer tab-label rendering only. No overflow observer, persistence, IPC, or new primitive is needed to satisfy the issue.
|
||||
- Architecture/data flow: title data already reaches the tab components; tooltip content should be derived in the same component that renders each visible label. This keeps terminal, browser, and editor ownership unchanged and avoids cross-window/SSH special cases. The trigger must be the focusable sortable root, not the inner label span, because the issue requires focus behavior.
|
||||
- Failure modes covered:
|
||||
- Tooltip root/trigger wiring could interfere with drag/activation if it adds an extra DOM wrapper or drops the dnd-kit ref/listeners/attributes.
|
||||
- Rename mode could show stale tooltip content over an input if the tooltip is not gated out.
|
||||
- Context-menu open could leave a delayed tooltip rendered over menu items unless `menuOpen` suppresses it.
|
||||
- Long tooltip text could overflow the viewport if content width is unbounded.
|
||||
- Browser fallback labels could drift if tooltip recomputes a different label than the visible span.
|
||||
- Editor badges/status text could shift if the tooltip adds a block wrapper inside the label row.
|
||||
- Root-level hover could show the tooltip while the pointer is over close/collapse/context-menu surfaces; validation must prove it does not block those controls or create unusable duplicate copy.
|
||||
- Test coverage required:
|
||||
- Unit/component: tab-bar label tooltip wiring for terminal, browser, and editor components.
|
||||
- Unit/component: terminal custom title precedence, browser fallback label parity, and non-rename root trigger focusability.
|
||||
- Electron validation: hover, keyboard-focus, rename suppression, context-menu suppression, and adjacent close/context-menu smoke for terminal/browser/editor labels.
|
||||
- Performance/blast radius: small but not free. This adds one Radix tooltip root per visible tab and a few event handlers on the existing tab root; that is acceptable because tab count is bounded by rendered tabs and there is no IPC, polling, file watching, persistence, startup work, or overflow measurement.
|
||||
- UI quality bar: existing tooltip primitive only, bounded wrapping, no tab-size shift, no overlap with close/action affordances, legible in light/dark.
|
||||
- Required review screenshots:
|
||||
1. Long terminal tab tooltip.
|
||||
2. Long browser tab tooltip.
|
||||
3. Long editor tab tooltip with existing adornments intact.
|
||||
4. Keyboard-focus tooltip plus adjacent close/context-menu smoke state.
|
||||
- Residual risks: Radix `TooltipTrigger asChild` must merge props/ref with the dnd-kit sortable root without disrupting pointer or keyboard drag behavior; unit tests with mocked dnd-kit can only prove wiring, so Electron validation must explicitly cover drag/activation adjacency, context-menu suppression, and focus-triggered display.
|
||||
@@ -0,0 +1,243 @@
|
||||
# SSH Handler Re-registration Port Forwards
|
||||
|
||||
## Problem
|
||||
|
||||
Issue #2932 reported that macOS window reactivation can re-run
|
||||
`attachMainWindowServices`, which calls `registerSshHandlers` again
|
||||
([src/main/window/attach-main-window-services.ts:83](src/main/window/attach-main-window-services.ts:83)).
|
||||
|
||||
Before this change, `registerSshHandlers` removed and re-added IPC handlers but
|
||||
also replaced the module-level `connectionManager` and `portForwardManager`
|
||||
([src/main/ipc/ssh.ts:438](src/main/ipc/ssh.ts:438),
|
||||
[src/main/ipc/ssh.ts:439](src/main/ipc/ssh.ts:439)).
|
||||
`activeSessions` remains module-global
|
||||
([src/main/ipc/ssh.ts:63](src/main/ipc/ssh.ts:63)),
|
||||
so live relay sessions kept references to the old port-forward manager while
|
||||
new IPC handlers read a fresh empty one.
|
||||
|
||||
The visible failure is:
|
||||
|
||||
1. Connect an SSH target.
|
||||
2. Add a local port forward.
|
||||
3. Close all windows on macOS while the app process remains alive.
|
||||
4. Reactivate Orca, causing SSH handlers to register again.
|
||||
5. `ssh:listPortForwards` returns an empty list, `ssh:removePortForward` cannot
|
||||
remove the old forward id, `ssh:addPortForward`/`ssh:updatePortForward` can
|
||||
fail because the fresh connection manager has no live connection, and
|
||||
`ssh:disconnect` does not close the old SSH connection or local listener.
|
||||
Re-adding the same local port fails because the old server remains bound.
|
||||
|
||||
## Root Cause
|
||||
|
||||
SSH handler registration mixes two lifetimes:
|
||||
|
||||
- Process-lifetime session state: active SSH connections, relay sessions, port
|
||||
listeners, relay lost backoff, reset/connect in-flight maps.
|
||||
- Window-lifetime callback state: `getMainWindow` and renderer IPC handlers.
|
||||
|
||||
The previous re-registration path preserved `activeSessions` but replaced the
|
||||
managers that sessions and IPC handlers must share. Port-forward IPC operations
|
||||
use `portForwardManager` ([src/main/ipc/ssh.ts:992](src/main/ipc/ssh.ts:992),
|
||||
[src/main/ipc/ssh.ts:1047](src/main/ipc/ssh.ts:1047),
|
||||
[src/main/ipc/ssh.ts:1056](src/main/ipc/ssh.ts:1056)),
|
||||
and disconnect/terminate cleanup also uses that variable
|
||||
([src/main/ipc/ssh.ts:750](src/main/ipc/ssh.ts:750),
|
||||
[src/main/ipc/ssh.ts:814](src/main/ipc/ssh.ts:814)).
|
||||
After replacement, those operations no longer targeted the manager that owns the
|
||||
live local servers. Replacing `connectionManager` also strands the live
|
||||
`SshConnection` objects: existing relay sessions still hold their current
|
||||
connection, but new IPC handlers and `getSshConnectionManager()` see an empty
|
||||
manager.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change relay protocol, remote deployment, or SSH transport behavior.
|
||||
- Do not redesign port-forward persistence or enrichment.
|
||||
- Do not change renderer UI.
|
||||
- Do not introduce a second SSH service layer.
|
||||
- Do not force-dispose live SSH sessions merely because a window was recreated.
|
||||
|
||||
## Design
|
||||
|
||||
1. Preserve process-lifetime managers across handler re-registration.
|
||||
Instantiate `SshConnectionManager` and `SshPortForwardManager` only when
|
||||
absent; later `registerSshHandlers` calls reuse the existing instances.
|
||||
|
||||
2. Refresh every live callback owner on re-registration. This is required; a
|
||||
plain `connectionManager ??= new SshConnectionManager(callbacks)` is not
|
||||
enough.
|
||||
- `SshConnectionManager` must update callbacks used by both future and
|
||||
existing `SshConnection` objects, either via explicit `setCallbacks` methods
|
||||
on manager/connection or via a stable callback proxy whose implementation
|
||||
is mutable.
|
||||
- Existing `SshRelaySession` objects must refresh `getMainWindow`, store,
|
||||
runtime, and detected-port callback references. Event handlers must call
|
||||
the current callback at event time; do not capture the old `getMainWindow`
|
||||
in long-lived provider callbacks.
|
||||
- The credential-request tracking set must not be per-registration if live
|
||||
connections can switch callbacks during an in-flight `ssh:connect`.
|
||||
|
||||
3. Re-register IPC handlers and dependent global listeners on every call.
|
||||
`ipcMain` handlers, advertised URL refresh, credential IPC, browse handler,
|
||||
and power-monitor listeners are window-registration concerns and should still
|
||||
point at the latest window.
|
||||
|
||||
4. Preserve existing explicit teardown behavior.
|
||||
`ssh:disconnect`, `ssh:terminateSessions`, `ssh:removeTarget`, reset, and
|
||||
double-connect cleanup must still remove forwards through the shared manager
|
||||
before detaching or disposing sessions.
|
||||
|
||||
5. Add regression tests in `src/main/ipc/ssh.test.ts`.
|
||||
Connect a target, add a mocked port forward, call `registerSshHandlers`
|
||||
again, then assert:
|
||||
- `ssh:listPortForwards` still returns the original forward.
|
||||
- `ssh:removePortForward` can remove the original id.
|
||||
- `ssh:addPortForward`/`ssh:updatePortForward` still use the original live
|
||||
connection.
|
||||
- A second re-registration followed by `ssh:disconnect` still calls
|
||||
`removeAllForwards` and `disconnect` on the original shared managers.
|
||||
- State, credential, PTY, and detected-port callbacks from an existing live
|
||||
session publish to the newest window after re-registration.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- First registration:
|
||||
- `registerSshHandlers(store, getWindowA)` creates store wrapper, connection
|
||||
manager, port-forward manager, handlers, listeners, and current callback
|
||||
environment.
|
||||
- `ssh:connect` creates a relay session with the shared port-forward manager.
|
||||
- `ssh:addPortForward` stores a local server in that same manager.
|
||||
|
||||
- Window reactivation:
|
||||
- `registerSshHandlers(store, getWindowB)` removes/re-adds IPC handlers.
|
||||
- Existing managers are reused.
|
||||
- Existing connection and relay-session callback owners are refreshed to the
|
||||
latest store/runtime/window environment.
|
||||
- New handlers close over `getWindowB` and call the same managers.
|
||||
|
||||
- Cleanup:
|
||||
- `ssh:removePortForward` and `ssh:disconnect` operate on the same manager
|
||||
that owns the live forward, then broadcast through the latest window.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Re-registration while a target is connected and has active port forwards.
|
||||
- Re-registration while `ssh:connect`, `restorePortForwards`, reset, reconnect,
|
||||
or disconnect is in flight. The operation must not split credential tracking
|
||||
or create a session that holds stale callbacks.
|
||||
- Re-registration while no targets are connected.
|
||||
- Re-registration after the store object changes. Either update existing relay
|
||||
sessions to use the new store/runtime or document and test the stronger
|
||||
invariant that production re-registration always passes the same process
|
||||
store/runtime.
|
||||
- Re-registration after the window changes. All broadcasts, credential prompts,
|
||||
PTY events, detected-port events, advertised URL refreshes, relay-loss state
|
||||
changes, and terminal relay errors must use the newest `getMainWindow`.
|
||||
- Disconnect after re-registration must release old local ports.
|
||||
- `ssh:connect` after window reactivation must be idempotent when the existing
|
||||
session is already ready and healthy: return the connected state without
|
||||
tearing down forwards. Explicit reset/reconnect or non-ready replacement paths
|
||||
must still await old port teardown before restoring forwards.
|
||||
- `getSshConnectionManager()` consumers must continue to see live connections
|
||||
after re-registration.
|
||||
- Test isolation must not depend on module-singleton state leaking between
|
||||
tests. Add explicit reset/teardown support if preserving managers makes
|
||||
`beforeEach(registerSshHandlers)` insufficient.
|
||||
- SSH and relay paths must keep working for remote targets; the fix must not
|
||||
assume local filesystem or local-only execution.
|
||||
- Windows/Linux remain unaffected: re-registration can still happen during
|
||||
development or future window lifecycles, and the fix must avoid path or
|
||||
platform assumptions.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: `pnpm vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts`
|
||||
- Add regression coverage for list/remove/disconnect after handler
|
||||
re-registration.
|
||||
- Add coverage that add/update after re-registration uses the still-live
|
||||
connection manager connection, not a fresh empty manager.
|
||||
- Add coverage that existing connection/session callbacks publish to a second
|
||||
mock window after re-registration.
|
||||
- Add an in-flight connect or credential-request test if callback refresh uses
|
||||
mutable callback objects.
|
||||
- Existing connect, disconnect, reset, relay-loss, and terminate tests cover
|
||||
adjacent lifecycle behavior.
|
||||
- Typecheck: `pnpm typecheck`.
|
||||
- Lint: `pnpm lint`.
|
||||
- Electron/SSH validation: use an existing SSH target such as `openclaw 2` if
|
||||
available in the running app, add a disposable local port forward, trigger
|
||||
window/service re-registration by closing and reopening the main window on
|
||||
macOS, then verify the forward remains listed and removable. IPC/unit tests
|
||||
are supporting evidence only; if the golden path cannot be exercised safely,
|
||||
halt before PR and report the missing evidence.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
Not UI-visible. No layout, copy, or visual styling changes are expected. The
|
||||
only user-visible expectation is that existing SSH port-forward rows remain
|
||||
present and actionable after window reactivation.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. SSH target connected with a port forward listed before re-registration.
|
||||
2. Same SSH target after window reactivation, showing the same port forward
|
||||
still listed.
|
||||
3. Same SSH target after removing the port forward, showing it gone without an
|
||||
error.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the focused regression test to prove the current lifecycle bug.
|
||||
2. Change SSH handler registration to reuse process-lifetime managers.
|
||||
3. Run the focused test, then typecheck and lint.
|
||||
4. Validate in Electron against an SSH target if feasible; otherwise halt
|
||||
before PR if the golden-path SSH UI cannot be exercised.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: reduced to SSH IPC lifecycle only. No relay, renderer, or persistence
|
||||
redesign is needed because the broken boundary is manager replacement during
|
||||
handler re-registration.
|
||||
- Architecture/data flow: process-lifetime managers stay module-level and are
|
||||
reused; window-lifetime IPC handlers/listeners are refreshed; existing
|
||||
connection and relay-session callback owners must also be refreshed or proxied
|
||||
so live events target the current BrowserWindow.
|
||||
- Failure modes covered:
|
||||
- Active forwards becoming invisible after re-registration.
|
||||
- `ssh:removePortForward` missing the old forward id.
|
||||
- `ssh:addPortForward`/`ssh:updatePortForward` failing against a fresh empty
|
||||
connection manager.
|
||||
- `ssh:disconnect` failing to close old SSH connections and local listeners
|
||||
after re-registration.
|
||||
- Store/window/runtime callback refresh after re-registration.
|
||||
- Re-registration during in-flight connect/reset/reconnect.
|
||||
- No-session re-registration continuing to work.
|
||||
- Test coverage required:
|
||||
- Unit in `src/main/ipc/ssh.test.ts` for connect/add/list/remove across
|
||||
`registerSshHandlers` calls.
|
||||
- Unit in `src/main/ipc/ssh.test.ts` for disconnect cleanup after
|
||||
re-registration.
|
||||
- Unit in `src/main/ipc/ssh.test.ts` for existing live callbacks reaching the
|
||||
newest window after re-registration.
|
||||
- Unit in `src/main/ipc/ssh.test.ts` for no-session re-registration and test
|
||||
teardown/reset of module singletons.
|
||||
- Existing lifecycle tests for reset, terminate, relay loss, and sleep remain
|
||||
adjacent coverage.
|
||||
- Performance/blast radius: no material startup or IPC cost. Reusing managers
|
||||
avoids leaked runtime state and does not add polling, watchers, or
|
||||
cross-process calls. Callback refresh is O(number of live SSH connections and
|
||||
sessions) per registration, which should be tiny.
|
||||
- UI quality bar: not UI-visible; preserve existing SSH port-forward UI state
|
||||
rather than changing layout or copy.
|
||||
- Required review screenshots:
|
||||
1. Connected SSH target with active port forward before re-registration.
|
||||
2. Connected SSH target with same port forward after re-registration.
|
||||
3. Connected SSH target after removing that forward.
|
||||
- Feasibility: one-time manager creation is feasible only with callback refresh
|
||||
for existing `SshConnection` and `SshRelaySession` instances. If that refresh
|
||||
proves larger than expected, prefer a stable callback proxy over recreating
|
||||
managers; do not dispose live sessions just to make callback ownership easier.
|
||||
- Residual risks: Electron validation may be constrained by availability of an
|
||||
existing SSH target and by avoiding live-user port collisions. If the golden
|
||||
path cannot be exercised safely, stop before opening a PR and report the
|
||||
missing manual evidence.
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
FileText,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Keyboard as KeyboardIcon,
|
||||
Mic,
|
||||
Monitor,
|
||||
Plus,
|
||||
@@ -59,6 +60,10 @@ import {
|
||||
type TerminalWebViewHandle
|
||||
} from '../../../../src/terminal/TerminalWebView'
|
||||
import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys'
|
||||
import {
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
|
||||
import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
|
||||
import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url'
|
||||
@@ -335,7 +340,8 @@ function TerminalPaneView({
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput
|
||||
onTerminalInput,
|
||||
onTerminalTap
|
||||
}: {
|
||||
handle: string
|
||||
active: boolean
|
||||
@@ -350,6 +356,7 @@ function TerminalPaneView({
|
||||
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput: (handle: string, bytes: string) => void
|
||||
onTerminalTap: (handle: string) => void
|
||||
}) {
|
||||
const setRef = useCallback(
|
||||
(ref: TerminalWebViewHandle | null) => {
|
||||
@@ -379,6 +386,7 @@ function TerminalPaneView({
|
||||
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
|
||||
onHaptic={onHaptic}
|
||||
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
|
||||
onTerminalTap={() => onTerminalTap(handle)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
@@ -679,6 +687,10 @@ export default function SessionScreen() {
|
||||
const sessionTabsRef = useRef<MobileSessionTab[]>([])
|
||||
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
const [liveInputCapture, setLiveInputCapture] = useState('')
|
||||
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [activeHandle, setActiveHandle] = useState<string | null>(null)
|
||||
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
|
||||
const activeSessionTabIdRef = useRef<string | null>(null)
|
||||
@@ -745,6 +757,7 @@ export default function SessionScreen() {
|
||||
const viewportRef = useRef<{ cols: number; rows: number } | null>(null)
|
||||
const viewportMeasuredRef = useRef(false)
|
||||
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
|
||||
const liveInputRef = useRef<TextInput>(null)
|
||||
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
|
||||
const subscribingHandlesRef = useRef<Set<string>>(new Set())
|
||||
const initializedHandlesRef = useRef<Set<string>>(new Set())
|
||||
@@ -781,6 +794,7 @@ export default function SessionScreen() {
|
||||
activeSessionTab?.type !== 'markdown' &&
|
||||
activeSessionTab?.type !== 'file' &&
|
||||
activeSessionTab?.type !== 'browser'
|
||||
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
|
||||
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1818,6 +1832,8 @@ export default function SessionScreen() {
|
||||
terminalsRef.current = []
|
||||
setSessionTabs([])
|
||||
setActiveSessionTabId(null)
|
||||
setLiveInputCapture('')
|
||||
setLiveInputTerminalHandles(new Set())
|
||||
setMarkdownDocs(new Map())
|
||||
setFileDocs(new Map())
|
||||
}, [clearTerminalCache, worktreeId])
|
||||
@@ -2146,6 +2162,117 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
const sendLiveTerminalInput = useCallback(
|
||||
(handle: string, bytes: string) => {
|
||||
if (bytes.length === 0) return
|
||||
if (!isTerminalLiveInputWithinByteLimit(bytes)) {
|
||||
triggerError()
|
||||
showToast('Input too large (max 256 KiB)', 1500)
|
||||
return
|
||||
}
|
||||
const rpc = clientRef.current
|
||||
if (
|
||||
!rpc ||
|
||||
connStateRef.current !== 'connected' ||
|
||||
handle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal'
|
||||
) {
|
||||
return
|
||||
}
|
||||
void rpc
|
||||
.sendRequest('terminal.send', {
|
||||
terminal: handle,
|
||||
text: bytes,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
.catch(() => {
|
||||
// Transient failure
|
||||
})
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const focusLiveInput = useCallback(() => {
|
||||
if (!canSend || !liveInputEnabled) return
|
||||
liveInputRef.current?.focus()
|
||||
}, [canSend, liveInputEnabled])
|
||||
|
||||
const handleTerminalTap = useCallback(
|
||||
(handle: string) => {
|
||||
if (handle !== activeHandleRef.current) return
|
||||
focusLiveInput()
|
||||
},
|
||||
[focusLiveInput]
|
||||
)
|
||||
|
||||
const toggleLiveInput = useCallback(() => {
|
||||
if (!activeHandle) return
|
||||
const nextEnabled = !liveInputTerminalHandles.has(activeHandle)
|
||||
setLiveInputTerminalHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (nextEnabled) {
|
||||
next.add(activeHandle)
|
||||
} else {
|
||||
next.delete(activeHandle)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setLiveInputCapture('')
|
||||
if (nextEnabled) {
|
||||
setTimeout(() => liveInputRef.current?.focus(), 50)
|
||||
} else {
|
||||
liveInputRef.current?.blur()
|
||||
}
|
||||
}, [activeHandle, liveInputTerminalHandles])
|
||||
|
||||
const handleLiveInputChange = useCallback(
|
||||
(text: string) => {
|
||||
if (!activeHandle) {
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
return
|
||||
}
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
return
|
||||
}
|
||||
if (text.length > 0) {
|
||||
sendLiveTerminalInput(activeHandle, text)
|
||||
}
|
||||
setLiveInputCapture('')
|
||||
// Why: the field is only a keyboard capture surface. Clearing the
|
||||
// native value prevents subsequent phone-keyboard events from replaying
|
||||
// already-sent characters when React state remains the empty string.
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
},
|
||||
[activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
|
||||
)
|
||||
|
||||
const handleLiveInputKeyPress = useCallback(
|
||||
(event: { nativeEvent: { key: string } }) => {
|
||||
if (!activeHandle) return
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) return
|
||||
const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key)
|
||||
if (!bytes) return
|
||||
sendLiveTerminalInput(activeHandle, bytes)
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
},
|
||||
[activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
|
||||
)
|
||||
|
||||
const handleLiveInputSubmit = useCallback(() => {
|
||||
if (!activeHandle) return
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) return
|
||||
sendLiveTerminalInput(activeHandle, '\r')
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
}, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput])
|
||||
|
||||
const allowTerminalGestureInput = useCallback(
|
||||
(handle: string, sequenceCount: number): boolean => {
|
||||
const now = Date.now()
|
||||
@@ -3085,6 +3212,7 @@ export default function SessionScreen() {
|
||||
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
|
||||
onHaptic={handleHaptic}
|
||||
onTerminalInput={handleTerminalInput}
|
||||
onTerminalTap={handleTerminalTap}
|
||||
/>
|
||||
))}
|
||||
{toastMessage && (
|
||||
@@ -3136,6 +3264,31 @@ export default function SessionScreen() {
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.accessoryKey,
|
||||
liveInputEnabled && styles.accessoryKeyActive,
|
||||
pressed && styles.accessoryKeyPressed,
|
||||
!canSend && styles.accessoryKeyDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={toggleLiveInput}
|
||||
accessibilityLabel={
|
||||
liveInputEnabled
|
||||
? 'Switch to buffered command input'
|
||||
: 'Switch to live terminal input'
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.accessoryKeyText,
|
||||
liveInputEnabled && styles.accessoryKeyTextActive,
|
||||
!canSend && styles.accessoryKeyTextDisabled
|
||||
]}
|
||||
>
|
||||
Live
|
||||
</Text>
|
||||
</Pressable>
|
||||
{canPaste && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
@@ -3223,72 +3376,107 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
|
||||
{/* Input bar */}
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="Type a command…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="send"
|
||||
editable={canSend}
|
||||
onSubmitEditing={() => void handleSend()}
|
||||
/>
|
||||
{liveInputEnabled ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.dictationButton,
|
||||
(dictation.isStarting || dictation.isRecording) && styles.dictationButtonActive,
|
||||
!canSend && styles.sendButtonDisabled
|
||||
]}
|
||||
style={[styles.inputBar, styles.liveInputBar]}
|
||||
disabled={!canSend}
|
||||
onPress={() => {
|
||||
if (dictation.isProcessing) {
|
||||
void dictation.cancel()
|
||||
} else if (dictation.isStarting) {
|
||||
return
|
||||
} else if (dictation.isRecording) {
|
||||
void dictation.stop()
|
||||
} else {
|
||||
void dictation.start().catch((err) => {
|
||||
triggerError()
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
if (dictation.isRecording || dictation.isProcessing) {
|
||||
void dictation.cancel()
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={
|
||||
dictation.isRecording
|
||||
? 'Stop voice dictation'
|
||||
: dictation.isProcessing
|
||||
? 'Cancel voice dictation'
|
||||
: dictation.isStarting
|
||||
? 'Starting voice dictation'
|
||||
: 'Start voice dictation'
|
||||
}
|
||||
onPress={focusLiveInput}
|
||||
accessibilityLabel="Focus live terminal input"
|
||||
>
|
||||
{dictation.isProcessing ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : dictation.isStarting || dictation.isRecording ? (
|
||||
<Mic size={17} color={colors.textPrimary} strokeWidth={2.4} />
|
||||
) : (
|
||||
<Mic size={17} color={colors.textSecondary} strokeWidth={2.4} />
|
||||
)}
|
||||
<View style={styles.liveInputBadge}>
|
||||
<KeyboardIcon size={13} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
<Text style={styles.liveInputBadgeText}>Live</Text>
|
||||
</View>
|
||||
<Text style={styles.liveInputHint} numberOfLines={1}>
|
||||
Keyboard input goes to terminal
|
||||
</Text>
|
||||
<TextInput
|
||||
ref={liveInputRef}
|
||||
style={styles.liveInputCapture}
|
||||
value={liveInputCapture}
|
||||
onChangeText={handleLiveInputChange}
|
||||
onKeyPress={handleLiveInputKeyPress}
|
||||
onSubmitEditing={handleLiveInputSubmit}
|
||||
placeholder=""
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'}
|
||||
returnKeyType="default"
|
||||
blurOnSubmit={false}
|
||||
editable={canSend}
|
||||
importantForAutofill="no"
|
||||
textContentType="none"
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
|
||||
disabled={!canSend}
|
||||
onPress={() => void handleSend()}
|
||||
accessibilityLabel="Send command"
|
||||
>
|
||||
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="Type a command…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="send"
|
||||
editable={canSend}
|
||||
onSubmitEditing={() => void handleSend()}
|
||||
/>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.dictationButton,
|
||||
(dictation.isStarting || dictation.isRecording) && styles.dictationButtonActive,
|
||||
!canSend && styles.sendButtonDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={() => {
|
||||
if (dictation.isProcessing) {
|
||||
void dictation.cancel()
|
||||
} else if (dictation.isStarting) {
|
||||
return
|
||||
} else if (dictation.isRecording) {
|
||||
void dictation.stop()
|
||||
} else {
|
||||
void dictation.start().catch((err) => {
|
||||
triggerError()
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
if (dictation.isRecording || dictation.isProcessing) {
|
||||
void dictation.cancel()
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={
|
||||
dictation.isRecording
|
||||
? 'Stop voice dictation'
|
||||
: dictation.isProcessing
|
||||
? 'Cancel voice dictation'
|
||||
: dictation.isStarting
|
||||
? 'Starting voice dictation'
|
||||
: 'Start voice dictation'
|
||||
}
|
||||
>
|
||||
{dictation.isProcessing ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : dictation.isStarting || dictation.isRecording ? (
|
||||
<Mic size={17} color={colors.textPrimary} strokeWidth={2.4} />
|
||||
) : (
|
||||
<Mic size={17} color={colors.textSecondary} strokeWidth={2.4} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
|
||||
disabled={!canSend}
|
||||
onPress={() => void handleSend()}
|
||||
accessibilityLabel="Send command"
|
||||
>
|
||||
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -4005,6 +4193,9 @@ const styles = StyleSheet.create({
|
||||
accessoryKeyPressed: {
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
accessoryKeyActive: {
|
||||
backgroundColor: colors.accentBlue
|
||||
},
|
||||
customAccessoryKey: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
@@ -4017,6 +4208,10 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
accessoryKeyTextActive: {
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '700'
|
||||
},
|
||||
accessoryKeyTextDisabled: {
|
||||
color: colors.textMuted
|
||||
},
|
||||
@@ -4040,6 +4235,37 @@ const styles = StyleSheet.create({
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
liveInputBar: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
liveInputBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.accentBlue,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
liveInputBadgeText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
liveInputHint: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
liveInputCapture: {
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
width: 34,
|
||||
|
||||
@@ -31,6 +31,7 @@ export type TerminalSelectionEvents = {
|
||||
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput?: (bytes: string) => void
|
||||
onTerminalTap?: () => void
|
||||
}
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
@@ -1538,6 +1539,9 @@ const XTERM_HTML = `<!DOCTYPE html>
|
||||
return;
|
||||
}
|
||||
if (dispatch.mode === 'surface') {
|
||||
if (e.touches.length === 0 && longPressOrigin && selMode !== 'select') {
|
||||
notify({ type: 'terminal-tap' });
|
||||
}
|
||||
clearLongPress();
|
||||
if (e.touches.length === 0) {
|
||||
dispatch.mode = 'idle';
|
||||
@@ -1797,7 +1801,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput
|
||||
onTerminalInput,
|
||||
onTerminalTap
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -1900,6 +1905,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
} else if (msg.type === 'terminal-input') {
|
||||
const bytes = typeof msg.bytes === 'string' ? msg.bytes : ''
|
||||
if (bytes.length > 0) onTerminalInput?.(bytes)
|
||||
} else if (msg.type === 'terminal-tap') {
|
||||
onTerminalTap?.()
|
||||
} else if (msg.type === 'keyboard-avoidance-metrics') {
|
||||
const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0
|
||||
const rows = typeof msg.rows === 'number' ? msg.rows : 0
|
||||
@@ -1932,7 +1939,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput
|
||||
onTerminalInput,
|
||||
onTerminalTap
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIVE_INPUT_MAX_BYTES,
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit
|
||||
} from './terminal-live-input'
|
||||
|
||||
describe('terminal live input', () => {
|
||||
it('maps phone keyboard special keys to PTY bytes', () => {
|
||||
expect(getTerminalLiveSpecialKeyBytes('Backspace')).toBe('\x7f')
|
||||
expect(getTerminalLiveSpecialKeyBytes('Enter')).toBeNull()
|
||||
expect(getTerminalLiveSpecialKeyBytes('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('enforces the paste-sized byte budget', () => {
|
||||
expect(isTerminalLiveInputWithinByteLimit('hello')).toBe(true)
|
||||
expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES))).toBe(true)
|
||||
expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES + 1))).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
isTerminalLiveInputWithinByteLimit('é'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES / 2 + 1))
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
export function getTerminalLiveSpecialKeyBytes(key: string): string | null {
|
||||
if (key === 'Backspace') {
|
||||
return '\x7f'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isTerminalLiveInputWithinByteLimit(
|
||||
text: string,
|
||||
maxBytes = TERMINAL_LIVE_INPUT_MAX_BYTES
|
||||
): boolean {
|
||||
return encoder.encode(text).byteLength <= maxBytes
|
||||
}
|
||||
|
||||
export { TERMINAL_LIVE_INPUT_MAX_BYTES }
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.31",
|
||||
"version": "1.4.34",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
Generated
+3
-3
@@ -12,7 +12,7 @@ patchedDependencies:
|
||||
hash: b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4
|
||||
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
|
||||
path: config/patches/node-pty@1.1.0.patch
|
||||
|
||||
importers:
|
||||
@@ -162,7 +162,7 @@ importers:
|
||||
version: 0.55.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4)
|
||||
version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282)
|
||||
pdfjs-dist:
|
||||
specifier: ^5.7.284
|
||||
version: 5.7.284
|
||||
@@ -11367,7 +11367,7 @@ snapshots:
|
||||
undici: 6.25.0
|
||||
which: 6.0.1
|
||||
|
||||
node-pty@1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4):
|
||||
node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282):
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
// Why: isolate the handler's flag-to-param mapping; printResult only writes output.
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('orchestration reset CLI handler', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } })
|
||||
})
|
||||
|
||||
const invoke = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration reset']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
it('sends all: true for a bare `reset` (no scope flag)', async () => {
|
||||
await invoke(new Map())
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: true,
|
||||
tasks: undefined,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('sends only the tasks scope for --tasks', async () => {
|
||||
await invoke(new Map([['tasks', true]]))
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: undefined,
|
||||
tasks: true,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('sends only the all scope for --all (no implicit extra scopes)', async () => {
|
||||
await invoke(new Map([['all', true]]))
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: true,
|
||||
tasks: undefined,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -438,8 +438,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
|
||||
'orchestration reset': async ({ flags, client, json }) => {
|
||||
const hasScopeFlag = flags.has('all') || flags.has('tasks') || flags.has('messages')
|
||||
const result = await client.call<{ reset: string }>('orchestration.reset', {
|
||||
all: flags.has('all') ? true : undefined,
|
||||
all: flags.has('all') || !hasScopeFlag ? true : undefined,
|
||||
tasks: flags.has('tasks') ? true : undefined,
|
||||
messages: flags.has('messages') ? true : undefined
|
||||
})
|
||||
|
||||
@@ -1311,6 +1311,54 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients')
|
||||
})
|
||||
|
||||
it('passes all reset scope explicitly for no-flag orchestration reset', async () => {
|
||||
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset: 'all' }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['orchestration', 'reset'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: true,
|
||||
tasks: undefined,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
args: ['orchestration', 'reset', '--all'],
|
||||
params: { all: true, tasks: undefined, messages: undefined },
|
||||
reset: 'all'
|
||||
},
|
||||
{
|
||||
args: ['orchestration', 'reset', '--tasks'],
|
||||
params: { all: undefined, tasks: true, messages: undefined },
|
||||
reset: 'tasks'
|
||||
},
|
||||
{
|
||||
args: ['orchestration', 'reset', '--messages'],
|
||||
params: { all: undefined, tasks: undefined, messages: true },
|
||||
reset: 'messages'
|
||||
},
|
||||
{
|
||||
args: ['orchestration', 'reset', '--tasks', '--messages'],
|
||||
params: { all: undefined, tasks: true, messages: true },
|
||||
reset: 'tasks'
|
||||
},
|
||||
{
|
||||
args: ['orchestration', 'reset', '--all', '--tasks'],
|
||||
params: { all: true, tasks: true, messages: undefined },
|
||||
reset: 'all'
|
||||
}
|
||||
])('passes explicit reset flags through for $args', async ({ args, params, reset }) => {
|
||||
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(args, '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', params)
|
||||
})
|
||||
|
||||
it('rejects unknown task-update status with an enum-aware error', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -136,8 +136,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'reset'],
|
||||
summary: 'Reset orchestration state',
|
||||
usage: 'orca orchestration reset [--all] [--tasks] [--messages] [--json]',
|
||||
summary: 'Reset orchestration state (one scope; bare command resets all)',
|
||||
usage: 'orca orchestration reset [--all | --tasks | --messages] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -228,11 +228,12 @@ describe('remote hook service installers', () => {
|
||||
const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) {
|
||||
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']) {
|
||||
const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
expect(geminiConfig.hooks.PreToolUse).toBeUndefined()
|
||||
|
||||
const antigravityConfig = JSON.parse(
|
||||
antigravity.fs.files.get('/home/dev/.gemini/config/hooks.json')!
|
||||
@@ -361,6 +362,54 @@ describe('remote hook service installers', () => {
|
||||
expect(postToolCommands.some((command) => command.includes('antigravity-hook.sh'))).toBe(true)
|
||||
})
|
||||
|
||||
it('removes stale remote Gemini PreToolUse hooks while preserving user-authored hooks', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.files.set(
|
||||
'/home/dev/.gemini/settings.json',
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
"if [ -x '/tmp/old/agent-hooks/gemini-hook.sh' ]; then /bin/sh '/tmp/old/agent-hooks/gemini-hook.sh'; fi"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'echo user-authored'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
|
||||
await new GeminiHookService().installRemote(sftp, '/home/dev')
|
||||
|
||||
const config = JSON.parse(fs.files.get('/home/dev/.gemini/settings.json')!) as {
|
||||
hooks: Record<string, { hooks?: { command: string }[] }[]>
|
||||
}
|
||||
const preToolCommands = config.hooks.PreToolUse.flatMap((definition) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
expect(preToolCommands).toEqual(['echo user-authored'])
|
||||
const beforeToolCommands = config.hooks.BeforeTool.flatMap((definition) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
expect(beforeToolCommands.some((command) => command.includes('gemini-hook.sh'))).toBe(true)
|
||||
})
|
||||
|
||||
it('installs remote Copilot hooks under the user-level hooks directory', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.dirs.add('/home/dev/.copilot')
|
||||
|
||||
@@ -2655,11 +2655,11 @@ describe('Codex hook normalization', () => {
|
||||
})
|
||||
|
||||
describe('Gemini hook normalization', () => {
|
||||
it('PreToolUse surfaces toolName + toolInput', () => {
|
||||
it('BeforeTool surfaces toolName + toolInput', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'gemini',
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
hook_event_name: 'BeforeTool',
|
||||
tool_name: 'read_file',
|
||||
tool_input: { path: '/src/index.ts' }
|
||||
}),
|
||||
@@ -2674,7 +2674,7 @@ describe('Gemini hook normalization', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'gemini',
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
hook_event_name: 'BeforeTool',
|
||||
tool_name: 'run_shell_command',
|
||||
args: { command: 'git status' }
|
||||
}),
|
||||
@@ -2688,7 +2688,7 @@ describe('Gemini hook normalization', () => {
|
||||
_internals.normalizeHookPayload(
|
||||
'gemini',
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
hook_event_name: 'BeforeTool',
|
||||
tool_name: 'read_file',
|
||||
tool_input: { path: '/stale.ts' }
|
||||
}),
|
||||
|
||||
@@ -120,6 +120,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
mobileAutoRestoreFitMs: null,
|
||||
experimentalPet: false,
|
||||
experimentalActivity: true,
|
||||
experimentalTerminalAttention: false,
|
||||
experimentalWorktreeSymlinks: false,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
|
||||
@@ -107,6 +107,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
mobileAutoRestoreFitMs: null,
|
||||
experimentalPet: false,
|
||||
experimentalActivity: true,
|
||||
experimentalTerminalAttention: false,
|
||||
experimentalWorktreeSymlinks: false,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
|
||||
@@ -252,6 +252,58 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`))
|
||||
})
|
||||
|
||||
it('mirrors system user hook approvals when the system trust indices are stale', () => {
|
||||
const systemCodexHome = join(tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command: 'first-stop-hook' }] },
|
||||
{ hooks: [{ type: 'command', command: 'second-stop-hook' }] }
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
upsertHookTrustEntriesInContent('model = "system-model"\n', [
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'second-stop-hook'
|
||||
},
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 1,
|
||||
handlerIndex: 0,
|
||||
command: 'first-stop-hook'
|
||||
}
|
||||
]),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`))
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`))
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`))
|
||||
})
|
||||
|
||||
it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', () => {
|
||||
const pluginCommands = [
|
||||
'node "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.mjs"',
|
||||
|
||||
@@ -312,6 +312,7 @@ function getTrustedSystemUserHookSignatures(
|
||||
console.warn('[codex-hook-service] failed to read system hook trust entries', error)
|
||||
return signatures
|
||||
}
|
||||
const trustedHashesByEvent = getTrustedSystemHookHashesByEvent(systemConfigPath, trustEntries)
|
||||
for (const [eventName, definitions] of Object.entries(systemHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
@@ -333,15 +334,20 @@ function getTrustedSystemUserHookSignatures(
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
const expectedHash = computeTrustedHash(entry)
|
||||
const state = trustEntries.get(computeTrustKey(entry))
|
||||
if (state?.trustedHash === computeTrustedHash(entry)) {
|
||||
const signature = getTrustSignature(entry)
|
||||
const enabled = state.enabled !== false
|
||||
// Why: runtime deduping collapses identical system hook definitions;
|
||||
// if any duplicate remains enabled, keep the mirrored hook enabled.
|
||||
if (enabled || !signatures.has(signature)) {
|
||||
signatures.set(signature, enabled)
|
||||
}
|
||||
const enabled =
|
||||
state?.trustedHash === expectedHash
|
||||
? state.enabled !== false
|
||||
: trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash)
|
||||
if (enabled === undefined) {
|
||||
return
|
||||
}
|
||||
const signature = getTrustSignature(entry)
|
||||
// Why: runtime deduping collapses identical system hook definitions;
|
||||
// if any duplicate remains enabled, keep the mirrored hook enabled.
|
||||
if (enabled || !signatures.has(signature)) {
|
||||
signatures.set(signature, enabled)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -349,6 +355,35 @@ function getTrustedSystemUserHookSignatures(
|
||||
return signatures
|
||||
}
|
||||
|
||||
function getTrustedSystemHookHashesByEvent(
|
||||
systemConfigPath: string,
|
||||
trustEntries: ReadonlyMap<string, CodexHookTrustState>
|
||||
): Map<CodexEventLabel, Map<string, boolean>> {
|
||||
const trustedHashesByEvent = new Map<CodexEventLabel, Map<string, boolean>>()
|
||||
const canonicalSystemConfigPath = getCodexCanonicalTrustPath(systemConfigPath)
|
||||
for (const [key, state] of trustEntries) {
|
||||
const parsed = parseTrustKey(key)
|
||||
if (!parsed || !state.trustedHash) {
|
||||
continue
|
||||
}
|
||||
if (getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalSystemConfigPath) {
|
||||
continue
|
||||
}
|
||||
let hashes = trustedHashesByEvent.get(parsed.eventLabel)
|
||||
if (!hashes) {
|
||||
hashes = new Map()
|
||||
trustedHashesByEvent.set(parsed.eventLabel, hashes)
|
||||
}
|
||||
const enabled = state.enabled !== false
|
||||
// Why: Codex trust keys include hook indices. If a user reorders hooks,
|
||||
// the hash still proves the same event+command identity was approved.
|
||||
if (enabled || !hashes.has(state.trustedHash)) {
|
||||
hashes.set(state.trustedHash, enabled)
|
||||
}
|
||||
}
|
||||
return trustedHashesByEvent
|
||||
}
|
||||
|
||||
function collectMirroredRuntimeUserHookTrustEntries(
|
||||
runtimeConfigPath: string,
|
||||
runtimeHooks: Record<string, HookDefinition[]>,
|
||||
|
||||
@@ -64,11 +64,13 @@ const {
|
||||
const killStaleDaemonMock = vi.fn(async () => true)
|
||||
const getProcessStartedAtMsMock = vi.fn(() => 1_000_000)
|
||||
|
||||
const daemonClientMock = vi.fn().mockImplementation(() => ({
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
request: vi.fn(async () => ({ sessions: [] })),
|
||||
disconnect: vi.fn()
|
||||
}))
|
||||
const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
request: vi.fn(async () => ({ sessions: [] })),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: every DaemonSpawner constructed under test pushes into this array so
|
||||
// assertions can check "was the *same* spawner reused across restart?".
|
||||
@@ -435,6 +437,30 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(legacyAdapter.listProcesses).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes affected v9 daemon sessions through a legacy adapter on launch', async () => {
|
||||
const mod = await importFresh()
|
||||
probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false)
|
||||
netConnectMock.mockImplementation(() => {
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
return {
|
||||
on(event: string, cb: () => void) {
|
||||
handlers[event]?.push(cb)
|
||||
if (event === 'connect') {
|
||||
queueMicrotask(() => cb())
|
||||
}
|
||||
return this
|
||||
},
|
||||
destroy() {}
|
||||
}
|
||||
})
|
||||
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const { DaemonPtyRouter } = await import('./daemon-pty-router')
|
||||
expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyRouter)
|
||||
expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true)
|
||||
})
|
||||
|
||||
it('restart path with no legacy adapters yields a bare DaemonPtyAdapter (not wrapped in a router)', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
@@ -734,6 +760,79 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a resolver-unhealthy daemon when it owns live sessions', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const requestMock = vi.fn(async (method: string) => {
|
||||
if (method === 'listSessions') {
|
||||
return {
|
||||
sessions: [
|
||||
{ sessionId: 'wt-1@@live', isAlive: true },
|
||||
{ sessionId: 'wt-1@@dead', isAlive: false }
|
||||
]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const disconnectMock = vi.fn()
|
||||
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
request: requestMock,
|
||||
disconnect: disconnectMock
|
||||
}
|
||||
})
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy')
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token')
|
||||
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined)
|
||||
expect(disconnectMock).toHaveBeenCalledOnce()
|
||||
expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled()
|
||||
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a resolver-unhealthy daemon when live session state cannot be verified', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const requestMock = vi.fn(async (method: string) => {
|
||||
if (method === 'listSessions') {
|
||||
throw new Error('listSessions failed')
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const disconnectMock = vi.fn()
|
||||
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
request: requestMock,
|
||||
disconnect: disconnectMock
|
||||
}
|
||||
})
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy')
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined)
|
||||
expect(disconnectMock).toHaveBeenCalledOnce()
|
||||
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the direct daemon entry when Electron app path is already out/main', async () => {
|
||||
probeSocketExistsMock.mockImplementation(
|
||||
(p?: string) => p === '/fake/app/out/main/daemon-entry.js'
|
||||
|
||||
@@ -99,6 +99,34 @@ function probeSocket(socketPath: string): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
async function getAliveDaemonSessionCount(
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
protocolVersion = PROTOCOL_VERSION
|
||||
): Promise<number | null> {
|
||||
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
|
||||
try {
|
||||
await client.ensureConnected()
|
||||
const result = await client.request<ListSessionsResult>('listSessions', undefined)
|
||||
return result.sessions.filter((session) => session.isAlive).length
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
client.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
function createPreservedDaemonHandle(
|
||||
runtimeDir: string,
|
||||
protocolVersion = PROTOCOL_VERSION
|
||||
): { shutdown(): Promise<void> } {
|
||||
return {
|
||||
shutdown: async () => {
|
||||
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
return async (socketPath, tokenPath) => {
|
||||
const entryPath = getDaemonEntryPath()
|
||||
@@ -106,6 +134,15 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
if (healthy) {
|
||||
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
|
||||
if (resolverHealth === 'unhealthy') {
|
||||
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
|
||||
if (liveSessionCount !== 0) {
|
||||
console.warn(
|
||||
liveSessionCount === null
|
||||
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
|
||||
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
|
||||
)
|
||||
return createPreservedDaemonHandle(runtimeDir)
|
||||
}
|
||||
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
|
||||
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
|
||||
} else {
|
||||
@@ -122,11 +159,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
} else {
|
||||
// Why: daemon is already running from a previous app session and
|
||||
// responded to a protocol-level ping. Safe to reuse.
|
||||
return {
|
||||
shutdown: async () => {
|
||||
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
|
||||
}
|
||||
}
|
||||
return createPreservedDaemonHandle(runtimeDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,7 +852,52 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
await respawnServer?.shutdown()
|
||||
})
|
||||
|
||||
it('replaces an unhealthy macOS resolver daemon before creating a fresh session', async () => {
|
||||
it('preserves an unhealthy macOS resolver daemon when it owns live sessions', async () => {
|
||||
const respawnFn = vi.fn()
|
||||
const exits: { id: string; code: number }[] = []
|
||||
const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn })
|
||||
respawnAdapter.onExit((payload) => exits.push(payload))
|
||||
const existing = await respawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy')
|
||||
|
||||
const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
|
||||
|
||||
expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
respawnAdapter.protocolVersion
|
||||
)
|
||||
expect(respawnFn).not.toHaveBeenCalled()
|
||||
expect(exits).toEqual([])
|
||||
expect(next.id).toBeDefined()
|
||||
expect(next.id).not.toBe(existing.id)
|
||||
|
||||
respawnAdapter.dispose()
|
||||
})
|
||||
|
||||
it('preserves an unhealthy macOS resolver daemon when live sessions have not been reconciled locally', async () => {
|
||||
const respawnFn = vi.fn()
|
||||
const background = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
const backgroundSubprocess = lastSubprocess
|
||||
const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn })
|
||||
getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy')
|
||||
|
||||
const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
|
||||
|
||||
expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
respawnAdapter.protocolVersion
|
||||
)
|
||||
expect(respawnFn).not.toHaveBeenCalled()
|
||||
expect(next.id).toBeDefined()
|
||||
expect(next.id).not.toBe(background.id)
|
||||
expect(backgroundSubprocess.forceKill).not.toHaveBeenCalled()
|
||||
|
||||
respawnAdapter.dispose()
|
||||
})
|
||||
|
||||
it('replaces an unhealthy macOS resolver daemon before creating a fresh session when no sessions are active', async () => {
|
||||
let respawnServer: DaemonServer | undefined
|
||||
const respawnFn = vi.fn(async () => {
|
||||
await server.shutdown()
|
||||
@@ -867,7 +912,6 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
const exits: { id: string; code: number }[] = []
|
||||
const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn })
|
||||
respawnAdapter.onExit((payload) => exits.push(payload))
|
||||
const existing = await respawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy')
|
||||
|
||||
const replacement = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
|
||||
@@ -878,7 +922,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
respawnAdapter.protocolVersion
|
||||
)
|
||||
expect(respawnFn).toHaveBeenCalledOnce()
|
||||
expect(exits).toContainEqual({ id: existing.id, code: -1 })
|
||||
expect(exits).toEqual([])
|
||||
expect(replacement.id).toBeDefined()
|
||||
|
||||
respawnAdapter.dispose()
|
||||
|
||||
@@ -670,6 +670,17 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return
|
||||
}
|
||||
|
||||
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
|
||||
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
|
||||
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
|
||||
console.warn(
|
||||
daemonLiveSessionCount === null
|
||||
? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified'
|
||||
: `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: replacing the daemon kills its sessions without daemon-side exit
|
||||
// fanout. Emit exits first so renderer panes do not write to dead PTYs.
|
||||
this.fanoutSyntheticExits(-1)
|
||||
@@ -683,6 +694,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
await this.respawnPromise
|
||||
}
|
||||
|
||||
private async getDaemonLiveSessionCount(): Promise<number | null> {
|
||||
try {
|
||||
await this.client.ensureConnected()
|
||||
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
|
||||
return result.sessions.filter((session) => session.isAlive).length
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async doRespawn(message = '[daemon] Daemon died — respawning'): Promise<void> {
|
||||
console.warn(message)
|
||||
this.removeEventListener?.()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// ─── Protocol Version ────────────────────────────────────────────────
|
||||
// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or
|
||||
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
|
||||
// Why: bumped from 8 -> 9 so new app launches do not reuse daemon processes
|
||||
// that cannot report their own macOS system resolver health.
|
||||
export const PROTOCOL_VERSION = 9
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
// Why: bumped from 9 -> 10 so affected v9 daemons are preserved as legacy
|
||||
// instead of being resolver-health replaced while they own live PTY sessions.
|
||||
export const PROTOCOL_VERSION = 10
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const
|
||||
|
||||
// ─── Session State Machine ──────────────────────────────────────────
|
||||
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type * as osModule from 'os'
|
||||
|
||||
const { getPathMock, homedirMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>(),
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof osModule>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock
|
||||
}
|
||||
})
|
||||
|
||||
import { GeminiHookService } from './hook-service'
|
||||
|
||||
describe('GeminiHookService', () => {
|
||||
let homeDir: string
|
||||
let userDataDir: string
|
||||
|
||||
beforeAll(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-gemini-home-'))
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-gemini-userdata-'))
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
getPathMock.mockImplementation((name: string) => {
|
||||
if (name === 'userData') {
|
||||
return userDataDir
|
||||
}
|
||||
throw new Error(`unexpected getPath(${name})`)
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes stale PreToolUse hooks when reinstalling managed Gemini hooks', () => {
|
||||
const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh'
|
||||
const staleManagedHookPath =
|
||||
process.platform === 'win32'
|
||||
? `C:\\Users\\ramzi\\.orca\\agent-hooks\\${managedHookFileName}`
|
||||
: `/Users/ramzi/.orca/agent-hooks/${managedHookFileName}`
|
||||
const staleManagedCommand =
|
||||
process.platform === 'win32'
|
||||
? staleManagedHookPath
|
||||
: `if [ -x '${staleManagedHookPath}' ]; then /bin/sh '${staleManagedHookPath}'; fi`
|
||||
const managedHookPath = join(homeDir, '.orca', 'agent-hooks', managedHookFileName)
|
||||
const configDir = join(homeDir, '.gemini')
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(configDir, 'settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'echo user-before-agent' }]
|
||||
}
|
||||
],
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: staleManagedCommand
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const service = new GeminiHookService()
|
||||
const status = service.install()
|
||||
const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8'))
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(Object.keys(config.hooks).sort()).toEqual([
|
||||
'AfterAgent',
|
||||
'AfterTool',
|
||||
'BeforeAgent',
|
||||
'BeforeTool'
|
||||
])
|
||||
expect(config.hooks.PreToolUse).toBeUndefined()
|
||||
expect(config.hooks.BeforeAgent).toHaveLength(2)
|
||||
expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent')
|
||||
expect(config.hooks.BeforeAgent[1].hooks[0].command).toContain(managedHookPath)
|
||||
expect(config.hooks.AfterAgent[0].hooks[0].command).toContain(managedHookPath)
|
||||
expect(config.hooks.AfterTool[0].hooks[0].command).toContain(managedHookPath)
|
||||
expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookPath)
|
||||
})
|
||||
|
||||
it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => {
|
||||
const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh'
|
||||
const staleManagedHookPath =
|
||||
process.platform === 'win32'
|
||||
? `C:\\Users\\ramzi\\.orca\\agent-hooks\\${managedHookFileName}`
|
||||
: `/Users/ramzi/.orca/agent-hooks/${managedHookFileName}`
|
||||
const staleManagedCommand =
|
||||
process.platform === 'win32'
|
||||
? staleManagedHookPath
|
||||
: `if [ -x '${staleManagedHookPath}' ]; then /bin/sh '${staleManagedHookPath}'; fi`
|
||||
const configDir = join(homeDir, '.gemini')
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(configDir, 'settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: staleManagedCommand }]
|
||||
},
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'echo user-authored' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const status = new GeminiHookService().install()
|
||||
const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8'))
|
||||
const preToolCommands = config.hooks.PreToolUse.flatMap(
|
||||
(definition: { hooks?: { command: string }[] }) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(preToolCommands).toEqual(['echo user-authored'])
|
||||
expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookFileName)
|
||||
})
|
||||
})
|
||||
@@ -25,12 +25,10 @@ import {
|
||||
// (approvals flow through inline UI), so Orca cannot surface a waiting state
|
||||
// for Gemini — that is an upstream limitation, not an Orca bug.
|
||||
//
|
||||
// PreToolUse surfaces the current tool name + input preview (e.g.
|
||||
// `read_file: src/foo.ts`) so long-running tool calls aren't a silent gap
|
||||
// between BeforeAgent and AfterAgent. PostToolUse is intentionally omitted —
|
||||
// AfterTool already signals "back to working" and the tool name from
|
||||
// PreToolUse is what we show; PostToolUse would be a redundant fire.
|
||||
const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse'] as const
|
||||
// Gemini's native pre-tool event is BeforeTool, not Claude/Codex's PreToolUse.
|
||||
// Keep installing the pre-tool status hook, but sweep stale PreToolUse entries
|
||||
// below so current Gemini CLI no longer warns about an invalid event bucket.
|
||||
const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool'] as const
|
||||
|
||||
function getConfigPath(): string {
|
||||
return join(homedir(), '.gemini', 'settings.json')
|
||||
@@ -175,6 +173,27 @@ export class GeminiHookService {
|
||||
// accumulate duplicate hook entries pointing at defunct scripts.
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
|
||||
const managedEvents = new Set<string>(GEMINI_EVENTS)
|
||||
|
||||
// Why: when Orca stops subscribing to an event, install() must sweep the
|
||||
// old managed entry out of any leftover event bucket. Otherwise a stale
|
||||
// hook such as PreToolUse survives forever in ~/.gemini/settings.json and
|
||||
// continues firing even though the current build no longer wants it.
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (managedEvents.has(eventName)) {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
for (const eventName of GEMINI_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
@@ -213,6 +232,24 @@ export class GeminiHookService {
|
||||
const command = wrapPosixHookCommand(remoteScriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh')
|
||||
const managedEvents = new Set<string>(GEMINI_EVENTS)
|
||||
|
||||
// Why: remote installs must sweep legacy managed event buckets too.
|
||||
// Otherwise stale PreToolUse entries keep warning in SSH Gemini sessions.
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (managedEvents.has(eventName)) {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
for (const eventName of GEMINI_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { deriveValidatedClonePath, getClonePathComparisonKey } from './repo-clone-path'
|
||||
|
||||
describe('repo clone path helpers', () => {
|
||||
it('allows safe repository names that start with two dots', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-clone-path-'))
|
||||
try {
|
||||
expect(
|
||||
deriveValidatedClonePath({
|
||||
url: 'https://example.com/..repo.git',
|
||||
destination
|
||||
})
|
||||
).toBe(join(destination, '..repo'))
|
||||
} finally {
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects Windows-looking destinations on non-Windows hosts', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
expect(() =>
|
||||
deriveValidatedClonePath({
|
||||
url: 'https://example.com/orca.git',
|
||||
destination: 'C:\\Users\\me\\src'
|
||||
})
|
||||
).toThrow('Clone destination must be an absolute path')
|
||||
expect(() =>
|
||||
deriveValidatedClonePath({
|
||||
url: 'https://example.com/orca.git',
|
||||
destination: '\\\\server\\share'
|
||||
})
|
||||
).toThrow('Clone destination must be an absolute path')
|
||||
expect(() =>
|
||||
deriveValidatedClonePath({
|
||||
url: 'https://example.com/orca.git',
|
||||
destination: '//server/share'
|
||||
})
|
||||
).toThrow('Clone destination must be an absolute path')
|
||||
expect(() =>
|
||||
deriveValidatedClonePath({
|
||||
url: 'https://example.com/orca.git',
|
||||
destination: '//wsl.localhost/Ubuntu/home/me'
|
||||
})
|
||||
).toThrow('Clone destination must be an absolute path')
|
||||
})
|
||||
|
||||
it('canonicalizes WSL UNC server aliases without folding Linux path casing', () => {
|
||||
expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo')).toBe(
|
||||
getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\User\\repo')
|
||||
)
|
||||
expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo\\')).toBe(
|
||||
getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\User\\repo')
|
||||
)
|
||||
expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo')).not.toBe(
|
||||
getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\user\\repo')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { isAbsolute, join, posix, relative, resolve, sep, win32 } from 'path'
|
||||
import type { Stats } from 'fs'
|
||||
import { lstat, mkdir, rm } from 'fs/promises'
|
||||
import {
|
||||
isWindowsAbsolutePathLike,
|
||||
normalizeRuntimePathForComparison,
|
||||
normalizeRuntimePathSeparators
|
||||
} from '../../shared/cross-platform-path'
|
||||
|
||||
export type ClaimedCloneTarget = {
|
||||
canCleanup: boolean
|
||||
ownedDirectoryIdentity: CloneDirectoryIdentity | null
|
||||
}
|
||||
|
||||
type CloneDirectoryIdentity = Pick<Stats, 'dev' | 'ino' | 'birthtimeMs'>
|
||||
|
||||
export function deriveValidatedClonePath(args: { url: string; destination: string }): string {
|
||||
if (
|
||||
!args.destination ||
|
||||
!isAbsolute(args.destination) ||
|
||||
(process.platform !== 'win32' && isWindowsAbsolutePathLike(args.destination))
|
||||
) {
|
||||
throw new Error('Clone destination must be an absolute path')
|
||||
}
|
||||
|
||||
// Why: direct callers can supply URLs whose default git clone folder would
|
||||
// be "." or ".."; rejecting them prevents parent/destination deletion.
|
||||
const source = args.url.replace(/\.git\/?$/, '')
|
||||
const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\')
|
||||
const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source)
|
||||
if (!repoName || repoName === '.' || repoName === '..') {
|
||||
throw new Error('Invalid repository name derived from URL')
|
||||
}
|
||||
if (repoName.includes('/') || repoName.includes('\\')) {
|
||||
throw new Error('Invalid repository name derived from URL')
|
||||
}
|
||||
|
||||
const clonePath = join(args.destination, repoName)
|
||||
const resolvedDestination = resolve(args.destination)
|
||||
const resolvedClonePath = resolve(clonePath)
|
||||
const pathFromDestination = relative(resolvedDestination, resolvedClonePath)
|
||||
if (
|
||||
pathFromDestination === '' ||
|
||||
pathFromDestination === '..' ||
|
||||
pathFromDestination.startsWith(`..${sep}`) ||
|
||||
isAbsolute(pathFromDestination)
|
||||
) {
|
||||
throw new Error('Clone path must be inside the destination directory')
|
||||
}
|
||||
|
||||
return clonePath
|
||||
}
|
||||
|
||||
export function getClonePathComparisonKey(clonePath: string): string {
|
||||
const resolvedClonePath = isWindowsAbsolutePathLike(clonePath) ? clonePath : resolve(clonePath)
|
||||
const normalized = normalizeRuntimePathSeparators(resolvedClonePath)
|
||||
const wslUncMatch = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i)
|
||||
if (wslUncMatch) {
|
||||
// Why: WSL UNC paths cross into a case-sensitive Linux filesystem, so only
|
||||
// the Windows UNC server alias and distro segment should be case-folded.
|
||||
const linuxPath = (wslUncMatch[2] ?? '').replace(/\/+$/, '')
|
||||
return `//wsl/${wslUncMatch[1].toLowerCase()}${linuxPath}`
|
||||
}
|
||||
return normalizeRuntimePathForComparison(resolvedClonePath)
|
||||
}
|
||||
|
||||
export async function claimCloneTarget(clonePath: string): Promise<ClaimedCloneTarget> {
|
||||
try {
|
||||
await mkdir(clonePath, { recursive: false })
|
||||
return {
|
||||
canCleanup: true,
|
||||
ownedDirectoryIdentity: cloneDirectoryIdentity(await lstat(clonePath))
|
||||
}
|
||||
} catch (error) {
|
||||
if (isErrnoCode(error, 'EEXIST')) {
|
||||
return { canCleanup: false, ownedDirectoryIdentity: null }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupClaimedCloneTarget(
|
||||
clonePath: string,
|
||||
claimedTarget: ClaimedCloneTarget
|
||||
): Promise<void> {
|
||||
if (!claimedTarget.canCleanup || !claimedTarget.ownedDirectoryIdentity) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const currentStats = await lstat(clonePath)
|
||||
if (!currentStats.isDirectory()) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!isSameCloneDirectoryIdentity(
|
||||
claimedTarget.ownedDirectoryIdentity,
|
||||
cloneDirectoryIdentity(currentStats)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await rm(clonePath, { recursive: true, force: true }).catch(() => {
|
||||
// Best-effort cleanup - do not mask the original clone failure.
|
||||
})
|
||||
}
|
||||
|
||||
function cloneDirectoryIdentity(stats: Stats): CloneDirectoryIdentity {
|
||||
// Why: fast remove/recreate cycles can reuse an inode; birthtime keeps us
|
||||
// from treating a replacement directory as the clone target we created.
|
||||
return { dev: stats.dev, ino: stats.ino, birthtimeMs: stats.birthtimeMs }
|
||||
}
|
||||
|
||||
function isSameCloneDirectoryIdentity(
|
||||
a: CloneDirectoryIdentity,
|
||||
b: CloneDirectoryIdentity
|
||||
): boolean {
|
||||
return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs
|
||||
}
|
||||
|
||||
function isErrnoCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile, access, readFile } from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { bulkDiscardChanges, discardChanges } from './status'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
async function createRepoWithOutsideDirectory(): Promise<{
|
||||
repo: string
|
||||
outsideDir: string
|
||||
outsideFile: string
|
||||
}> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-discard-symlink-'))
|
||||
tempRoots.push(root)
|
||||
const repo = path.join(root, 'repo')
|
||||
const outsideDir = path.join(root, 'outside')
|
||||
const outsideFile = path.join(outsideDir, 'keep.txt')
|
||||
|
||||
await mkdir(repo)
|
||||
await mkdir(outsideDir)
|
||||
execFileSync('git', ['init', '-q'], { cwd: repo })
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo })
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo })
|
||||
await writeFile(path.join(repo, '.gitkeep'), '')
|
||||
execFileSync('git', ['add', '.gitkeep'], { cwd: repo })
|
||||
execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repo })
|
||||
await writeFile(outsideFile, 'outside')
|
||||
|
||||
return { repo, outsideDir, outsideFile }
|
||||
}
|
||||
|
||||
async function createDirectoryLink(target: string, linkPath: string): Promise<void> {
|
||||
await symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('discardChanges symlink safety', () => {
|
||||
it('rejects an untracked child path through a symlinked parent', async () => {
|
||||
const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory()
|
||||
await createDirectoryLink(outsideDir, path.join(repo, 'link'))
|
||||
|
||||
await expect(discardChanges(repo, 'link/keep.txt')).rejects.toThrow(
|
||||
'resolves outside the worktree'
|
||||
)
|
||||
await expect(access(outsideFile)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => {
|
||||
const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory()
|
||||
const untrackedFile = path.join(repo, 'new.txt')
|
||||
const trackedFile = path.join(repo, '.gitkeep')
|
||||
await writeFile(trackedFile, 'modified')
|
||||
await writeFile(untrackedFile, 'untracked')
|
||||
await createDirectoryLink(outsideDir, path.join(repo, 'link'))
|
||||
|
||||
await expect(
|
||||
bulkDiscardChanges(repo, ['.gitkeep', 'new.txt', 'link/keep.txt'])
|
||||
).rejects.toThrow('resolves outside the worktree')
|
||||
await expect(access(outsideFile)).resolves.toBeUndefined()
|
||||
await expect(access(untrackedFile)).resolves.toBeUndefined()
|
||||
await expect(readFile(trackedFile, 'utf8')).resolves.toBe('modified')
|
||||
})
|
||||
|
||||
it('removes an untracked symlink leaf without deleting its target', async () => {
|
||||
const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory()
|
||||
const linkPath = path.join(repo, 'outside-link')
|
||||
await createDirectoryLink(outsideDir, linkPath)
|
||||
|
||||
await discardChanges(repo, 'outside-link')
|
||||
|
||||
await expect(access(linkPath)).rejects.toThrow()
|
||||
await expect(access(outsideFile)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes ignored paths selected for discard', async () => {
|
||||
const { repo } = await createRepoWithOutsideDirectory()
|
||||
await writeFile(path.join(repo, '.gitignore'), 'ignored/\n')
|
||||
execFileSync('git', ['add', '.gitignore'], { cwd: repo })
|
||||
execFileSync('git', ['commit', '-q', '-m', 'ignore ignored dir'], { cwd: repo })
|
||||
const ignoredFile = path.join(repo, 'ignored', 'file.txt')
|
||||
await mkdir(path.dirname(ignoredFile))
|
||||
await writeFile(ignoredFile, 'ignored')
|
||||
|
||||
await discardChanges(repo, 'ignored')
|
||||
|
||||
await expect(access(path.join(repo, 'ignored'))).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('removes untracked nested git repos selected for discard', async () => {
|
||||
const { repo } = await createRepoWithOutsideDirectory()
|
||||
const nestedRepo = path.join(repo, 'nested')
|
||||
await mkdir(nestedRepo)
|
||||
execFileSync('git', ['init', '-q'], { cwd: nestedRepo })
|
||||
await writeFile(path.join(nestedRepo, 'file.txt'), 'nested')
|
||||
|
||||
await discardChanges(repo, 'nested')
|
||||
|
||||
await expect(access(nestedRepo)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
+28
-15
@@ -6,6 +6,7 @@ const {
|
||||
gitExecFileAsyncMock,
|
||||
gitExecFileAsyncBufferMock,
|
||||
lstatMock,
|
||||
realpathMock,
|
||||
readFileMock,
|
||||
rmMock,
|
||||
existsSyncMock
|
||||
@@ -13,6 +14,7 @@ const {
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncBufferMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
rmMock: vi.fn(),
|
||||
existsSyncMock: vi.fn()
|
||||
@@ -29,6 +31,7 @@ vi.mock('./runner', () => ({
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
realpath: realpathMock,
|
||||
readFile: readFileMock,
|
||||
rm: rmMock
|
||||
}))
|
||||
@@ -55,8 +58,12 @@ describe('discardChanges', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncBufferMock.mockReset()
|
||||
lstatMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
readFileMock.mockReset()
|
||||
rmMock.mockReset()
|
||||
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath))
|
||||
})
|
||||
|
||||
it('restores tracked files from HEAD', async () => {
|
||||
@@ -87,13 +94,15 @@ describe('discardChanges', () => {
|
||||
|
||||
await discardChanges('/repo', 'src/new-file.ts')
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
// Why: discardChanges uses path.resolve(worktreePath, filePath) to build
|
||||
// the absolute rm target, which on Windows prepends a drive letter.
|
||||
expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), {
|
||||
force: true,
|
||||
recursive: true
|
||||
})
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['clean', '-ffdx', '--', 'src/new-file.ts'],
|
||||
{
|
||||
cwd: '/repo'
|
||||
}
|
||||
)
|
||||
expect(rmMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects paths that traverse outside the worktree', async () => {
|
||||
@@ -113,7 +122,11 @@ describe('discardChanges', () => {
|
||||
describe('bulk git helpers', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
lstatMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
rmMock.mockReset()
|
||||
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath))
|
||||
})
|
||||
|
||||
it('chunks bulk stage requests to avoid oversized argv payloads', async () => {
|
||||
@@ -178,14 +191,14 @@ describe('bulk git helpers', () => {
|
||||
cwd: '/repo'
|
||||
}
|
||||
)
|
||||
expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), {
|
||||
force: true,
|
||||
recursive: true
|
||||
})
|
||||
expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'scratch'), {
|
||||
force: true,
|
||||
recursive: true
|
||||
})
|
||||
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['clean', '-ffdx', '--', 'src/new-file.ts', 'scratch'],
|
||||
{
|
||||
cwd: '/repo'
|
||||
}
|
||||
)
|
||||
expect(rmMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects bulk discard paths that traverse outside the worktree', async () => {
|
||||
|
||||
+40
-18
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { existsSync } from 'fs'
|
||||
import { readFile, rm } from 'fs/promises'
|
||||
import { readFile } from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type {
|
||||
GitBranchChangeEntry,
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
type GitLineStats
|
||||
} from '../../shared/git-uncommitted-line-stats'
|
||||
import { gitExecFileAsync, gitExecFileAsyncBuffer, gitOptionalLocksDisabledEnv } from './runner'
|
||||
import {
|
||||
removeSafeUntrackedDiscardTarget,
|
||||
removeSafeUntrackedDiscardTargets
|
||||
} from '../../shared/git-discard-path-safety'
|
||||
|
||||
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
|
||||
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
|
||||
@@ -1105,11 +1109,16 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr
|
||||
// File is not tracked by git
|
||||
}
|
||||
|
||||
await (tracked
|
||||
? gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], {
|
||||
cwd: worktreePath
|
||||
})
|
||||
: rm(resolvedTarget, { force: true, recursive: true }))
|
||||
if (tracked) {
|
||||
await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], {
|
||||
cwd: worktreePath
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) =>
|
||||
cleanUntrackedPaths(worktreePath, [targetPath])
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeGitPathForCompare(filePath: string): string {
|
||||
@@ -1139,6 +1148,19 @@ async function listTrackedPathSpecs(
|
||||
return trackedPaths
|
||||
}
|
||||
|
||||
async function cleanUntrackedPaths(
|
||||
worktreePath: string,
|
||||
filePaths: readonly string[]
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
|
||||
const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
|
||||
if (chunk.length > 0) {
|
||||
// Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents.
|
||||
await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk], { cwd: worktreePath })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard working tree changes for many paths in a small number of subprocesses.
|
||||
*/
|
||||
@@ -1160,18 +1182,18 @@ export async function bulkDiscardChanges(worktreePath: string, filePaths: string
|
||||
const untrackedPaths = filePaths.filter(
|
||||
(filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs)
|
||||
)
|
||||
|
||||
for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) {
|
||||
const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE)
|
||||
await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', ...chunk], {
|
||||
cwd: worktreePath
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
untrackedPaths.map((filePath) =>
|
||||
rm(path.resolve(worktreePath, filePath), { force: true, recursive: true })
|
||||
)
|
||||
await removeSafeUntrackedDiscardTargets(
|
||||
worktreePath,
|
||||
untrackedPaths,
|
||||
(targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths),
|
||||
async () => {
|
||||
for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) {
|
||||
const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE)
|
||||
await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', ...chunk], {
|
||||
cwd: worktreePath
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handlers, appExitMock, appRelaunchMock } = vi.hoisted(() => ({
|
||||
const { handlers, appExitMock, appQuitMock, appRelaunchMock } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
appExitMock: vi.fn(),
|
||||
appQuitMock: vi.fn(),
|
||||
appRelaunchMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -11,6 +12,7 @@ vi.mock('electron', () => ({
|
||||
exit: appExitMock,
|
||||
getAppPath: vi.fn(() => '/test/app'),
|
||||
isPackaged: false,
|
||||
quit: appQuitMock,
|
||||
relaunch: appRelaunchMock
|
||||
},
|
||||
BrowserWindow: {
|
||||
@@ -37,6 +39,7 @@ describe('registerAppHandlers', () => {
|
||||
vi.useFakeTimers()
|
||||
handlers.clear()
|
||||
appExitMock.mockReset()
|
||||
appQuitMock.mockReset()
|
||||
appRelaunchMock.mockReset()
|
||||
})
|
||||
|
||||
@@ -59,4 +62,22 @@ describe('registerAppHandlers', () => {
|
||||
expect(appRelaunchMock).toHaveBeenCalledTimes(1)
|
||||
expect(appExitMock).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('marks restart as expected shutdown before quitting through the normal pipeline', () => {
|
||||
const onBeforeRelaunch = vi.fn()
|
||||
registerAppHandlers({} as never, { onBeforeRelaunch })
|
||||
|
||||
handlers.get('app:restart')?.(null)
|
||||
|
||||
expect(onBeforeRelaunch).toHaveBeenCalledTimes(1)
|
||||
expect(appRelaunchMock).not.toHaveBeenCalled()
|
||||
expect(appQuitMock).not.toHaveBeenCalled()
|
||||
expect(appExitMock).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(150)
|
||||
|
||||
expect(appRelaunchMock).toHaveBeenCalledTimes(1)
|
||||
expect(appQuitMock).toHaveBeenCalledTimes(1)
|
||||
expect(appExitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,6 +178,17 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
|
||||
}, 150)
|
||||
})
|
||||
|
||||
ipcMain.handle('app:restart', () => {
|
||||
// Why: the hidden admin restart should mirror the update relaunch path:
|
||||
// schedule a new Orca process, then use the normal quit pipeline so daemon
|
||||
// checkpoints, runtime metadata, and telemetry flush before exit.
|
||||
options.onBeforeRelaunch?.()
|
||||
setTimeout(() => {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
}, 150)
|
||||
})
|
||||
|
||||
ipcMain.handle('app:setUnreadDockBadgeCount', (_event, count: number) => {
|
||||
setUnreadDockBadgeCount(Number.isFinite(count) ? count : 0)
|
||||
})
|
||||
|
||||
@@ -273,6 +273,43 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
|
||||
})
|
||||
|
||||
it('routes rename through the SSH no-clobber filesystem provider when a connection is present', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
|
||||
try {
|
||||
await handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(renameNoClobber).toHaveBeenCalledWith('/home/me/repo/old.ts', '/home/me/repo/new.ts')
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates SSH no-clobber rename failures', async () => {
|
||||
const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists'))
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('destination exists')
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── fs:copy ────────────────────────────────────────────────────
|
||||
|
||||
it('copies a file without overwriting an existing destination', async () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { Store } from '../persistence'
|
||||
import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { importExternalPathsSsh } from './filesystem-import-ssh'
|
||||
import { assertFileExplorerRenameDestinationAvailable } from '../file-explorer-rename-collision'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
|
||||
|
||||
/**
|
||||
* Re-throw filesystem errors with user-friendly messages.
|
||||
@@ -109,7 +109,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.rename(args.oldPath, args.newPath)
|
||||
return provider.renameNoClobber(args.oldPath, args.newPath)
|
||||
}
|
||||
// Why: rename() operates on directory entries, not file contents. If
|
||||
// oldPath is a symlink, we must rename the link itself rather than
|
||||
@@ -119,7 +119,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
// accidentally write into a symlinked destination name.
|
||||
const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true })
|
||||
const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true })
|
||||
await assertFileExplorerRenameDestinationAvailable(oldPath, newPath)
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -652,7 +652,7 @@ describe('registerNotificationHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses rich formatter output for mobile notifications before desktop guards', () => {
|
||||
it('uses rich formatter output for mobile notifications before the native support guard', () => {
|
||||
notificationIsSupportedMock.mockReturnValue(false)
|
||||
const dispatchMobileNotification = vi.fn()
|
||||
registerNotificationHandlers(
|
||||
@@ -694,6 +694,145 @@ describe('registerNotificationHandlers', () => {
|
||||
expect(notificationCtorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch mobile notifications when notifications are disabled', () => {
|
||||
const dispatchMobileNotification = vi.fn()
|
||||
registerNotificationHandlers(
|
||||
{
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: false,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false
|
||||
}
|
||||
})
|
||||
} as never,
|
||||
{ dispatchMobileNotification } as never
|
||||
)
|
||||
|
||||
const handler = getDispatchHandler()
|
||||
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
|
||||
delivered: false,
|
||||
reason: 'disabled'
|
||||
})
|
||||
|
||||
expect(dispatchMobileNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch mobile notifications when the source is disabled', () => {
|
||||
const dispatchMobileNotification = vi.fn()
|
||||
registerNotificationHandlers(
|
||||
{
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: false,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false
|
||||
}
|
||||
})
|
||||
} as never,
|
||||
{ dispatchMobileNotification } as never
|
||||
)
|
||||
|
||||
const handler = getDispatchHandler()
|
||||
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
|
||||
delivered: false,
|
||||
reason: 'source-disabled'
|
||||
})
|
||||
|
||||
expect(dispatchMobileNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch mobile notifications for focused active-worktree notifications', () => {
|
||||
getAllWindowsMock.mockReturnValue([
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => true
|
||||
} as never
|
||||
])
|
||||
const dispatchMobileNotification = vi.fn()
|
||||
registerNotificationHandlers(
|
||||
{
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: true
|
||||
}
|
||||
})
|
||||
} as never,
|
||||
{ dispatchMobileNotification } as never
|
||||
)
|
||||
|
||||
const handler = getDispatchHandler()
|
||||
expect(
|
||||
handler(
|
||||
{},
|
||||
{ source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true }
|
||||
)
|
||||
).toEqual({
|
||||
delivered: false,
|
||||
reason: 'suppressed-focus'
|
||||
})
|
||||
|
||||
expect(dispatchMobileNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch mobile notifications for cooldown-suppressed bursts', () => {
|
||||
const dispatchMobileNotification = vi.fn()
|
||||
registerNotificationHandlers(
|
||||
{
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false
|
||||
}
|
||||
})
|
||||
} as never,
|
||||
{ dispatchMobileNotification } as never
|
||||
)
|
||||
|
||||
const handler = getDispatchHandler()
|
||||
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
|
||||
delivered: true
|
||||
})
|
||||
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
|
||||
delivered: false,
|
||||
reason: 'cooldown'
|
||||
})
|
||||
|
||||
expect(dispatchMobileNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchMobileNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not forward explicit desktop test notifications to mobile clients', () => {
|
||||
const dispatchMobileNotification = vi.fn()
|
||||
registerNotificationHandlers(
|
||||
{
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false
|
||||
}
|
||||
})
|
||||
} as never,
|
||||
{ dispatchMobileNotification } as never
|
||||
)
|
||||
|
||||
const handler = getDispatchHandler()
|
||||
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
|
||||
|
||||
expect(dispatchMobileNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silences the native notification when a custom sound is configured', () => {
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
|
||||
@@ -157,25 +157,6 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
||||
_event,
|
||||
args: NotificationDispatchRequest
|
||||
): NotificationDispatchResult | Promise<NotificationDispatchResult> => {
|
||||
// Why: mobile push is independent of desktop notification guards.
|
||||
// The user's phone should receive the notification even when the desktop
|
||||
// window is focused (suppressWhenFocused), Electron notifications aren't
|
||||
// supported, or the desktop is in cooldown. The mobile client decides
|
||||
// independently whether to show based on its own app state.
|
||||
if (runtime) {
|
||||
const opts = buildNotificationOptions(args)
|
||||
runtime.dispatchMobileNotification({
|
||||
source: args.source,
|
||||
title: opts.title,
|
||||
body: opts.body,
|
||||
worktreeId: args.worktreeId
|
||||
})
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return { delivered: false, reason: 'not-supported' }
|
||||
}
|
||||
|
||||
const settings = store.getSettings().notifications
|
||||
if (!settings.enabled) {
|
||||
return { delivered: false, reason: 'disabled' }
|
||||
@@ -223,6 +204,23 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
||||
}
|
||||
|
||||
const notificationOptions = buildNotificationOptions(args)
|
||||
|
||||
// Why: paired mobile clients should follow the same user-facing
|
||||
// notification gates as desktop delivery, while still working on hosts
|
||||
// where Electron native notifications are unavailable.
|
||||
if (runtime && args.source !== 'test') {
|
||||
runtime.dispatchMobileNotification({
|
||||
source: args.source,
|
||||
title: notificationOptions.title,
|
||||
body: notificationOptions.body,
|
||||
worktreeId: args.worktreeId
|
||||
})
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return { delivered: false, reason: 'not-supported' }
|
||||
}
|
||||
|
||||
if (getEffectiveNotificationSoundId(settings) !== 'system') {
|
||||
notificationOptions.silent = true
|
||||
} else if (process.platform === 'darwin') {
|
||||
|
||||
@@ -3,8 +3,12 @@ tests (addRemote, getBaseRefDefault envelope, searchBaseRefs SSH relay) so
|
||||
fixture setup and mock plumbing can be shared. Splitting by line count would
|
||||
duplicate the hoisted mocks and the `../git/repo` partial-real/partial-stub
|
||||
setup. */
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type * as RepoModule from '../git/repo'
|
||||
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
|
||||
|
||||
@@ -564,12 +568,46 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
})
|
||||
|
||||
type MockCloneProcess = EventEmitter & {
|
||||
stderr: EventEmitter
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createMockCloneProcess(): MockCloneProcess {
|
||||
const proc = new EventEmitter() as MockCloneProcess
|
||||
proc.stderr = new EventEmitter()
|
||||
proc.kill = vi.fn().mockReturnValue(true)
|
||||
return proc
|
||||
}
|
||||
|
||||
async function waitForAssertion(assertion: () => void): Promise<void> {
|
||||
const deadline = Date.now() + 2_000
|
||||
let lastError: unknown
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
assertion()
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
describe('repos:add + repos:clone', () => {
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
|
||||
const mockWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
}
|
||||
const tempRoots: string[] = []
|
||||
|
||||
const createTempRoot = async (): Promise<string> => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-repos-clone-'))
|
||||
tempRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
@@ -583,8 +621,7 @@ describe('repos:add + repos:clone', () => {
|
||||
mockWindow.webContents.send.mockReset()
|
||||
gitSpawnMock.mockReset()
|
||||
gitSpawnMock.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
const proc = createMockCloneProcess()
|
||||
queueMicrotask(() => proc.emit('close', 0, null))
|
||||
return proc
|
||||
})
|
||||
@@ -592,6 +629,10 @@ describe('repos:add + repos:clone', () => {
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('defaults repos:add badgeColor to DEFAULT_REPO_BADGE_COLOR for folder repos', async () => {
|
||||
const result = await handlers.get('repos:add')!(null, { path: '/tmp/from-add', kind: 'folder' })
|
||||
|
||||
@@ -638,14 +679,16 @@ describe('repos:add + repos:clone', () => {
|
||||
})
|
||||
|
||||
it('defaults repos:clone badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
|
||||
const destination = await createTempRoot()
|
||||
|
||||
const result = await handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination: '/tmp'
|
||||
destination
|
||||
})
|
||||
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/tmp/orca',
|
||||
path: join(destination, 'orca'),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
@@ -657,9 +700,11 @@ describe('repos:add + repos:clone', () => {
|
||||
})
|
||||
|
||||
it('preserves existing badgeColor when repos:clone upgrades folder->git after dedupe', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const existing = {
|
||||
id: 'folder-repo',
|
||||
path: '/tmp/orca',
|
||||
path: clonePath,
|
||||
displayName: 'orca',
|
||||
badgeColor: '#8b5cf6',
|
||||
addedAt: 1,
|
||||
@@ -671,7 +716,7 @@ describe('repos:add + repos:clone', () => {
|
||||
|
||||
const result = await handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination: '/tmp'
|
||||
destination
|
||||
})
|
||||
|
||||
expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { kind: 'git' })
|
||||
@@ -679,6 +724,330 @@ describe('repos:add + repos:clone', () => {
|
||||
expect(result).toHaveProperty('badgeColor', '#8b5cf6')
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a dot-segment URL before creating the destination or spawning git', async () => {
|
||||
const tempRoot = await createTempRoot()
|
||||
const destination = join(tempRoot, 'destination')
|
||||
|
||||
await expect(
|
||||
handlers.get('repos:clone')!(null, {
|
||||
url: 'file:///tmp/source/.',
|
||||
destination
|
||||
})
|
||||
).rejects.toThrow('Invalid repository name derived from URL')
|
||||
|
||||
expect(existsSync(destination)).toBe(false)
|
||||
expect(gitSpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a parent-segment URL before creating the destination or spawning git', async () => {
|
||||
const tempRoot = await createTempRoot()
|
||||
const destination = join(tempRoot, 'destination')
|
||||
|
||||
await expect(
|
||||
handlers.get('repos:clone')!(null, {
|
||||
url: 'file:///tmp/source/..',
|
||||
destination
|
||||
})
|
||||
).rejects.toThrow('Invalid repository name derived from URL')
|
||||
|
||||
expect(existsSync(destination)).toBe(false)
|
||||
expect(gitSpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a relative destination before creating directories or spawning git', async () => {
|
||||
const destination = `relative-clone-destination-${Date.now()}`
|
||||
|
||||
await expect(
|
||||
handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
).rejects.toThrow('Clone destination must be an absolute path')
|
||||
|
||||
expect(existsSync(destination)).toBe(false)
|
||||
expect(gitSpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects URL-derived names containing Windows separators before spawning git', async () => {
|
||||
const destination = await createTempRoot()
|
||||
|
||||
await expect(
|
||||
handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/team\\orca.git',
|
||||
destination
|
||||
})
|
||||
).rejects.toThrow('Invalid repository name derived from URL')
|
||||
|
||||
expect(gitSpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts Windows local-path clone sources while validating the final segment', async () => {
|
||||
const destination = await createTempRoot()
|
||||
|
||||
const result = await handlers.get('repos:clone')!(null, {
|
||||
url: 'C:\\src\\orca.git',
|
||||
destination
|
||||
})
|
||||
|
||||
expect(gitSpawnMock).toHaveBeenCalledWith(
|
||||
['clone', '--progress', '--', 'C:\\src\\orca.git', join(destination, 'orca')],
|
||||
expect.objectContaining({ cwd: destination })
|
||||
)
|
||||
expect(result).toHaveProperty('path', join(destination, 'orca'))
|
||||
})
|
||||
|
||||
it('treats cloneAbort with no active clone as a no-op', async () => {
|
||||
await expect(handlers.get('repos:cloneAbort')!(null, undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not remove an existing target directory when aborting a pending clone', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
await mkdir(clonePath)
|
||||
await writeFile(join(clonePath, 'user-file.txt'), 'keep me')
|
||||
const proc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(proc)
|
||||
|
||||
const clonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
proc.emit('close', null, 'SIGTERM')
|
||||
await expect(clonePromise).rejects.toThrow('Clone aborted')
|
||||
|
||||
expect(existsSync(clonePath)).toBe(true)
|
||||
expect(existsSync(join(clonePath, 'user-file.txt'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not remove an existing target file when aborting a pending clone', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
await writeFile(clonePath, 'existing file')
|
||||
const proc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(proc)
|
||||
|
||||
const clonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
proc.emit('close', null, 'SIGTERM')
|
||||
await expect(clonePromise).rejects.toThrow('Clone aborted')
|
||||
|
||||
expect(existsSync(clonePath)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes a fresh clone target only after the aborted process closes unsuccessfully', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const proc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(proc)
|
||||
|
||||
const clonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
expect(existsSync(clonePath)).toBe(true)
|
||||
|
||||
proc.emit('close', null, 'SIGTERM')
|
||||
await expect(clonePromise).rejects.toThrow('Clone aborted')
|
||||
expect(existsSync(clonePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('removes an owned fresh clone target when git exits unsuccessfully', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const partialFile = join(clonePath, 'partial.txt')
|
||||
const proc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(proc)
|
||||
|
||||
const clonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
await writeFile(partialFile, 'git wrote this before failing')
|
||||
|
||||
proc.stderr.emit('data', Buffer.from('fatal: repository not found\n'))
|
||||
proc.emit('close', 128, null)
|
||||
await expect(clonePromise).rejects.toThrow('Clone failed: fatal: repository not found')
|
||||
|
||||
expect(existsSync(clonePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('removes an owned fresh clone target when git spawn emits an error', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const partialFile = join(clonePath, 'partial.txt')
|
||||
const proc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(proc)
|
||||
|
||||
const clonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
await writeFile(partialFile, 'git wrote this before spawn failure')
|
||||
|
||||
proc.emit('error', new Error('spawn failed'))
|
||||
await expect(clonePromise).rejects.toThrow('Clone failed: spawn failed')
|
||||
|
||||
expect(existsSync(clonePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a fresh clone target when abort races with a successful close', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const proc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(proc)
|
||||
|
||||
const clonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
proc.emit('close', 0, null)
|
||||
await expect(clonePromise).resolves.toMatchObject({
|
||||
path: clonePath,
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(existsSync(clonePath)).toBe(true)
|
||||
})
|
||||
|
||||
it('dedupes retry when abort races with a successful clone close', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const repos: unknown[] = []
|
||||
mockStore.getRepos.mockImplementation(() => repos)
|
||||
mockStore.addRepo.mockImplementation((repo: unknown) => {
|
||||
repos.push(repo)
|
||||
})
|
||||
const firstProc = createMockCloneProcess()
|
||||
const secondProc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(firstProc).mockReturnValueOnce(secondProc)
|
||||
|
||||
const firstClonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
const secondClonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(gitSpawnMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstProc.emit('close', 0, null)
|
||||
await expect(firstClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' })
|
||||
await expect(secondClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' })
|
||||
|
||||
expect(gitSpawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(secondProc.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes concurrent clones for the same target', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const repos: unknown[] = []
|
||||
mockStore.getRepos.mockImplementation(() => repos)
|
||||
mockStore.addRepo.mockImplementation((repo: unknown) => {
|
||||
repos.push(repo)
|
||||
})
|
||||
const firstProc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(firstProc)
|
||||
|
||||
const firstClonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
const secondClonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
firstProc.emit('close', 0, null)
|
||||
await expect(firstClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' })
|
||||
await expect(secondClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' })
|
||||
|
||||
expect(gitSpawnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits for pending abort cleanup before retrying the same clone target', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const partialFile = join(clonePath, 'partial.txt')
|
||||
const firstProc = createMockCloneProcess()
|
||||
const secondProc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(firstProc).mockReturnValueOnce(secondProc)
|
||||
|
||||
const firstClonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
await writeFile(partialFile, 'first clone wrote this before abort')
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
|
||||
const secondClonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(gitSpawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(existsSync(partialFile)).toBe(true)
|
||||
|
||||
firstProc.emit('close', null, 'SIGTERM')
|
||||
await expect(firstClonePromise).rejects.toThrow('Clone aborted')
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(2))
|
||||
expect(existsSync(partialFile)).toBe(false)
|
||||
|
||||
secondProc.emit('close', 0, null)
|
||||
await expect(secondClonePromise).resolves.toMatchObject({
|
||||
path: clonePath,
|
||||
kind: 'git'
|
||||
})
|
||||
expect(existsSync(clonePath)).toBe(true)
|
||||
})
|
||||
|
||||
it('skips abort cleanup when the claimed target is replaced before close', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
const replacementFile = join(clonePath, 'replacement.txt')
|
||||
const proc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(proc)
|
||||
|
||||
const clonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
await rm(clonePath, { recursive: true, force: true })
|
||||
await mkdir(clonePath)
|
||||
await writeFile(replacementFile, 'new owner')
|
||||
|
||||
proc.emit('close', null, 'SIGTERM')
|
||||
await expect(clonePromise).rejects.toThrow('Clone aborted')
|
||||
|
||||
expect(existsSync(replacementFile)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('repos:getBaseRefDefault envelope', () => {
|
||||
|
||||
+261
-113
@@ -23,8 +23,15 @@ import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
import { access, mkdir, readdir, rm } from 'fs/promises'
|
||||
import { gitExecFileAsync, gitSpawn } from '../git/runner'
|
||||
import { basename, isAbsolute, join, posix } from 'path'
|
||||
import { isAbsolute, join, posix } from 'path'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
cleanupClaimedCloneTarget,
|
||||
claimCloneTarget,
|
||||
deriveValidatedClonePath,
|
||||
getClonePathComparisonKey
|
||||
} from '../git/repo-clone-path'
|
||||
import type { ClaimedCloneTarget } from '../git/repo-clone-path'
|
||||
import { getNextProjectGroupOrder } from '../../shared/project-groups'
|
||||
import { scanNestedRepos } from '../project-groups/nested-repo-discovery'
|
||||
import {
|
||||
@@ -75,11 +82,25 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void {
|
||||
track('repo_added', { method, ...getCohortAtEmit() })
|
||||
}
|
||||
|
||||
type ActiveCloneMetadata = {
|
||||
path: string
|
||||
pathKey: string
|
||||
claimedTarget: ClaimedCloneTarget
|
||||
process: ChildProcess
|
||||
abortRequested: boolean
|
||||
generation: number
|
||||
pendingAbortCleanup: Promise<void> | null
|
||||
resolvePendingAbortCleanup: (() => void) | null
|
||||
}
|
||||
|
||||
// Why: module-scoped so the abort handle survives window re-creation on macOS.
|
||||
// registerRepoHandlers is called again when a new BrowserWindow is created,
|
||||
// and a function-scoped variable would lose the reference to an in-flight clone.
|
||||
let activeCloneProc: ChildProcess | null = null
|
||||
let activeClonePath: string | null = null
|
||||
let activeClone: ActiveCloneMetadata | null = null
|
||||
let nextCloneGeneration = 1
|
||||
const latestCloneGenerationByPath = new Map<string, number>()
|
||||
const pendingAbortCleanupByPath = new Map<string, Promise<void>>()
|
||||
const cloneInFlightByPath = new Map<string, Promise<void>>()
|
||||
|
||||
const ProjectGroupCreateArgs = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -148,6 +169,71 @@ function validateNestedRepoScanRoot(path: string, connectionId?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupOwnedCloneTarget(metadata: ActiveCloneMetadata): Promise<void> {
|
||||
if (!metadata.claimedTarget.canCleanup || !metadata.claimedTarget.ownedDirectoryIdentity) {
|
||||
return
|
||||
}
|
||||
if (latestCloneGenerationByPath.get(metadata.pathKey) !== metadata.generation) {
|
||||
return
|
||||
}
|
||||
// Why: an immediate retry can attach a newer process to the same target
|
||||
// before the aborted process closes; the old close handler must not delete it.
|
||||
if (
|
||||
activeClone &&
|
||||
activeClone.process !== metadata.process &&
|
||||
activeClone.pathKey === metadata.pathKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (latestCloneGenerationByPath.get(metadata.pathKey) !== metadata.generation) {
|
||||
return
|
||||
}
|
||||
await cleanupClaimedCloneTarget(metadata.path, metadata.claimedTarget)
|
||||
}
|
||||
|
||||
function markCloneAbortCleanupPending(metadata: ActiveCloneMetadata): void {
|
||||
if (metadata.resolvePendingAbortCleanup) {
|
||||
return
|
||||
}
|
||||
metadata.pendingAbortCleanup = new Promise<void>((resolve) => {
|
||||
metadata.resolvePendingAbortCleanup = resolve
|
||||
})
|
||||
pendingAbortCleanupByPath.set(metadata.pathKey, metadata.pendingAbortCleanup)
|
||||
}
|
||||
|
||||
function settleCloneAbortCleanup(metadata: ActiveCloneMetadata): void {
|
||||
if (pendingAbortCleanupByPath.get(metadata.pathKey) === metadata.pendingAbortCleanup) {
|
||||
pendingAbortCleanupByPath.delete(metadata.pathKey)
|
||||
}
|
||||
metadata.resolvePendingAbortCleanup?.()
|
||||
metadata.pendingAbortCleanup = null
|
||||
metadata.resolvePendingAbortCleanup = null
|
||||
}
|
||||
|
||||
async function runWithClonePathLock<T>(clonePathKey: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const tail = previous.then(
|
||||
() => current,
|
||||
() => current
|
||||
)
|
||||
cloneInFlightByPath.set(clonePathKey, tail)
|
||||
|
||||
try {
|
||||
await previous
|
||||
return await task()
|
||||
} finally {
|
||||
release()
|
||||
if (cloneInFlightByPath.get(clonePathKey) === tail) {
|
||||
cloneInFlightByPath.delete(clonePathKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeNestedRepoImportError(context: string, error: unknown): string {
|
||||
console.warn(`[project-groups] ${context}`, error)
|
||||
return 'Repository could not be imported'
|
||||
@@ -976,19 +1062,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
})
|
||||
|
||||
ipcMain.handle('repos:cloneAbort', async () => {
|
||||
if (activeCloneProc) {
|
||||
const pathToClean = activeClonePath
|
||||
activeCloneProc.kill()
|
||||
activeCloneProc = null
|
||||
activeClonePath = null
|
||||
// Why: git clone creates the target directory before it finishes.
|
||||
// Without cleanup, retrying the same URL/destination fails with
|
||||
// "destination path already exists and is not an empty directory".
|
||||
if (pathToClean) {
|
||||
await rm(pathToClean, { recursive: true, force: true }).catch(() => {
|
||||
// Best-effort cleanup — don't fail the abort if removal fails
|
||||
})
|
||||
}
|
||||
if (activeClone) {
|
||||
const clone = activeClone
|
||||
clone.abortRequested = true
|
||||
markCloneAbortCleanupPending(clone)
|
||||
clone.process.kill()
|
||||
activeClone = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -999,114 +1078,183 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
// the repo folder name from the URL (e.g. "orca" from .../orca.git).
|
||||
// This matches the default git clone behavior where the last path segment
|
||||
// of the URL becomes the directory name.
|
||||
const repoName = basename(args.url.replace(/\.git\/?$/, ''))
|
||||
if (!repoName) {
|
||||
throw new Error('Could not determine repository name from URL')
|
||||
}
|
||||
// Why: gitSpawn uses args.destination as cwd, so it must exist before
|
||||
// spawn — fresh installs may have a defaulted parent dir that does not
|
||||
// exist yet (e.g. ~/orca). recursive: true is a no-op when present.
|
||||
await mkdir(args.destination, { recursive: true })
|
||||
const clonePath = join(args.destination, repoName)
|
||||
const clonePath = deriveValidatedClonePath(args)
|
||||
const clonePathKey = getClonePathComparisonKey(clonePath)
|
||||
return runWithClonePathLock(clonePathKey, async () => {
|
||||
await pendingAbortCleanupByPath.get(clonePathKey)
|
||||
const existingAfterPendingClone = store
|
||||
.getRepos()
|
||||
.find((r) => getClonePathComparisonKey(r.path) === clonePathKey)
|
||||
if (existingAfterPendingClone && !isFolderRepo(existingAfterPendingClone)) {
|
||||
emitRepoAdded('clone_url', true)
|
||||
return existingAfterPendingClone
|
||||
}
|
||||
// Why: gitSpawn uses args.destination as cwd, so it must exist before
|
||||
// spawn — fresh installs may have a defaulted parent dir that does not
|
||||
// exist yet (e.g. ~/orca). recursive: true is a no-op when present.
|
||||
await mkdir(args.destination, { recursive: true })
|
||||
const claimedTarget = await claimCloneTarget(clonePath)
|
||||
|
||||
// Why: use spawn instead of execFile so there is no maxBuffer limit.
|
||||
// git clone writes progress to stderr which can exceed Node's default
|
||||
// 1 MB buffer on large or submodule-heavy repos. We only keep the tail
|
||||
// of stderr for error reporting and discard stdout entirely.
|
||||
// Why: use --progress to force git to emit progress even when stderr
|
||||
// is not a TTY. Without it, git suppresses progress output when piped.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Why: clone destination may be a WSL path (e.g. user picks a WSL
|
||||
// directory). Use the parent destination as the cwd so the runner
|
||||
// detects WSL and routes through wsl.exe.
|
||||
// Why: use the '--' separator to isolate the URL argument and prevent
|
||||
// malicious URLs from being interpreted as git flags (command injection).
|
||||
const proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], {
|
||||
cwd: args.destination,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
activeCloneProc = proc
|
||||
activeClonePath = clonePath
|
||||
// Why: use spawn instead of execFile so there is no maxBuffer limit.
|
||||
// git clone writes progress to stderr which can exceed Node's default
|
||||
// 1 MB buffer on large or submodule-heavy repos. We only keep the tail
|
||||
// of stderr for error reporting and discard stdout entirely.
|
||||
// Why: use --progress to force git to emit progress even when stderr
|
||||
// is not a TTY. Without it, git suppresses progress output when piped.
|
||||
const cloneMetadataRef: { current: ActiveCloneMetadata | null } = { current: null }
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Why: clone destination may be a WSL path (e.g. user picks a WSL
|
||||
// directory). Use the parent destination as the cwd so the runner
|
||||
// detects WSL and routes through wsl.exe.
|
||||
// Why: use the '--' separator to isolate the URL argument and prevent
|
||||
// malicious URLs from being interpreted as git flags (command injection).
|
||||
let proc: ReturnType<typeof gitSpawn>
|
||||
try {
|
||||
proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], {
|
||||
cwd: args.destination,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
} catch (err) {
|
||||
void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
reject(new Error(`Clone failed: ${message}`))
|
||||
})
|
||||
return
|
||||
}
|
||||
const generation = nextCloneGeneration++
|
||||
latestCloneGenerationByPath.set(clonePathKey, generation)
|
||||
const metadata: ActiveCloneMetadata = {
|
||||
path: clonePath,
|
||||
pathKey: clonePathKey,
|
||||
claimedTarget,
|
||||
process: proc,
|
||||
abortRequested: false,
|
||||
generation,
|
||||
pendingAbortCleanup: null,
|
||||
resolvePendingAbortCleanup: null
|
||||
}
|
||||
cloneMetadataRef.current = metadata
|
||||
activeClone = metadata
|
||||
|
||||
let stderrTail = ''
|
||||
proc.stderr!.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString()
|
||||
stderrTail = (stderrTail + text).slice(-4096)
|
||||
let stderrTail = ''
|
||||
let settled = false
|
||||
proc.stderr!.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString()
|
||||
stderrTail = (stderrTail + text).slice(-4096)
|
||||
|
||||
// Why: git progress lines use \r to overwrite in-place. Split on
|
||||
// both \r and \n to find the latest progress fragment, then extract
|
||||
// the phase name and percentage for the renderer.
|
||||
const lines = text.split(/[\r\n]+/)
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
|
||||
if (match && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('repos:clone-progress', {
|
||||
phase: match[1].trim(),
|
||||
percent: parseInt(match[2], 10)
|
||||
})
|
||||
// Why: git progress lines use \r to overwrite in-place. Split on
|
||||
// both \r and \n to find the latest progress fragment, then extract
|
||||
// the phase name and percentage for the renderer.
|
||||
const lines = text.split(/[\r\n]+/)
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
|
||||
if (match && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('repos:clone-progress', {
|
||||
phase: match[1].trim(),
|
||||
percent: parseInt(match[2], 10)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const finishClone = async (
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
err?: Error
|
||||
) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
// Why: only clear the ref if it still points to this process.
|
||||
// A quick abort-and-retry can reassign activeClone to a new
|
||||
// spawn before this handler fires, and nulling it would make the
|
||||
// new clone unabortable.
|
||||
if (activeClone?.process === proc) {
|
||||
activeClone = null
|
||||
}
|
||||
|
||||
const cloneSucceeded = !err && code === 0 && !signal
|
||||
if (!cloneSucceeded) {
|
||||
// Why: only the process that created this target may remove it,
|
||||
// and only after git reports the clone did not complete.
|
||||
await cleanupOwnedCloneTarget(metadata)
|
||||
}
|
||||
if (metadata.abortRequested && !cloneSucceeded) {
|
||||
settleCloneAbortCleanup(metadata)
|
||||
}
|
||||
if (latestCloneGenerationByPath.get(metadata.pathKey) === metadata.generation) {
|
||||
latestCloneGenerationByPath.delete(metadata.pathKey)
|
||||
}
|
||||
|
||||
if (err) {
|
||||
reject(new Error(`Clone failed: ${err.message}`))
|
||||
} else if (signal === 'SIGTERM') {
|
||||
reject(new Error('Clone aborted'))
|
||||
} else if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error'
|
||||
reject(new Error(`Clone failed: ${lastLine}`))
|
||||
}
|
||||
}
|
||||
|
||||
proc.on('error', (err) => {
|
||||
void finishClone(null, null, err)
|
||||
})
|
||||
|
||||
proc.on('close', (code, signal) => {
|
||||
void finishClone(code, signal)
|
||||
})
|
||||
})
|
||||
|
||||
proc.on('error', (err) => reject(new Error(`Clone failed: ${err.message}`)))
|
||||
|
||||
proc.on('close', (code, signal) => {
|
||||
// Why: only clear the ref if it still points to this process.
|
||||
// A quick abort-and-retry can reassign activeCloneProc to a new
|
||||
// spawn before this handler fires, and nulling it would make the
|
||||
// new clone unabortable.
|
||||
if (activeCloneProc === proc) {
|
||||
activeCloneProc = null
|
||||
activeClonePath = null
|
||||
try {
|
||||
// Why: check after clone (not before) because the path didn't exist
|
||||
// before cloning. But if the user somehow had a folder repo at this path
|
||||
// that git clone succeeded into (empty dir), reuse that entry and upgrade
|
||||
// its kind to 'git' instead of creating a duplicate.
|
||||
const existing = store
|
||||
.getRepos()
|
||||
.find((r) => getClonePathComparisonKey(r.path) === clonePathKey)
|
||||
if (existing) {
|
||||
if (isFolderRepo(existing)) {
|
||||
const updated = store.updateRepo(existing.id, { kind: 'git' })
|
||||
if (updated) {
|
||||
notifyReposChanged(mainWindow)
|
||||
// Why: folder→git upgrade is a real new git repo provisioning event.
|
||||
emitRepoAdded('clone_url', false)
|
||||
return updated
|
||||
}
|
||||
}
|
||||
emitRepoAdded('clone_url', true)
|
||||
return existing
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
reject(new Error('Clone aborted'))
|
||||
} else if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error'
|
||||
reject(new Error(`Clone failed: ${lastLine}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Why: check after clone (not before) because the path didn't exist
|
||||
// before cloning. But if the user somehow had a folder repo at this path
|
||||
// that git clone succeeded into (empty dir), reuse that entry and upgrade
|
||||
// its kind to 'git' instead of creating a duplicate.
|
||||
const existing = store.getRepos().find((r) => r.path === clonePath)
|
||||
if (existing) {
|
||||
if (isFolderRepo(existing)) {
|
||||
const updated = store.updateRepo(existing.id, { kind: 'git' })
|
||||
if (updated) {
|
||||
notifyReposChanged(mainWindow)
|
||||
// Why: folder→git upgrade is a real new git repo provisioning event.
|
||||
emitRepoAdded('clone_url', false)
|
||||
return updated
|
||||
const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: clonePath,
|
||||
displayName: getRepoName(clonePath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('clone_url', false)
|
||||
return repo
|
||||
} finally {
|
||||
const metadata = cloneMetadataRef.current
|
||||
if (metadata?.abortRequested) {
|
||||
settleCloneAbortCleanup(metadata)
|
||||
}
|
||||
}
|
||||
emitRepoAdded('clone_url', true)
|
||||
return existing
|
||||
}
|
||||
|
||||
const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: clonePath,
|
||||
displayName: getRepoName(clonePath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('clone_url', false)
|
||||
return repo
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+376
-8
@@ -15,7 +15,10 @@ const {
|
||||
mockPtyProvider,
|
||||
mockFsProvider,
|
||||
mockGitProvider,
|
||||
mockPortForwardManager
|
||||
mockPortForwardManager,
|
||||
mockPortScannerCallbacks,
|
||||
mockNextConnectionManagers,
|
||||
mockNextPortForwardManagers
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
powerMonitorOffMock: vi.fn(),
|
||||
@@ -34,7 +37,9 @@ const {
|
||||
reconnect: vi.fn(),
|
||||
getConnection: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
disconnectAll: vi.fn()
|
||||
disconnectAll: vi.fn(),
|
||||
setCallbacks: vi.fn(),
|
||||
callbacksRef: { current: null as unknown }
|
||||
},
|
||||
mockDeployAndLaunchRelay: vi.fn(),
|
||||
mockForceStopRelayForTarget: vi.fn(),
|
||||
@@ -57,11 +62,15 @@ const {
|
||||
mockGitProvider: {},
|
||||
mockPortForwardManager: {
|
||||
addForward: vi.fn(),
|
||||
updateForward: vi.fn(),
|
||||
removeForward: vi.fn(),
|
||||
listForwards: vi.fn().mockReturnValue([]),
|
||||
removeAllForwards: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
},
|
||||
mockPortScannerCallbacks: new Map<string, unknown>(),
|
||||
mockNextConnectionManagers: [] as unknown[],
|
||||
mockNextPortForwardManagers: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -88,8 +97,14 @@ vi.mock('../ssh/ssh-connection-store', () => ({
|
||||
|
||||
vi.mock('../ssh/ssh-connection', () => ({
|
||||
SshConnectionManager: class MockSshConnectionManager {
|
||||
constructor() {
|
||||
return mockConnectionManager
|
||||
constructor(callbacks: unknown) {
|
||||
const manager = (mockNextConnectionManagers.shift() ??
|
||||
mockConnectionManager) as typeof mockConnectionManager
|
||||
manager.callbacksRef.current = callbacks
|
||||
manager.setCallbacks.mockImplementation((nextCallbacks: unknown) => {
|
||||
manager.callbacksRef.current = nextCallbacks
|
||||
})
|
||||
return manager
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -161,12 +176,26 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
vi.mock('../ssh/ssh-port-forward', () => ({
|
||||
SshPortForwardManager: class MockPortForwardManager {
|
||||
constructor() {
|
||||
return mockPortForwardManager
|
||||
return mockNextPortForwardManagers.shift() ?? mockPortForwardManager
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerSshHandlers } from './ssh'
|
||||
vi.mock('../ssh/ssh-port-scanner', () => ({
|
||||
PortScanner: class MockPortScanner {
|
||||
startScanning(targetId: string, _mux: unknown, onChanged: unknown) {
|
||||
mockPortScannerCallbacks.set(targetId, onChanged)
|
||||
}
|
||||
getDetectedPorts() {
|
||||
return []
|
||||
}
|
||||
stopScanning(targetId: string) {
|
||||
mockPortScannerCallbacks.delete(targetId)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { getSshConnectionManager, registerSshHandlers, resetSshHandlerStateForTests } from './ssh'
|
||||
import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, type SshTarget } from '../../shared/ssh-types'
|
||||
import {
|
||||
clearProviderPtyState,
|
||||
@@ -188,9 +217,35 @@ describe('SSH IPC handlers', () => {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
}
|
||||
const createMockWindow = () => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
})
|
||||
const createConnectionManagerMock = () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
reconnect: vi.fn(),
|
||||
getConnection: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
disconnectAll: vi.fn(),
|
||||
setCallbacks: vi.fn(),
|
||||
callbacksRef: { current: null as unknown }
|
||||
})
|
||||
const createPortForwardManagerMock = () => ({
|
||||
addForward: vi.fn(),
|
||||
updateForward: vi.fn(),
|
||||
removeForward: vi.fn(),
|
||||
listForwards: vi.fn().mockReturnValue([]),
|
||||
removeAllForwards: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await resetSshHandlerStateForTests()
|
||||
handlers.clear()
|
||||
mockNextConnectionManagers.length = 0
|
||||
mockNextPortForwardManagers.length = 0
|
||||
mockPortScannerCallbacks.clear()
|
||||
handleMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
@@ -213,6 +268,8 @@ describe('SSH IPC handlers', () => {
|
||||
mockConnectionManager.getConnection.mockReset()
|
||||
mockConnectionManager.getState.mockReset()
|
||||
mockConnectionManager.disconnectAll.mockReset()
|
||||
mockConnectionManager.setCallbacks.mockReset()
|
||||
mockConnectionManager.callbacksRef.current = null
|
||||
mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined)
|
||||
|
||||
mockDeployAndLaunchRelay.mockReset().mockResolvedValue({
|
||||
@@ -228,6 +285,7 @@ describe('SSH IPC handlers', () => {
|
||||
mockPtyProvider.onReplay.mockReset()
|
||||
mockPtyProvider.shutdown.mockReset()
|
||||
mockPortForwardManager.addForward.mockReset()
|
||||
mockPortForwardManager.updateForward.mockReset()
|
||||
mockPortForwardManager.removeForward.mockReset()
|
||||
mockPortForwardManager.listForwards.mockReset().mockReturnValue([])
|
||||
mockPortForwardManager.removeAllForwards.mockReset()
|
||||
@@ -437,6 +495,64 @@ describe('SSH IPC handlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilds instead of reusing a ready session while relay loss is pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
try {
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
const onDispose = mockMux.onDispose.mock.calls[0]?.[0] as
|
||||
| ((reason: 'shutdown' | 'connection_lost') => void)
|
||||
| undefined
|
||||
|
||||
onDispose?.('connection_lost')
|
||||
|
||||
expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({
|
||||
targetId: 'ssh-1',
|
||||
status: 'reconnecting',
|
||||
error: 'Relay channel lost. Reconnecting...',
|
||||
reconnectAttempt: 1
|
||||
})
|
||||
|
||||
mockDeployAndLaunchRelay.mockClear()
|
||||
mockPortForwardManager.removeAllForwards.mockClear()
|
||||
|
||||
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mockDeployAndLaunchRelay).toHaveBeenCalled()
|
||||
expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards remote PTY events into the runtime', async () => {
|
||||
const runtime = {
|
||||
onPtyData: vi.fn(),
|
||||
@@ -474,6 +590,258 @@ describe('SSH IPC handlers', () => {
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7)
|
||||
})
|
||||
|
||||
it('preserves active port forwards and live connections across handler re-registration', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
const forward = {
|
||||
id: 'pf-1',
|
||||
connectionId: 'ssh-1',
|
||||
localPort: 4100,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 3000,
|
||||
label: 'app'
|
||||
}
|
||||
const updatedForward = { ...forward, remotePort: 3001 }
|
||||
const newForward = { ...forward, id: 'pf-2', localPort: 4101 }
|
||||
const connectedState = {
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected' as const,
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue(connectedState)
|
||||
mockPortForwardManager.addForward
|
||||
.mockResolvedValueOnce(forward)
|
||||
.mockResolvedValueOnce(newForward)
|
||||
mockPortForwardManager.updateForward.mockResolvedValue(updatedForward)
|
||||
mockPortForwardManager.removeForward.mockReturnValue(updatedForward)
|
||||
mockPortForwardManager.listForwards.mockReturnValue([forward])
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
await handlers.get('ssh:addPortForward')!(null, {
|
||||
targetId: 'ssh-1',
|
||||
localPort: 4100,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 3000,
|
||||
label: 'app'
|
||||
})
|
||||
const replacementConnectionManager = createConnectionManagerMock()
|
||||
const replacementPortForwardManager = createPortForwardManagerMock()
|
||||
mockNextConnectionManagers.push(replacementConnectionManager)
|
||||
mockNextPortForwardManagers.push(replacementPortForwardManager)
|
||||
|
||||
registerSshHandlers(mockStore as never, () => createMockWindow() as never)
|
||||
|
||||
expect(getSshConnectionManager()).toBe(mockConnectionManager)
|
||||
expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([
|
||||
forward
|
||||
])
|
||||
mockDeployAndLaunchRelay.mockClear()
|
||||
mockPortForwardManager.removeAllForwards.mockClear()
|
||||
|
||||
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual(
|
||||
connectedState
|
||||
)
|
||||
expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled()
|
||||
expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled()
|
||||
expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([
|
||||
forward
|
||||
])
|
||||
|
||||
await handlers.get('ssh:updatePortForward')!(null, {
|
||||
id: 'pf-1',
|
||||
targetId: 'ssh-1',
|
||||
localPort: 4100,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 3001,
|
||||
label: 'app'
|
||||
})
|
||||
expect(mockPortForwardManager.updateForward).toHaveBeenCalledWith(
|
||||
'pf-1',
|
||||
conn,
|
||||
4100,
|
||||
'127.0.0.1',
|
||||
3001,
|
||||
'app'
|
||||
)
|
||||
|
||||
expect(await handlers.get('ssh:removePortForward')!(null, { id: 'pf-1' })).toEqual(
|
||||
updatedForward
|
||||
)
|
||||
await handlers.get('ssh:addPortForward')!(null, {
|
||||
targetId: 'ssh-1',
|
||||
localPort: 4101,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 3000,
|
||||
label: 'app'
|
||||
})
|
||||
expect(mockPortForwardManager.addForward).toHaveBeenLastCalledWith(
|
||||
'ssh-1',
|
||||
conn,
|
||||
4101,
|
||||
'127.0.0.1',
|
||||
3000,
|
||||
'app'
|
||||
)
|
||||
expect(replacementConnectionManager.getConnection).not.toHaveBeenCalled()
|
||||
expect(replacementPortForwardManager.listForwards).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disconnects the original session and releases original forwards after re-registration', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
mockPortForwardManager.removeAllForwards.mockClear()
|
||||
mockConnectionManager.disconnect.mockClear().mockResolvedValue(undefined)
|
||||
const replacementConnectionManager = createConnectionManagerMock()
|
||||
const replacementPortForwardManager = createPortForwardManagerMock()
|
||||
mockNextConnectionManagers.push(replacementConnectionManager)
|
||||
mockNextPortForwardManagers.push(replacementPortForwardManager)
|
||||
|
||||
registerSshHandlers(mockStore as never, () => createMockWindow() as never)
|
||||
await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
expect(replacementPortForwardManager.removeAllForwards).not.toHaveBeenCalled()
|
||||
expect(replacementConnectionManager.disconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes live session callbacks to the newest window, store, and runtime', async () => {
|
||||
const firstWindow = createMockWindow()
|
||||
const secondWindow = createMockWindow()
|
||||
const firstRuntime = {
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
const secondRuntime = {
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
registerSshHandlers(mockStore as never, () => firstWindow as never, firstRuntime as never)
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as
|
||||
| ((payload: { id: string; data: string }) => void)
|
||||
| undefined
|
||||
const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as
|
||||
| ((payload: { id: string; code: number }) => void)
|
||||
| undefined
|
||||
const onDetectedPorts = mockPortScannerCallbacks.get('ssh-1') as
|
||||
| ((targetId: string, ports: unknown[], platform: string) => void)
|
||||
| undefined
|
||||
firstWindow.webContents.send.mockClear()
|
||||
secondWindow.webContents.send.mockClear()
|
||||
|
||||
registerSshHandlers(mockStore as never, () => secondWindow as never, secondRuntime as never)
|
||||
const callbacks = mockConnectionManager.callbacksRef.current as {
|
||||
onStateChange: (targetId: string, state: unknown) => void
|
||||
}
|
||||
|
||||
callbacks.onStateChange('ssh-1', {
|
||||
targetId: 'ssh-1',
|
||||
status: 'error',
|
||||
error: 'network down',
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
onData?.({ id: 'remote-pty', data: 'hello' })
|
||||
onExit?.({ id: 'remote-pty', code: 9 })
|
||||
onDetectedPorts?.(
|
||||
'ssh-1',
|
||||
[{ host: '127.0.0.1', port: 3000, pid: 12, processName: 'node' }],
|
||||
'linux-x64'
|
||||
)
|
||||
|
||||
expect(firstWindow.webContents.send).not.toHaveBeenCalled()
|
||||
expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', {
|
||||
targetId: 'ssh-1',
|
||||
state: {
|
||||
targetId: 'ssh-1',
|
||||
status: 'error',
|
||||
error: 'network down',
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
})
|
||||
expect(secondWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'pty:data',
|
||||
expect.objectContaining({ id: 'remote-pty', data: 'hello' })
|
||||
)
|
||||
expect(secondWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
|
||||
id: 'remote-pty',
|
||||
code: 9
|
||||
})
|
||||
expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:detected-ports-changed', {
|
||||
targetId: 'ssh-1',
|
||||
ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })])
|
||||
})
|
||||
expect(secondRuntime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number))
|
||||
expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9)
|
||||
expect(firstRuntime.onPtyData).not.toHaveBeenCalled()
|
||||
expect(firstRuntime.onPtyExit).not.toHaveBeenCalled()
|
||||
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'remote-pty',
|
||||
'terminated'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-registers without replacing managers when no targets are connected', () => {
|
||||
const replacementConnectionManager = createConnectionManagerMock()
|
||||
const replacementPortForwardManager = createPortForwardManagerMock()
|
||||
mockNextConnectionManagers.push(replacementConnectionManager)
|
||||
mockNextPortForwardManagers.push(replacementPortForwardManager)
|
||||
|
||||
const result = registerSshHandlers(mockStore as never, () => createMockWindow() as never)
|
||||
|
||||
expect(result.connectionManager).toBe(mockConnectionManager)
|
||||
expect(replacementConnectionManager.setCallbacks).not.toHaveBeenCalled()
|
||||
expect(replacementPortForwardManager.dispose).not.toHaveBeenCalled()
|
||||
expect(mockNextConnectionManagers).toHaveLength(1)
|
||||
expect(mockNextPortForwardManagers).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ssh:disconnect calls connection manager', async () => {
|
||||
mockConnectionManager.disconnect.mockResolvedValue(undefined)
|
||||
|
||||
|
||||
+259
-182
@@ -45,6 +45,37 @@ let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined
|
||||
let persistedStore: Store | null = null
|
||||
let advertisedUrlWatcherUnsubscribe: (() => void) | null = null
|
||||
let powerMonitorUnsubscribe: (() => void) | null = null
|
||||
let currentGetMainWindow: () => BrowserWindow | null = () => null
|
||||
let currentRuntime: OrcaRuntimeService | undefined
|
||||
|
||||
const SSH_IPC_CHANNELS = [
|
||||
'ssh:listTargets',
|
||||
'ssh:addTarget',
|
||||
'ssh:updateTarget',
|
||||
'ssh:removeTarget',
|
||||
'ssh:importConfig',
|
||||
'ssh:connect',
|
||||
'ssh:disconnect',
|
||||
'ssh:terminateSessions',
|
||||
'ssh:resetRelay',
|
||||
'ssh:getState',
|
||||
'ssh:needsPassphrasePrompt',
|
||||
'ssh:testConnection',
|
||||
'ssh:addPortForward',
|
||||
'ssh:updatePortForward',
|
||||
'ssh:removePortForward',
|
||||
'ssh:listPortForwards',
|
||||
'ssh:listDetectedPorts'
|
||||
] as const
|
||||
|
||||
// Why: connection callbacks are process-lifetime; keeping this set outside
|
||||
// registerSshHandlers prevents in-flight connects from splitting credential
|
||||
// tracking when a BrowserWindow is recreated.
|
||||
const credentialRequestedForTarget = new Set<string>()
|
||||
|
||||
function getCurrentMainWindow(): BrowserWindow | null {
|
||||
return currentGetMainWindow()
|
||||
}
|
||||
|
||||
export async function connectRegisteredSshTarget(targetId: string): Promise<SshConnectionState> {
|
||||
if (!registeredConnectSshTarget) {
|
||||
@@ -340,51 +371,11 @@ function registerPowerMonitorReconnect(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSshHandlers(
|
||||
store: Store,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
runtime?: OrcaRuntimeService
|
||||
): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } {
|
||||
// Why: on macOS, app re-activation creates a new BrowserWindow and re-calls
|
||||
// this function. ipcMain.handle() throws if a handler is already registered,
|
||||
// so we must remove any prior handlers before re-registering.
|
||||
for (const ch of [
|
||||
'ssh:listTargets',
|
||||
'ssh:addTarget',
|
||||
'ssh:updateTarget',
|
||||
'ssh:removeTarget',
|
||||
'ssh:importConfig',
|
||||
'ssh:connect',
|
||||
'ssh:disconnect',
|
||||
'ssh:terminateSessions',
|
||||
'ssh:resetRelay',
|
||||
'ssh:getState',
|
||||
'ssh:needsPassphrasePrompt',
|
||||
'ssh:testConnection',
|
||||
'ssh:addPortForward',
|
||||
'ssh:updatePortForward',
|
||||
'ssh:removePortForward',
|
||||
'ssh:listPortForwards',
|
||||
'ssh:listDetectedPorts'
|
||||
]) {
|
||||
ipcMain.removeHandler(ch)
|
||||
}
|
||||
|
||||
sshStore = new SshConnectionStore(store)
|
||||
persistedStore = store
|
||||
registerAdvertisedUrlRefresh(getMainWindow)
|
||||
|
||||
registerCredentialHandler(getMainWindow)
|
||||
|
||||
// Why: tracks whether a credential prompt was triggered during the current
|
||||
// ssh:connect call. Used to set lastRequiredPassphrase on the target so
|
||||
// startup reconnect can defer passphrase-protected targets to tab focus.
|
||||
const credentialRequestedForTarget = new Set<string>()
|
||||
|
||||
const callbacks: SshConnectionCallbacks = {
|
||||
function createSshConnectionCallbacks(): SshConnectionCallbacks {
|
||||
return {
|
||||
onCredentialRequest: (targetId, kind, detail) => {
|
||||
credentialRequestedForTarget.add(targetId)
|
||||
return requestCredential(getMainWindow, targetId, kind, detail)
|
||||
return requestCredential(getCurrentMainWindow, targetId, kind, detail)
|
||||
},
|
||||
onStateChange: (targetId: string, state: SshConnectionState) => {
|
||||
if (testingTargets.has(targetId)) {
|
||||
@@ -407,7 +398,7 @@ export function registerSshHandlers(
|
||||
// Why: SSH is connected before the relay providers are rebuilt. Keep
|
||||
// renderer actions gated until SshRelaySession reaches ready again.
|
||||
publishRelayOverride(
|
||||
getMainWindow,
|
||||
getCurrentMainWindow,
|
||||
targetId,
|
||||
'reconnecting',
|
||||
'Relay channel reconnecting...',
|
||||
@@ -415,7 +406,7 @@ export function registerSshHandlers(
|
||||
)
|
||||
} else {
|
||||
clearRelayStateOverride(targetId)
|
||||
broadcastSshState(getMainWindow, targetId, state)
|
||||
broadcastSshState(getCurrentMainWindow, targetId, state)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
@@ -434,9 +425,159 @@ export function registerSshHandlers(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectionManager = new SshConnectionManager(callbacks)
|
||||
portForwardManager = new SshPortForwardManager()
|
||||
function broadcastDetectedPortsFromCurrentWindow(
|
||||
targetId: string,
|
||||
ports: DetectedPort[],
|
||||
_platform: string
|
||||
): void {
|
||||
broadcastDetectedPorts(getCurrentMainWindow, targetId, ports)
|
||||
}
|
||||
|
||||
function configureRelaySessionCallbacks(session: SshRelaySession): void {
|
||||
session.setOnTerminalRelayError((tid, err) => {
|
||||
clearRelayLostBackoff(tid)
|
||||
console.warn(
|
||||
`[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.`
|
||||
)
|
||||
publishRelayOverride(getCurrentMainWindow, tid, 'error', err.message, 0)
|
||||
})
|
||||
|
||||
session.setOnRelayLost((tid) => {
|
||||
const s = activeSessions.get(tid)
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
const c = connectionManager?.getConnection(tid)
|
||||
if (!c) {
|
||||
return
|
||||
}
|
||||
const t = sshStore?.getTarget(tid)
|
||||
|
||||
// Why: bounded exponential backoff. Without this, a remote-side bug
|
||||
// that closes every fresh --connect channel turns into an infinite
|
||||
// tight loop spawning relay deploys until the user force-quits.
|
||||
const state = relayLostBackoff.get(tid) ?? {
|
||||
attempts: 0,
|
||||
lastAttemptStartedAt: 0,
|
||||
pendingTimer: null
|
||||
}
|
||||
if (state.pendingTimer) {
|
||||
return
|
||||
}
|
||||
if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) {
|
||||
console.warn(
|
||||
`[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.`
|
||||
)
|
||||
relayLostBackoff.delete(tid)
|
||||
// Why: surface the failure so the renderer can prompt the user.
|
||||
// A still-live SSH connection with a dead relay is otherwise an
|
||||
// invisible failure — typing in remote terminals just stops working.
|
||||
publishRelayOverride(
|
||||
getCurrentMainWindow,
|
||||
tid,
|
||||
'error',
|
||||
'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.',
|
||||
0
|
||||
)
|
||||
return
|
||||
}
|
||||
const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS)
|
||||
state.attempts += 1
|
||||
publishRelayOverride(
|
||||
getCurrentMainWindow,
|
||||
tid,
|
||||
'reconnecting',
|
||||
'Relay channel lost. Reconnecting...',
|
||||
state.attempts
|
||||
)
|
||||
state.pendingTimer = setTimeout(() => {
|
||||
state.pendingTimer = null
|
||||
state.lastAttemptStartedAt = Date.now()
|
||||
relayLostBackoff.set(tid, state)
|
||||
const liveConn = connectionManager?.getConnection(tid)
|
||||
if (!liveConn || !activeSessions.has(tid)) {
|
||||
return
|
||||
}
|
||||
void s.reconnect(liveConn, relayGracePeriodForTarget(t))
|
||||
}, delay)
|
||||
relayLostBackoff.set(tid, state)
|
||||
console.warn(
|
||||
`[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms`
|
||||
)
|
||||
})
|
||||
|
||||
// Why: fires after both establish() and reconnect() reach 'ready'.
|
||||
// Re-creates persisted port forwards so they survive app restarts
|
||||
// and network blips without manual re-configuration.
|
||||
session.setOnReady((tid) => {
|
||||
const state = relayLostBackoff.get(tid)
|
||||
if (state) {
|
||||
const stabilized =
|
||||
state.lastAttemptStartedAt === 0 ||
|
||||
Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS
|
||||
if (stabilized) {
|
||||
relayLostBackoff.delete(tid)
|
||||
}
|
||||
}
|
||||
clearRelayStateOverride(tid)
|
||||
if (!testingTargets.has(tid)) {
|
||||
broadcastSshState(getCurrentMainWindow, tid, {
|
||||
targetId: tid,
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
}
|
||||
void restorePortForwards(tid, getCurrentMainWindow)
|
||||
})
|
||||
}
|
||||
|
||||
function refreshActiveRelaySessions(): void {
|
||||
if (!persistedStore || !portForwardManager) {
|
||||
return
|
||||
}
|
||||
for (const session of activeSessions.values()) {
|
||||
session.refreshEnvironment(
|
||||
getCurrentMainWindow,
|
||||
persistedStore,
|
||||
portForwardManager,
|
||||
currentRuntime,
|
||||
broadcastDetectedPortsFromCurrentWindow
|
||||
)
|
||||
configureRelaySessionCallbacks(session)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSshHandlers(
|
||||
store: Store,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
runtime?: OrcaRuntimeService
|
||||
): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } {
|
||||
// Why: on macOS, app re-activation creates a new BrowserWindow and re-calls
|
||||
// this function. ipcMain.handle() throws if a handler is already registered,
|
||||
// so we must remove any prior handlers before re-registering.
|
||||
for (const ch of SSH_IPC_CHANNELS) {
|
||||
ipcMain.removeHandler(ch)
|
||||
}
|
||||
|
||||
currentGetMainWindow = getMainWindow
|
||||
currentRuntime = runtime
|
||||
sshStore = new SshConnectionStore(store)
|
||||
persistedStore = store
|
||||
registerAdvertisedUrlRefresh(getCurrentMainWindow)
|
||||
|
||||
registerCredentialHandler(getCurrentMainWindow)
|
||||
|
||||
const callbacks = createSshConnectionCallbacks()
|
||||
if (connectionManager) {
|
||||
connectionManager.setCallbacks(callbacks)
|
||||
} else {
|
||||
connectionManager = new SshConnectionManager(callbacks)
|
||||
}
|
||||
portForwardManager ??= new SshPortForwardManager()
|
||||
refreshActiveRelaySessions()
|
||||
registerPowerMonitorReconnect()
|
||||
registerSshBrowseHandler(() => connectionManager)
|
||||
|
||||
@@ -475,7 +616,7 @@ export function registerSshHandlers(
|
||||
`[ssh] Failed to disconnect removed target ${args.id}: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
store.removeSshRemotePtyLeases(args.id)
|
||||
persistedStore!.removeSshRemotePtyLeases(args.id)
|
||||
sshStore!.removeTarget(args.id)
|
||||
})
|
||||
|
||||
@@ -516,17 +657,35 @@ export function registerSshHandlers(
|
||||
})
|
||||
|
||||
async function doConnect(targetId: string): Promise<SshConnectionState> {
|
||||
clearRelayStateOverride(targetId)
|
||||
const target = sshStore!.getTarget(targetId)
|
||||
if (!target) {
|
||||
throw new Error(`SSH target "${targetId}" not found`)
|
||||
}
|
||||
|
||||
const existingSession = activeSessions.get(targetId)
|
||||
const existingState = connectionManager!.getState(targetId)
|
||||
const existingMux = existingSession?.getMux()
|
||||
if (
|
||||
existingSession?.getState() === 'ready' &&
|
||||
existingState?.status === 'connected' &&
|
||||
connectionManager!.getConnection(targetId) &&
|
||||
existingMux &&
|
||||
!existingMux.isDisposed() &&
|
||||
!relayStateOverrides.has(targetId) &&
|
||||
!relayLostBackoff.has(targetId)
|
||||
) {
|
||||
// Why: BrowserWindow reactivation reruns renderer startup, which calls
|
||||
// ssh:connect for already-live targets. Treat that as a refresh instead
|
||||
// of tearing down the relay and stranding active port forwards.
|
||||
broadcastSshState(getCurrentMainWindow, targetId, existingState)
|
||||
return existingState
|
||||
}
|
||||
|
||||
clearRelayStateOverride(targetId)
|
||||
let conn
|
||||
// Why: dispose any existing session to avoid leaking the old multiplexer,
|
||||
// providers, and timers. This handles double-connect (user clicks connect
|
||||
// while already connected) and reconnect-after-error.
|
||||
const existingSession = activeSessions.get(targetId)
|
||||
if (existingSession) {
|
||||
// Why: await port teardown before disposing so the OS fully releases
|
||||
// local ports. Without this, restorePortForwards in the new session
|
||||
@@ -542,14 +701,13 @@ export function registerSshHandlers(
|
||||
// state and knows not to trigger reconnect logic.
|
||||
const session = new SshRelaySession(
|
||||
targetId,
|
||||
getMainWindow,
|
||||
store,
|
||||
getCurrentMainWindow,
|
||||
persistedStore!,
|
||||
portForwardManager!,
|
||||
runtime,
|
||||
(tid, ports, _platform) => {
|
||||
broadcastDetectedPorts(getMainWindow, tid, ports)
|
||||
}
|
||||
currentRuntime,
|
||||
broadcastDetectedPortsFromCurrentWindow
|
||||
)
|
||||
configureRelaySessionCallbacks(session)
|
||||
activeSessions.set(targetId, session)
|
||||
|
||||
try {
|
||||
@@ -569,7 +727,7 @@ export function registerSshHandlers(
|
||||
activeSessions.delete(targetId)
|
||||
clearRelayLostBackoff(targetId)
|
||||
clearRelayStateOverride(targetId)
|
||||
broadcastSshState(getMainWindow, targetId, {
|
||||
broadcastSshState(getCurrentMainWindow, targetId, {
|
||||
targetId,
|
||||
status,
|
||||
error: errObj.message,
|
||||
@@ -587,129 +745,13 @@ export function registerSshHandlers(
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
// Why: the relay exec channel can close independently of the SSH
|
||||
// connection (e.g. --connect bridge exits, relay process crashes).
|
||||
// When that happens, the mux is disposed but onStateChange never
|
||||
// fires because the SSH connection is still alive. This callback
|
||||
// triggers session.reconnect() using the live SSH connection.
|
||||
// Set before establish() so the callback is in place if the relay
|
||||
// dies during the deploy/connect sequence.
|
||||
// Why: a wire-handshake mismatch (typed RelayVersionMismatchError) means
|
||||
// the local client and remote daemon are at different code versions —
|
||||
// no amount of backoff will reconcile them. Skip the relay-lost loop
|
||||
// entirely and surface a terminal "please reconnect manually" error.
|
||||
session.setOnTerminalRelayError((tid, err) => {
|
||||
clearRelayLostBackoff(tid)
|
||||
console.warn(
|
||||
`[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.`
|
||||
)
|
||||
publishRelayOverride(getMainWindow, tid, 'error', err.message, 0)
|
||||
})
|
||||
|
||||
session.setOnRelayLost((tid) => {
|
||||
const s = activeSessions.get(tid)
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
const c = connectionManager?.getConnection(tid)
|
||||
if (!c) {
|
||||
return
|
||||
}
|
||||
const t = sshStore?.getTarget(tid)
|
||||
|
||||
// Why: bounded exponential backoff. Without this, a remote-side bug
|
||||
// that closes every fresh --connect channel turns into an infinite
|
||||
// tight loop spawning relay deploys until the user force-quits.
|
||||
const state = relayLostBackoff.get(tid) ?? {
|
||||
attempts: 0,
|
||||
lastAttemptStartedAt: 0,
|
||||
pendingTimer: null
|
||||
}
|
||||
if (state.pendingTimer) {
|
||||
// A retry is already scheduled — coalesce this burst.
|
||||
return
|
||||
}
|
||||
if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) {
|
||||
console.warn(
|
||||
`[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.`
|
||||
)
|
||||
relayLostBackoff.delete(tid)
|
||||
// Why: surface the failure so the renderer can prompt the user.
|
||||
// A still-live SSH connection with a dead relay is otherwise an
|
||||
// invisible failure — typing in remote terminals just stops working.
|
||||
publishRelayOverride(
|
||||
getMainWindow,
|
||||
tid,
|
||||
'error',
|
||||
'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.',
|
||||
0
|
||||
)
|
||||
return
|
||||
}
|
||||
const delay = Math.min(
|
||||
RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts,
|
||||
RELAY_LOST_MAX_DELAY_MS
|
||||
)
|
||||
state.attempts += 1
|
||||
publishRelayOverride(
|
||||
getMainWindow,
|
||||
tid,
|
||||
'reconnecting',
|
||||
'Relay channel lost. Reconnecting...',
|
||||
state.attempts
|
||||
)
|
||||
state.pendingTimer = setTimeout(() => {
|
||||
state.pendingTimer = null
|
||||
state.lastAttemptStartedAt = Date.now()
|
||||
relayLostBackoff.set(tid, state)
|
||||
const liveConn = connectionManager?.getConnection(tid)
|
||||
if (!liveConn || !activeSessions.has(tid)) {
|
||||
return
|
||||
}
|
||||
void s.reconnect(liveConn, relayGracePeriodForTarget(t))
|
||||
}, delay)
|
||||
relayLostBackoff.set(tid, state)
|
||||
console.warn(
|
||||
`[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms`
|
||||
)
|
||||
})
|
||||
|
||||
// Why: fires after both establish() and reconnect() reach 'ready'.
|
||||
// Re-creates persisted port forwards so they survive app restarts
|
||||
// and network blips without manual re-configuration. We also clear
|
||||
// the relay-lost backoff state so a subsequent genuine drop starts
|
||||
// from a fresh attempt counter — but only if the session had a chance
|
||||
// to stabilize, otherwise rapid `ready → lost → ready → lost` flaps
|
||||
// would silently keep retrying forever.
|
||||
session.setOnReady((tid) => {
|
||||
const state = relayLostBackoff.get(tid)
|
||||
if (state) {
|
||||
const stabilized =
|
||||
state.lastAttemptStartedAt === 0 ||
|
||||
Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS
|
||||
if (stabilized) {
|
||||
relayLostBackoff.delete(tid)
|
||||
}
|
||||
}
|
||||
clearRelayStateOverride(tid)
|
||||
if (!testingTargets.has(tid)) {
|
||||
broadcastSshState(getMainWindow, tid, {
|
||||
targetId: tid,
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
}
|
||||
void restorePortForwards(tid, getMainWindow)
|
||||
})
|
||||
|
||||
await session.establish(conn, relayGracePeriodForTarget(target))
|
||||
|
||||
// Why: we manually pushed `deploying-relay` above, so the renderer's
|
||||
// state is stuck there. Send `connected` directly to the renderer
|
||||
// instead of going through callbacks.onStateChange, which would
|
||||
// trigger the reconnection logic.
|
||||
const win = getMainWindow()
|
||||
const win = getCurrentMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
clearRelayStateOverride(targetId)
|
||||
win.webContents.send('ssh:state-changed', {
|
||||
@@ -759,7 +801,7 @@ export function registerSshHandlers(
|
||||
ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => {
|
||||
const session = activeSessions.get(args.targetId)
|
||||
const provider = getSshPtyProvider(args.targetId)
|
||||
const leasedIds = store
|
||||
const leasedIds = persistedStore!
|
||||
.getSshRemotePtyLeases(args.targetId)
|
||||
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')
|
||||
.map((lease) => lease.ptyId)
|
||||
@@ -803,7 +845,7 @@ export function registerSshHandlers(
|
||||
}
|
||||
clearProviderPtyState(appPtyId)
|
||||
deletePtyOwnership(appPtyId)
|
||||
store.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
|
||||
persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
|
||||
}
|
||||
if (shutdownFailures.length > 0) {
|
||||
// Why: a failed relay shutdown can leave the remote process alive in the
|
||||
@@ -848,10 +890,10 @@ export function registerSshHandlers(
|
||||
await forceStopRelayForTarget(conn, targetId)
|
||||
} finally {
|
||||
const ptyIds = new Set(getPtyIdsForConnection(targetId))
|
||||
for (const lease of store.getSshRemotePtyLeases(targetId)) {
|
||||
for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) {
|
||||
if (lease.state !== 'terminated' && lease.state !== 'expired') {
|
||||
ptyIds.add(lease.ptyId)
|
||||
store.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
|
||||
persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
|
||||
}
|
||||
}
|
||||
// Why: reset force-kills the remote relay daemon, so every local PTY
|
||||
@@ -998,7 +1040,7 @@ export function registerSshHandlers(
|
||||
args.label
|
||||
)
|
||||
persistPortForwards(args.targetId)
|
||||
broadcastPortForwards(getMainWindow, args.targetId)
|
||||
broadcastPortForwards(getCurrentMainWindow, args.targetId)
|
||||
return entry
|
||||
}
|
||||
)
|
||||
@@ -1030,14 +1072,14 @@ export function registerSshHandlers(
|
||||
args.label
|
||||
)
|
||||
persistPortForwards(entry.connectionId)
|
||||
broadcastPortForwards(getMainWindow, entry.connectionId)
|
||||
broadcastPortForwards(getCurrentMainWindow, entry.connectionId)
|
||||
return entry
|
||||
} catch (err) {
|
||||
// Why: if the edit failed (and rollback may also have failed),
|
||||
// sync the renderer with the actual runtime state so it doesn't
|
||||
// show a forward that no longer exists.
|
||||
persistPortForwards(args.targetId)
|
||||
broadcastPortForwards(getMainWindow, args.targetId)
|
||||
broadcastPortForwards(getCurrentMainWindow, args.targetId)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -1047,7 +1089,7 @@ export function registerSshHandlers(
|
||||
const removed = portForwardManager!.removeForward(args.id)
|
||||
if (removed) {
|
||||
persistPortForwards(removed.connectionId)
|
||||
broadcastPortForwards(getMainWindow, removed.connectionId)
|
||||
broadcastPortForwards(getCurrentMainWindow, removed.connectionId)
|
||||
}
|
||||
return removed
|
||||
})
|
||||
@@ -1076,6 +1118,41 @@ export function getSshConnectionManager(): SshConnectionManager | null {
|
||||
return connectionManager
|
||||
}
|
||||
|
||||
export async function resetSshHandlerStateForTests(): Promise<void> {
|
||||
advertisedUrlWatcherUnsubscribe?.()
|
||||
advertisedUrlWatcherUnsubscribe = null
|
||||
powerMonitorUnsubscribe?.()
|
||||
powerMonitorUnsubscribe = null
|
||||
for (const ch of SSH_IPC_CHANNELS) {
|
||||
ipcMain.removeHandler(ch)
|
||||
}
|
||||
ipcMain.removeHandler('ssh:submitCredential')
|
||||
|
||||
for (const session of activeSessions.values()) {
|
||||
session.dispose()
|
||||
}
|
||||
activeSessions.clear()
|
||||
for (const targetId of relayLostBackoff.keys()) {
|
||||
clearRelayLostBackoff(targetId)
|
||||
}
|
||||
relayStateOverrides.clear()
|
||||
connectInFlight.clear()
|
||||
resetRelayInFlight.clear()
|
||||
testingTargets.clear()
|
||||
credentialRequestedForTarget.clear()
|
||||
|
||||
await connectionManager?.disconnectAll()
|
||||
portForwardManager?.dispose()
|
||||
connectionManager = null
|
||||
portForwardManager = null
|
||||
sshStore = null
|
||||
persistedStore = null
|
||||
registeredConnectSshTarget = null
|
||||
registeredGetSshState = null
|
||||
currentGetMainWindow = () => null
|
||||
currentRuntime = undefined
|
||||
}
|
||||
|
||||
export function getSshConnectionStore(): SshConnectionStore | null {
|
||||
return sshStore
|
||||
}
|
||||
|
||||
@@ -253,6 +253,7 @@ describe('Store', () => {
|
||||
expect(settings.openInApplications).toEqual([])
|
||||
expect(settings.experimentalActivity).toBe(false)
|
||||
expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true)
|
||||
expect(settings.experimentalTerminalAttention).toBe(false)
|
||||
expect(settings.floatingTerminalEnabled).toBe(true)
|
||||
expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true)
|
||||
expect(settings.notifications.customSoundPath).toBeNull()
|
||||
@@ -785,6 +786,7 @@ describe('Store', () => {
|
||||
expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear'])
|
||||
expect(store.getSettings().experimentalActivity).toBe(false)
|
||||
expect(store.getSettings().experimentalActivityDefaultedOffForAllUsers).toBe(true)
|
||||
expect(store.getSettings().experimentalTerminalAttention).toBe(false)
|
||||
expect(store.getSettings().notifications.customSoundPath).toBeNull()
|
||||
// repos should be loaded
|
||||
expect(store.getRepos()).toHaveLength(1)
|
||||
|
||||
@@ -179,4 +179,61 @@ describe('SshFilesystemProvider readFile streaming', () => {
|
||||
})
|
||||
await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/count mismatch/i)
|
||||
})
|
||||
|
||||
it('rejects a short final chunk instead of zero-filling the buffer', async () => {
|
||||
// Two declared chunks, but the final one delivers a single byte. The chunk
|
||||
// count matches (2), so the old code resolved with a zero-filled tail. The
|
||||
// exact-length check must reject this.
|
||||
const totalSize = 256 * 1024 * 2
|
||||
mux.request.mockImplementation(async () => {
|
||||
setImmediate(() => {
|
||||
mux._emitMethod('fs.streamChunk', {
|
||||
streamId: 1,
|
||||
seq: 0,
|
||||
data: Buffer.alloc(256 * 1024).toString('base64')
|
||||
})
|
||||
mux._emitMethod('fs.streamChunk', {
|
||||
streamId: 1,
|
||||
seq: 1,
|
||||
data: Buffer.alloc(1).toString('base64')
|
||||
})
|
||||
mux._emitMethod('fs.streamEnd', { streamId: 1 })
|
||||
})
|
||||
return {
|
||||
streamId: 1,
|
||||
totalSize,
|
||||
isBinary: true,
|
||||
chunkEncoding: 'base64',
|
||||
resultEncoding: 'base64'
|
||||
}
|
||||
})
|
||||
await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i)
|
||||
})
|
||||
|
||||
it('rejects a short non-final chunk before later chunks arrive', async () => {
|
||||
const totalSize = 256 * 1024 * 2
|
||||
mux.request.mockImplementation(async () => {
|
||||
setImmediate(() => {
|
||||
mux._emitMethod('fs.streamChunk', {
|
||||
streamId: 1,
|
||||
seq: 0,
|
||||
data: Buffer.alloc(1).toString('base64')
|
||||
})
|
||||
mux._emitMethod('fs.streamChunk', {
|
||||
streamId: 1,
|
||||
seq: 1,
|
||||
data: Buffer.alloc(256 * 1024).toString('base64')
|
||||
})
|
||||
mux._emitMethod('fs.streamEnd', { streamId: 1 })
|
||||
})
|
||||
return {
|
||||
streamId: 1,
|
||||
totalSize,
|
||||
isBinary: true,
|
||||
chunkEncoding: 'base64',
|
||||
resultEncoding: 'base64'
|
||||
}
|
||||
})
|
||||
await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ SFTP binary writes, watch fan-out, and provider lifecycle tests together so
|
||||
transport parity regressions are visible in one suite. */
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { SshFilesystemProvider } from './ssh-filesystem-provider'
|
||||
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
|
||||
type MockMultiplexer = {
|
||||
request: ReturnType<typeof vi.fn>
|
||||
@@ -259,6 +260,29 @@ describe('SshFilesystemProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renameNoClobber sends fs.renameNoClobber request', async () => {
|
||||
await provider.renameNoClobber('/home/old.txt', '/home/new.txt')
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', {
|
||||
oldPath: '/home/old.txt',
|
||||
newPath: '/home/new.txt'
|
||||
})
|
||||
})
|
||||
|
||||
it('renameNoClobber fails closed when the relay lacks safe rename support', async () => {
|
||||
mux.request.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound })
|
||||
)
|
||||
|
||||
await expect(provider.renameNoClobber('/home/old.txt', '/home/new.txt')).rejects.toThrow(
|
||||
'Remote safe rename is unavailable'
|
||||
)
|
||||
expect(mux.request).toHaveBeenCalledTimes(1)
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', {
|
||||
oldPath: '/home/old.txt',
|
||||
newPath: '/home/new.txt'
|
||||
})
|
||||
})
|
||||
|
||||
it('copy sends fs.copy request', async () => {
|
||||
await provider.copy('/home/src.txt', '/home/dst.txt')
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.copy', {
|
||||
|
||||
@@ -214,6 +214,19 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
await this.mux.request('fs.rename', { oldPath, newPath })
|
||||
}
|
||||
|
||||
async renameNoClobber(oldPath: string, newPath: string): Promise<void> {
|
||||
try {
|
||||
await this.mux.request('fs.renameNoClobber', { oldPath, newPath })
|
||||
} catch (err) {
|
||||
if (isMethodNotFoundError(err)) {
|
||||
// Why: falling back to raw fs.rename can silently clobber the target on
|
||||
// older relays. Fail closed and let reconnect deploy the safe relay.
|
||||
throw new Error('Remote safe rename is unavailable. Reconnect the SSH target and retry.')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async copy(source: string, destination: string): Promise<void> {
|
||||
await this.mux.request('fs.copy', { source, destination })
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ export type IFilesystemProvider = {
|
||||
createDir(dirPath: string): Promise<void>
|
||||
createDirNoClobber(dirPath: string): Promise<void>
|
||||
rename(oldPath: string, newPath: string): Promise<void>
|
||||
renameNoClobber(oldPath: string, newPath: string): Promise<void>
|
||||
copy(source: string, destination: string): Promise<void>
|
||||
realpath(filePath: string): Promise<string>
|
||||
search(opts: SearchOptions): Promise<SearchResult>
|
||||
|
||||
@@ -56,6 +56,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
}))
|
||||
|
||||
import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
function enoent(): Error {
|
||||
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
@@ -67,7 +68,7 @@ function mockStats(dev: number, ino: number) {
|
||||
|
||||
function createRuntimeFileCommands() {
|
||||
const store = {
|
||||
getRepo: vi.fn(() => undefined)
|
||||
getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined)
|
||||
}
|
||||
const commands = new RuntimeFileCommands({
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
@@ -215,6 +216,30 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes runtime remote rename through the SSH no-clobber provider method', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')
|
||||
|
||||
expect(renameNoClobber).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts')
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates runtime remote no-clobber rename failures', async () => {
|
||||
const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists'))
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
|
||||
'destination exists'
|
||||
)
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses a conservative Node watcher for Windows runtime file watches', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
getSshFilesystemProvider,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { assertFileExplorerRenameDestinationAvailable } from '../file-explorer-rename-collision'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
|
||||
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
|
||||
const MOBILE_FILE_LIST_LIMIT = 5000
|
||||
@@ -543,14 +543,14 @@ export class RuntimeFileCommands {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
await provider.rename(oldTarget.path, newTarget.path)
|
||||
await provider.renameNoClobber(oldTarget.path, newTarget.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const store = this.host.requireStore()
|
||||
const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true })
|
||||
const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true })
|
||||
await assertFileExplorerRenameDestinationAvailable(oldPath, newPath)
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -2152,6 +2152,167 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const runtime = createRuntime()
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const destination = join(tempRoot, 'destination')
|
||||
|
||||
try {
|
||||
await expect(runtime.cloneRepo('file:///tmp/source/.', destination)).rejects.toThrow(
|
||||
'Invalid repository name derived from URL'
|
||||
)
|
||||
expect(spawnSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const runtime = createRuntime()
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const destination = join(tempRoot, 'destination')
|
||||
|
||||
try {
|
||||
await expect(runtime.cloneRepo('file:///tmp/source/..', destination)).rejects.toThrow(
|
||||
'Invalid repository name derived from URL'
|
||||
)
|
||||
expect(spawnSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('removes an owned runtime clone target when git exits unsuccessfully', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
const runtime = createRuntime()
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const clonePath = join(destination, 'repo-badge-color')
|
||||
|
||||
try {
|
||||
const clonePromise = runtime.cloneRepo(
|
||||
'https://example.com/repo-badge-color.git',
|
||||
destination
|
||||
)
|
||||
await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1))
|
||||
await writeFile(join(clonePath, 'partial.txt'), 'git wrote this before failing')
|
||||
proc.stderr.emit('data', Buffer.from('fatal: repository not found\n'))
|
||||
proc.emit('close', 128, null)
|
||||
|
||||
await expect(clonePromise).rejects.toThrow('Clone failed: fatal: repository not found')
|
||||
await expect(lstat(clonePath)).rejects.toThrow()
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an existing runtime clone target when git exits unsuccessfully', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
const runtime = createRuntime()
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const clonePath = join(destination, 'repo-badge-color')
|
||||
|
||||
try {
|
||||
await mkdir(clonePath)
|
||||
await writeFile(join(clonePath, 'user-file.txt'), 'keep me')
|
||||
const clonePromise = runtime.cloneRepo(
|
||||
'https://example.com/repo-badge-color.git',
|
||||
destination
|
||||
)
|
||||
await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1))
|
||||
proc.emit('close', 128, null)
|
||||
|
||||
await expect(clonePromise).rejects.toThrow('Clone failed')
|
||||
await expect(lstat(join(clonePath, 'user-file.txt'))).resolves.toBeTruthy()
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips runtime clone failure cleanup when the owned target is replaced', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
const runtime = createRuntime()
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const clonePath = join(destination, 'repo-badge-color')
|
||||
const replacementFile = join(clonePath, 'replacement.txt')
|
||||
|
||||
try {
|
||||
const clonePromise = runtime.cloneRepo(
|
||||
'https://example.com/repo-badge-color.git',
|
||||
destination
|
||||
)
|
||||
await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1))
|
||||
await rm(clonePath, { recursive: true, force: true })
|
||||
await mkdir(clonePath)
|
||||
await writeFile(replacementFile, 'new owner')
|
||||
proc.emit('close', 128, null)
|
||||
|
||||
await expect(clonePromise).rejects.toThrow('Clone failed')
|
||||
await expect(lstat(replacementFile)).resolves.toBeTruthy()
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes concurrent runtime clones for the same target', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
firstProc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValueOnce(firstProc as never)
|
||||
const added: Record<string, unknown>[] = []
|
||||
const colorStore = {
|
||||
...store,
|
||||
getRepos: () => [...added] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
added.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => added.find((repo) => repo.id === id) as never
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(colorStore as never)
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
|
||||
try {
|
||||
const firstClonePromise = runtime.cloneRepo(
|
||||
'https://example.com/repo-badge-color.git',
|
||||
destination
|
||||
)
|
||||
const secondClonePromise = runtime.cloneRepo(
|
||||
'https://example.com/repo-badge-color.git',
|
||||
destination
|
||||
)
|
||||
await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstProc.emit('close', 0, null)
|
||||
await expect(firstClonePromise).resolves.toMatchObject({
|
||||
path: join(destination, 'repo-badge-color')
|
||||
})
|
||||
await expect(secondClonePromise).resolves.toMatchObject({
|
||||
path: join(destination, 'repo-badge-color')
|
||||
})
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -9,9 +9,15 @@ import {
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import type { AgentStatusOrchestrationContext } from '../../shared/agent-status-types'
|
||||
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
|
||||
import {
|
||||
cleanupClaimedCloneTarget,
|
||||
claimCloneTarget,
|
||||
deriveValidatedClonePath,
|
||||
getClonePathComparisonKey
|
||||
} from '../git/repo-clone-path'
|
||||
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { basename, isAbsolute, join } from 'path'
|
||||
import { isAbsolute, join } from 'path'
|
||||
import { mkdir, readFile, readdir, rm, stat } from 'fs/promises'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { formatMessagesForInjection } from './orchestration/formatter'
|
||||
@@ -1029,6 +1035,7 @@ export class OrcaRuntimeService {
|
||||
private resolvedWorktreeCache: ResolvedWorktreeCache | null = null
|
||||
private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null
|
||||
private resolvedWorktreeGeneration = 0
|
||||
private cloneInFlightByPath = new Map<string, Promise<void>>()
|
||||
private agentDetector: AgentDetector | null = null
|
||||
private _orchestrationDb: OrchestrationDb | null = null
|
||||
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
|
||||
@@ -5460,30 +5467,92 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const trimmedUrl = url.trim()
|
||||
const trimmedDestination = destination.trim()
|
||||
const repoName = basename(trimmedUrl.replace(/\.git\/?$/, ''))
|
||||
if (!repoName) {
|
||||
throw new Error('Could not determine repository name from URL')
|
||||
}
|
||||
if (!trimmedDestination) {
|
||||
throw new Error('Clone destination is required')
|
||||
}
|
||||
if (!isAbsolute(trimmedDestination)) {
|
||||
throw new Error('Clone destination must be an absolute path')
|
||||
const clonePath = deriveValidatedClonePath({ url: trimmedUrl, destination: trimmedDestination })
|
||||
const clonePathKey = getClonePathComparisonKey(clonePath)
|
||||
const previous = this.cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const tail = previous.then(
|
||||
() => current,
|
||||
() => current
|
||||
)
|
||||
this.cloneInFlightByPath.set(clonePathKey, tail)
|
||||
|
||||
try {
|
||||
await previous
|
||||
return await this.cloneRepoAfterPathLock(
|
||||
trimmedUrl,
|
||||
trimmedDestination,
|
||||
clonePath,
|
||||
clonePathKey
|
||||
)
|
||||
} finally {
|
||||
release()
|
||||
if (this.cloneInFlightByPath.get(clonePathKey) === tail) {
|
||||
this.cloneInFlightByPath.delete(clonePathKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cloneRepoAfterPathLock(
|
||||
trimmedUrl: string,
|
||||
trimmedDestination: string,
|
||||
clonePath: string,
|
||||
clonePathKey: string
|
||||
): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const existingBeforeClone = this.store
|
||||
.getRepos()
|
||||
.find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey)
|
||||
if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) {
|
||||
return existingBeforeClone
|
||||
}
|
||||
|
||||
await mkdir(trimmedDestination, { recursive: true })
|
||||
const clonePath = join(trimmedDestination, repoName)
|
||||
const claimedTarget = await claimCloneTarget(clonePath)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], {
|
||||
cwd: trimmedDestination,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
let proc: ReturnType<typeof wslAwareSpawn>
|
||||
try {
|
||||
proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], {
|
||||
cwd: trimmedDestination,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
} catch (err) {
|
||||
void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
reject(new Error(`Clone failed: ${message}`))
|
||||
})
|
||||
return
|
||||
}
|
||||
let stderrTail = ''
|
||||
let settled = false
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-4096)
|
||||
})
|
||||
proc.on('error', (error) => reject(new Error(`Clone failed: ${error.message}`)))
|
||||
proc.on('close', (code, signal) => {
|
||||
if (signal === 'SIGTERM') {
|
||||
const finishClone = async (
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
error?: Error
|
||||
) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
const cloneSucceeded = !error && code === 0 && !signal
|
||||
if (!cloneSucceeded) {
|
||||
await cleanupClaimedCloneTarget(clonePath, claimedTarget)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
reject(new Error(`Clone failed: ${error.message}`))
|
||||
} else if (signal === 'SIGTERM') {
|
||||
reject(new Error('Clone aborted'))
|
||||
} else if (code === 0) {
|
||||
resolve()
|
||||
@@ -5491,10 +5560,18 @@ export class OrcaRuntimeService {
|
||||
const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error'
|
||||
reject(new Error(`Clone failed: ${lastLine}`))
|
||||
}
|
||||
}
|
||||
proc.on('error', (error) => {
|
||||
void finishClone(null, null, error)
|
||||
})
|
||||
proc.on('close', (code, signal) => {
|
||||
void finishClone(code, signal)
|
||||
})
|
||||
})
|
||||
|
||||
const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, clonePath))
|
||||
const existing = this.store
|
||||
.getRepos()
|
||||
.find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey)
|
||||
if (existing) {
|
||||
if (isFolderRepo(existing)) {
|
||||
const updated = this.store.updateRepo(existing.id, { kind: 'git' })
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// `pnpm run build:cli`. The verification gate explicitly builds the CLI
|
||||
// before running this file.
|
||||
import { spawn } from 'child_process'
|
||||
import { existsSync, mkdtempSync } from 'fs'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -31,6 +31,40 @@ const CLI_PATH = join(process.cwd(), 'out', 'cli', 'index.js')
|
||||
|
||||
const describeIfBuilt = existsSync(CLI_PATH) ? describe : describe.skip
|
||||
|
||||
async function runBuiltCli(
|
||||
userDataPath: string,
|
||||
args: string[],
|
||||
extraEnv: Record<string, string> = {}
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const child = spawn(process.execPath, [CLI_PATH, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_USER_DATA_PATH: userDataPath,
|
||||
ORCA_TERMINAL_HANDLE: 'term_cli',
|
||||
...extraEnv
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
const stdoutChunks: string[] = []
|
||||
const stderrChunks: string[] = []
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (d) => stdoutChunks.push(d))
|
||||
child.stderr.on('data', (d) => stderrChunks.push(d))
|
||||
|
||||
const exitCode = await new Promise<number>((resolveExit, rejectExit) => {
|
||||
child.once('exit', (code) => resolveExit(code ?? 1))
|
||||
child.once('error', rejectExit)
|
||||
})
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stdout: stdoutChunks.join(''),
|
||||
stderr: stderrChunks.join('')
|
||||
}
|
||||
}
|
||||
|
||||
describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => {
|
||||
it('emits newline-flushed JSON heartbeats to stderr while waiting', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-sub-'))
|
||||
@@ -156,3 +190,88 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => {
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describeIfBuilt('orca orchestration reset subprocess', () => {
|
||||
it('validates reset scopes against an isolated runtime through the built CLI', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-reset-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
runtime.setOrchestrationDb(db)
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
const send = await runBuiltCli(userDataPath, [
|
||||
'orchestration',
|
||||
'send',
|
||||
'--to',
|
||||
'term_target',
|
||||
'--subject',
|
||||
'hello',
|
||||
'--json'
|
||||
])
|
||||
expect(send.exitCode, send.stderr).toBe(0)
|
||||
|
||||
const create = await runBuiltCli(userDataPath, [
|
||||
'orchestration',
|
||||
'task-create',
|
||||
'--spec',
|
||||
'throwaway task',
|
||||
'--json'
|
||||
])
|
||||
expect(create.exitCode, create.stderr).toBe(0)
|
||||
expect(db.getInbox()).toHaveLength(1)
|
||||
expect(db.listTasks()).toHaveLength(1)
|
||||
|
||||
const invalid = await runBuiltCli(userDataPath, [
|
||||
'orchestration',
|
||||
'reset',
|
||||
'--tasks',
|
||||
'--messages',
|
||||
'--json'
|
||||
])
|
||||
expect(invalid.exitCode).toBe(1)
|
||||
const invalidPayload = JSON.parse(invalid.stdout) as {
|
||||
ok: boolean
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
expect(invalidPayload.ok).toBe(false)
|
||||
expect(invalidPayload.error.code).toBe('invalid_argument')
|
||||
expect(invalidPayload.error.message).toContain('Choose exactly one reset scope')
|
||||
expect(db.getInbox()).toHaveLength(1)
|
||||
expect(db.listTasks()).toHaveLength(1)
|
||||
|
||||
const resetTasks = await runBuiltCli(userDataPath, [
|
||||
'orchestration',
|
||||
'reset',
|
||||
'--tasks',
|
||||
'--json'
|
||||
])
|
||||
expect(resetTasks.exitCode, resetTasks.stderr).toBe(0)
|
||||
expect(JSON.parse(resetTasks.stdout)).toMatchObject({ ok: true, result: { reset: 'tasks' } })
|
||||
expect(db.getInbox()).toHaveLength(1)
|
||||
expect(db.listTasks()).toHaveLength(0)
|
||||
|
||||
const recreate = await runBuiltCli(userDataPath, [
|
||||
'orchestration',
|
||||
'task-create',
|
||||
'--spec',
|
||||
'throwaway task after partial reset',
|
||||
'--json'
|
||||
])
|
||||
expect(recreate.exitCode, recreate.stderr).toBe(0)
|
||||
expect(db.getInbox()).toHaveLength(1)
|
||||
expect(db.listTasks()).toHaveLength(1)
|
||||
|
||||
const resetAll = await runBuiltCli(userDataPath, ['orchestration', 'reset', '--json'])
|
||||
expect(resetAll.exitCode, resetAll.stderr).toBe(0)
|
||||
expect(JSON.parse(resetAll.stdout)).toMatchObject({ ok: true, result: { reset: 'all' } })
|
||||
expect(db.getInbox()).toHaveLength(0)
|
||||
expect(db.listTasks()).toHaveLength(0)
|
||||
} finally {
|
||||
db.close()
|
||||
await server.stop()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -330,6 +330,146 @@ describe('file RPC methods', () => {
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing content', { worktree: 'id:wt-1', relativePath: 'src/index.ts' }],
|
||||
['null content', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: null }],
|
||||
['non-string content', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: 0 }]
|
||||
])('rejects a write with %s instead of truncating the file', async (_name, params) => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('files.write', params))
|
||||
|
||||
expect(response).toMatchObject({ ok: false })
|
||||
expect(runtime.writeFileExplorerFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still allows writing an explicit empty string (empty file)', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.write', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: '' })
|
||||
)
|
||||
|
||||
expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith('id:wt-1', 'src/index.ts', '')
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('allows writing explicit empty base64 content', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.writeBase64', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'assets/logo.png',
|
||||
contentBase64: ''
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.writeFileExplorerFileBase64).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
'assets/logo.png',
|
||||
''
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing content', { worktree: 'id:wt-1', relativePath: 'assets/logo.png' }],
|
||||
['null content', { worktree: 'id:wt-1', relativePath: 'assets/logo.png', contentBase64: null }],
|
||||
[
|
||||
'non-string content',
|
||||
{ worktree: 'id:wt-1', relativePath: 'assets/logo.png', contentBase64: 0 }
|
||||
]
|
||||
])('rejects a base64 write with %s', async (_name, params) => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('files.writeBase64', params))
|
||||
|
||||
expect(response).toMatchObject({ ok: false })
|
||||
expect(runtime.writeFileExplorerFileBase64).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows writing an explicit empty base64 chunk', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.writeBase64Chunk', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'assets/video.mov',
|
||||
contentBase64: '',
|
||||
append: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.writeFileExplorerFileBase64Chunk).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
'assets/video.mov',
|
||||
'',
|
||||
true
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'missing content',
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'assets/video.mov',
|
||||
append: true
|
||||
}
|
||||
],
|
||||
[
|
||||
'null content',
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'assets/video.mov',
|
||||
contentBase64: null,
|
||||
append: true
|
||||
}
|
||||
],
|
||||
[
|
||||
'non-string content',
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'assets/video.mov',
|
||||
contentBase64: 0,
|
||||
append: true
|
||||
}
|
||||
]
|
||||
])('rejects a base64 chunk write with %s (inherits the schema)', async (_name, params) => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('files.writeBase64Chunk', params))
|
||||
|
||||
expect(response).toMatchObject({ ok: false })
|
||||
expect(runtime.writeFileExplorerFileBase64Chunk).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits staged runtime uploads without clobbering the final destination', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -30,18 +30,19 @@ const FileTreePath = WorktreeSelector.extend({
|
||||
.pipe(z.string())
|
||||
})
|
||||
|
||||
// Why: write content must be a real string. Coercing a missing/non-string value
|
||||
// to '' silently truncated the target file to empty instead of erroring. An
|
||||
// explicit '' is still accepted (writing an empty file is legitimate).
|
||||
const FileWrite = FileOpen.extend({
|
||||
content: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string())
|
||||
.refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
|
||||
})
|
||||
|
||||
const FileWriteBase64 = FileOpen.extend({
|
||||
contentBase64: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string())
|
||||
.refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
|
||||
})
|
||||
|
||||
const FileWriteBase64Chunk = FileWriteBase64.extend({
|
||||
|
||||
@@ -1022,10 +1022,14 @@ describe('orchestration RPC methods', () => {
|
||||
})
|
||||
|
||||
describe('orchestration.reset', () => {
|
||||
it('resets all state', async () => {
|
||||
setup()
|
||||
function seedResetState(): void {
|
||||
db.insertMessage({ from: 'a', to: 'b', subject: 'test' })
|
||||
db.createTask({ spec: 'work' })
|
||||
}
|
||||
|
||||
it('resets all state', async () => {
|
||||
setup()
|
||||
seedResetState()
|
||||
|
||||
const result = (await call('orchestration.reset', { all: true })) as { reset: string }
|
||||
expect(result.reset).toBe('all')
|
||||
@@ -1035,8 +1039,7 @@ describe('orchestration RPC methods', () => {
|
||||
|
||||
it('resets tasks only', async () => {
|
||||
setup()
|
||||
db.insertMessage({ from: 'a', to: 'b', subject: 'test' })
|
||||
db.createTask({ spec: 'work' })
|
||||
seedResetState()
|
||||
|
||||
await call('orchestration.reset', { tasks: true })
|
||||
expect(db.getInbox()).toHaveLength(1)
|
||||
@@ -1045,12 +1048,52 @@ describe('orchestration RPC methods', () => {
|
||||
|
||||
it('resets messages only', async () => {
|
||||
setup()
|
||||
db.insertMessage({ from: 'a', to: 'b', subject: 'test' })
|
||||
db.createTask({ spec: 'work' })
|
||||
seedResetState()
|
||||
|
||||
await call('orchestration.reset', { messages: true })
|
||||
expect(db.getInbox()).toHaveLength(0)
|
||||
expect(db.listTasks()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty params', {}],
|
||||
['false-only params', { all: false }],
|
||||
['multi-scope task and messages params', { tasks: true, messages: true }],
|
||||
['multi-scope all and tasks params', { all: true, tasks: true }],
|
||||
['non-boolean params', { all: 'true' }]
|
||||
])('rejects %s without mutating state', async (_name, params) => {
|
||||
setup()
|
||||
seedResetState()
|
||||
|
||||
await expect(call('orchestration.reset', params)).rejects.toThrow()
|
||||
expect(db.getInbox()).toHaveLength(1)
|
||||
expect(db.listTasks()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores false scopes when exactly one scope is true', async () => {
|
||||
setup()
|
||||
seedResetState()
|
||||
|
||||
const result = (await call('orchestration.reset', { all: false, tasks: true })) as {
|
||||
reset: string
|
||||
}
|
||||
|
||||
expect(result.reset).toBe('tasks')
|
||||
expect(db.getInbox()).toHaveLength(1)
|
||||
expect(db.listTasks()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores non-boolean scopes when exactly one real boolean scope is true', async () => {
|
||||
setup()
|
||||
seedResetState()
|
||||
|
||||
const result = (await call('orchestration.reset', { all: 'true', messages: true })) as {
|
||||
reset: string
|
||||
}
|
||||
|
||||
expect(result.reset).toBe('messages')
|
||||
expect(db.getInbox()).toHaveLength(0)
|
||||
expect(db.listTasks()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -136,11 +136,23 @@ const AskParams = z.object({
|
||||
from: OptionalString
|
||||
})
|
||||
|
||||
const ResetParams = z.object({
|
||||
all: OptionalBoolean,
|
||||
tasks: OptionalBoolean,
|
||||
messages: OptionalBoolean
|
||||
})
|
||||
const ResetParams = z
|
||||
.object({
|
||||
all: OptionalBoolean,
|
||||
tasks: OptionalBoolean,
|
||||
messages: OptionalBoolean
|
||||
})
|
||||
.superRefine((params, ctx) => {
|
||||
const selectedScopeCount = [params.all, params.tasks, params.messages].filter(
|
||||
(scope) => scope === true
|
||||
).length
|
||||
if (selectedScopeCount !== 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Choose exactly one reset scope: --all, --tasks, or --messages.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -582,8 +594,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
db.resetMessages()
|
||||
return { reset: 'messages' }
|
||||
}
|
||||
db.resetAll()
|
||||
return { reset: 'all' }
|
||||
throw new Error('Invalid reset scope')
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -18,6 +18,13 @@ export class SshConnectionManager {
|
||||
this.callbacks = callbacks
|
||||
}
|
||||
|
||||
setCallbacks(callbacks: SshConnectionCallbacks): void {
|
||||
this.callbacks = callbacks
|
||||
for (const connection of this.connections.values()) {
|
||||
connection.setCallbacks(callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
async connect(target: SshTarget): Promise<SshConnection> {
|
||||
const existing = this.connections.get(target.id)
|
||||
if (existing?.getState().status === 'connected') {
|
||||
|
||||
@@ -70,6 +70,10 @@ export class SshConnection {
|
||||
return { ...this.target }
|
||||
}
|
||||
|
||||
setCallbacks(callbacks: SshConnectionCallbacks): void {
|
||||
this.callbacks = callbacks
|
||||
}
|
||||
|
||||
// Why: exposes whether a passphrase/password is already cached in-memory for
|
||||
// this connection. Used by ssh:needsPassphrasePrompt so callers can decide
|
||||
// whether a manual-reconnect will prompt or go through silently. Without this,
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function readFileViaStream(
|
||||
let expectedSeq = 0
|
||||
let receivedChunks = 0
|
||||
let totalChunks = 0
|
||||
let bytesReceived = 0
|
||||
let settled = false
|
||||
|
||||
// Why: chunk/end/error frames may arrive in the same dispatch tick as the
|
||||
@@ -128,10 +129,13 @@ export async function readFileViaStream(
|
||||
}
|
||||
const offset = seq * STREAM_CHUNK_SIZE
|
||||
const decoded = Buffer.from(data, 'base64')
|
||||
if (offset + decoded.length > totalSize) {
|
||||
// Why: a short chunk would leave the pre-allocated buffer zero-filled and
|
||||
// resolve as silently-corrupt data; validate each chunk's exact length.
|
||||
const expectedLength = Math.min(STREAM_CHUNK_SIZE, totalSize - offset)
|
||||
if (decoded.length !== expectedLength) {
|
||||
fail(
|
||||
new StreamProtocolError(
|
||||
`Chunk overflows declared totalSize: offset=${offset} len=${decoded.length} total=${totalSize}`
|
||||
`Chunk length mismatch for stream ${id}: seq=${seq} expected=${expectedLength} got=${decoded.length}`
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -143,6 +147,7 @@ export async function readFileViaStream(
|
||||
decoded.copy(buffer, offset)
|
||||
expectedSeq += 1
|
||||
receivedChunks += 1
|
||||
bytesReceived += decoded.length
|
||||
}
|
||||
|
||||
const handleEnd = (params: Record<string, unknown>): void => {
|
||||
@@ -161,6 +166,16 @@ export async function readFileViaStream(
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: redundant given the per-chunk length + count checks, but kept as a
|
||||
// last-line invariant guard; never resolve with fewer bytes than declared.
|
||||
if (bytesReceived !== totalSize) {
|
||||
fail(
|
||||
new StreamProtocolError(
|
||||
`Byte count mismatch for stream ${id}: expected ${totalSize}, received ${bytesReceived}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!buffer) {
|
||||
fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`))
|
||||
return
|
||||
|
||||
@@ -110,6 +110,20 @@ export class SshRelaySession {
|
||||
) => void
|
||||
) {}
|
||||
|
||||
refreshEnvironment(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
store: Store,
|
||||
portForwardManager: SshPortForwardManager,
|
||||
runtime?: OrcaRuntimeService,
|
||||
onDetectedPortsChanged?: (targetId: string, ports: DetectedPort[], platform: string) => void
|
||||
): void {
|
||||
this.getMainWindow = getMainWindow
|
||||
this.store = store
|
||||
this.portForwardManager = portForwardManager
|
||||
this.runtime = runtime
|
||||
this.onDetectedPortsChanged = onDetectedPortsChanged
|
||||
}
|
||||
|
||||
setOnRelayLost(cb: (targetId: string) => void): void {
|
||||
this._onRelayLost = cb
|
||||
}
|
||||
@@ -783,10 +797,9 @@ export class SshRelaySession {
|
||||
}
|
||||
|
||||
private wireUpPtyEvents(ptyProvider: SshPtyProvider): void {
|
||||
const getWin = this.getMainWindow
|
||||
ptyProvider.onData((payload) => {
|
||||
const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now())
|
||||
const win = getWin()
|
||||
const win = this.getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('pty:data', {
|
||||
...payload,
|
||||
@@ -795,7 +808,7 @@ export class SshRelaySession {
|
||||
}
|
||||
})
|
||||
ptyProvider.onReplay((payload) => {
|
||||
const win = getWin()
|
||||
const win = this.getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('pty:replay', payload)
|
||||
}
|
||||
@@ -806,7 +819,7 @@ export class SshRelaySession {
|
||||
deletePtyOwnership(payload.id)
|
||||
this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated')
|
||||
this.runtime?.onPtyExit(payload.id, payload.code)
|
||||
const win = getWin()
|
||||
const win = this.getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('pty:exit', payload)
|
||||
}
|
||||
|
||||
@@ -582,6 +582,9 @@ export type AppApi = {
|
||||
* by settings panes that need a full restart to apply changes (e.g. the
|
||||
* terminal-window blur setting in TerminalWindowSection). */
|
||||
relaunch: () => Promise<void>
|
||||
/** Restarts Orca through the normal quit pipeline so daemon-backed terminal
|
||||
* sessions survive and can reattach after the new process starts. */
|
||||
restart: () => Promise<void>
|
||||
/** Reloads the current app renderer through main so expected renderer
|
||||
* teardown can be classified before Electron emits process-gone events. */
|
||||
reload: () => Promise<void>
|
||||
|
||||
+82
-47
@@ -140,6 +140,8 @@ import {
|
||||
type EditorSaveDirtyFilesDetail
|
||||
} from '../shared/editor-save-events'
|
||||
import {
|
||||
ORCA_APP_RESTART_ABORTED_EVENT,
|
||||
ORCA_APP_RESTART_STARTED_EVENT,
|
||||
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
|
||||
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT
|
||||
} from '../shared/updater-renderer-events'
|
||||
@@ -169,6 +171,62 @@ type NativeFileDropCallback = (data: NativeFileDropPayload) => void
|
||||
const nativeFileDropCallbacks: NativeFileDropCallback[] = []
|
||||
let nativeFileDropListenerRegistered = false
|
||||
|
||||
type AppRestartPrepOptions = {
|
||||
startedEventName: string
|
||||
abortedEventName: string
|
||||
continueOnSaveFailure: boolean
|
||||
saveFailureLogPrefix: string
|
||||
}
|
||||
|
||||
function requestDirtyEditorFileSave(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let claimed = false
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<EditorSaveDirtyFilesDetail>(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, {
|
||||
detail: {
|
||||
claim: () => {
|
||||
claimed = true
|
||||
},
|
||||
resolve,
|
||||
reject: (message) => {
|
||||
reject(new Error(message))
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Why: restart paths can run when no editor surface is mounted. When
|
||||
// nothing claims the request there are no in-memory editor buffers to
|
||||
// flush, so proceed with the normal shutdown path immediately.
|
||||
if (!claimed) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function prepareRendererForAppRestart({
|
||||
startedEventName,
|
||||
abortedEventName,
|
||||
continueOnSaveFailure,
|
||||
saveFailureLogPrefix
|
||||
}: AppRestartPrepOptions): Promise<void> {
|
||||
window.dispatchEvent(new Event(startedEventName))
|
||||
|
||||
try {
|
||||
await requestDirtyEditorFileSave()
|
||||
} catch (error) {
|
||||
if (!continueOnSaveFailure) {
|
||||
window.dispatchEvent(new Event(abortedEventName))
|
||||
throw error
|
||||
}
|
||||
console.warn(saveFailureLogPrefix, error)
|
||||
}
|
||||
|
||||
// Dispatch beforeunload now so terminal buffers are captured while panes are
|
||||
// still mounted; update installs later bypass the ordinary close sequence.
|
||||
window.dispatchEvent(new Event('beforeunload'))
|
||||
}
|
||||
|
||||
const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => {
|
||||
for (const callback of Array.from(nativeFileDropCallbacks)) {
|
||||
callback(data)
|
||||
@@ -358,6 +416,20 @@ const api = {
|
||||
getFeatureWallAssetBaseUrl: (): Promise<string> =>
|
||||
ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'),
|
||||
relaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
|
||||
restart: async (): Promise<void> => {
|
||||
await prepareRendererForAppRestart({
|
||||
startedEventName: ORCA_APP_RESTART_STARTED_EVENT,
|
||||
abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT,
|
||||
continueOnSaveFailure: false,
|
||||
saveFailureLogPrefix: '[app-restart] Saving dirty files before restart failed:'
|
||||
})
|
||||
try {
|
||||
return await ipcRenderer.invoke('app:restart')
|
||||
} catch (error) {
|
||||
window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT))
|
||||
throw error
|
||||
}
|
||||
},
|
||||
reload: (): Promise<void> => ipcRenderer.invoke('app:reload'),
|
||||
// Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so
|
||||
// the renderer's keyboard-layout probe can distinguish Polish Pro / US
|
||||
@@ -1933,53 +2005,16 @@ const api = {
|
||||
download: (): Promise<void> => ipcRenderer.invoke('updater:download'),
|
||||
dismissNudge: (): Promise<void> => ipcRenderer.invoke('updater:dismissNudge'),
|
||||
quitAndInstall: async (): Promise<void> => {
|
||||
// Why: quitAndInstall closes the BrowserWindow directly from the main
|
||||
// process. Renderer beforeunload guards treat that like a normal window
|
||||
// close unless we mark the updater path explicitly, and #300 introduced
|
||||
// longer-lived editor dirty/autosave state that can otherwise veto the
|
||||
// restart even after the update payload has been downloaded.
|
||||
window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT))
|
||||
|
||||
// Why: we wrap the save attempt in try/catch so that a save failure
|
||||
// (e.g., unsupported dirty files or a write error) never silently
|
||||
// prevents the update from installing. The user already clicked
|
||||
// "install update" — proceeding with the restart is better than
|
||||
// leaving them stuck with no feedback.
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let claimed = false
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<EditorSaveDirtyFilesDetail>(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, {
|
||||
detail: {
|
||||
claim: () => {
|
||||
claimed = true
|
||||
},
|
||||
resolve,
|
||||
reject: (message) => {
|
||||
reject(new Error(message))
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Why: updater installs can run when no editor surface is mounted.
|
||||
// When nothing claims the request there are no in-memory editor buffers
|
||||
// to flush, so proceed with the normal shutdown path immediately.
|
||||
if (!claimed) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[updater] Saving dirty files before quit failed; proceeding with install anyway:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
// Dispatch beforeunload to trigger terminal buffer capture before the
|
||||
// update process bypasses the normal window close sequence (quitAndInstall
|
||||
// removes close listeners, preventing beforeunload from firing naturally).
|
||||
window.dispatchEvent(new Event('beforeunload'))
|
||||
// Why: update installs must proceed even when a dirty-file auto-save
|
||||
// fails; otherwise a downloaded update can get stuck behind hidden editor
|
||||
// state. Manual app restart uses the same prep but aborts on save failure.
|
||||
await prepareRendererForAppRestart({
|
||||
startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT,
|
||||
abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
|
||||
continueOnSaveFailure: true,
|
||||
saveFailureLogPrefix:
|
||||
'[updater] Saving dirty files before quit failed; proceeding with install anyway:'
|
||||
})
|
||||
try {
|
||||
return await ipcRenderer.invoke('updater:quitAndInstall')
|
||||
} catch (error) {
|
||||
|
||||
@@ -52,6 +52,15 @@ export type StreamMetadata = {
|
||||
empty?: boolean
|
||||
}
|
||||
|
||||
type StreamChunkReader = {
|
||||
read(
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number
|
||||
): Promise<{ bytesRead: number }>
|
||||
}
|
||||
|
||||
export async function readRelayFileStreamMetadata(
|
||||
filePath: string,
|
||||
dispatcher: RelayDispatcher,
|
||||
@@ -142,11 +151,11 @@ async function pumpChunks(
|
||||
break
|
||||
}
|
||||
const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset)
|
||||
const { bytesRead } = await entry.handle.read(buffer, 0, want, offset)
|
||||
if (bytesRead === 0) {
|
||||
const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset)
|
||||
if (bytesRead !== want) {
|
||||
endReason = 'error'
|
||||
errorCode = 'ESTREAMTRUNCATED'
|
||||
errorMessage = `File truncated mid-stream: expected ${totalSize}, got ${offset}`
|
||||
errorMessage = `File truncated mid-stream: expected ${totalSize}, got ${offset + bytesRead}`
|
||||
break
|
||||
}
|
||||
if (context.isStale()) {
|
||||
@@ -202,6 +211,30 @@ async function pumpChunks(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: fs.read() may return fewer bytes than requested before EOF. Fill each
|
||||
// protocol chunk so strict clients reject corruption, not valid short reads.
|
||||
async function readFullStreamChunk(
|
||||
handle: StreamChunkReader,
|
||||
buffer: Buffer,
|
||||
length: number,
|
||||
offset: number
|
||||
): Promise<number> {
|
||||
let totalRead = 0
|
||||
while (totalRead < length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
totalRead,
|
||||
length - totalRead,
|
||||
offset + totalRead
|
||||
)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
totalRead += bytesRead
|
||||
}
|
||||
return totalRead
|
||||
}
|
||||
|
||||
export function isTooManyStreamsError(err: unknown): err is TooManyStreamsError {
|
||||
return err instanceof Error && (err as { code?: number }).code === RelayErrorCode.TooManyStreams
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { FsHandler } from './fs-handler'
|
||||
import { RelayContext } from './context'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
import { STREAM_CHUNK_SIZE } from './protocol'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { mkdtempSync, writeFileSync } from 'fs'
|
||||
@@ -134,6 +135,50 @@ describe('FsHandler readFileStream', () => {
|
||||
expect(reassembled.equals(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('fills protocol chunks when fs.read returns short before EOF', async () => {
|
||||
const filePath = path.join(tmpDir, 'short-read.png')
|
||||
const content = Buffer.alloc(STREAM_CHUNK_SIZE + 17, 0x42)
|
||||
writeFileSync(filePath, content)
|
||||
|
||||
const sampleHandle = await fs.open(filePath, 'r')
|
||||
const fileHandlePrototype = Object.getPrototypeOf(sampleHandle) as {
|
||||
read: typeof sampleHandle.read
|
||||
}
|
||||
await sampleHandle.close()
|
||||
|
||||
type PositionedRead = (
|
||||
this: typeof sampleHandle,
|
||||
buffer: Buffer,
|
||||
offset: number | null,
|
||||
length: number | null,
|
||||
position: number | null
|
||||
) => Promise<{ bytesRead: number; buffer: Buffer }>
|
||||
const originalRead = fileHandlePrototype.read as unknown as PositionedRead
|
||||
const readSpy = vi.spyOn(fileHandlePrototype, 'read').mockImplementation(async function (
|
||||
this: typeof sampleHandle,
|
||||
buffer: Buffer,
|
||||
offset: number | null = 0,
|
||||
length: number | null = buffer.byteLength,
|
||||
position: number | null = null
|
||||
) {
|
||||
const readLength = position === 0 && length !== null ? Math.min(length, 5) : length
|
||||
return originalRead.call(this, buffer, offset, readLength, position)
|
||||
} as typeof sampleHandle.read)
|
||||
try {
|
||||
await dispatcher.callRequest('fs.readFileStream', { filePath }, { isStale: () => false })
|
||||
await waitFor(() => collectStream(dispatcher).end !== null)
|
||||
} finally {
|
||||
readSpy.mockRestore()
|
||||
}
|
||||
|
||||
const { chunks, end, err } = collectStream(dispatcher)
|
||||
expect(err).toBeNull()
|
||||
expect(end).not.toBeNull()
|
||||
expect(chunks.map((c) => Buffer.from(c.data, 'base64').length)).toEqual([STREAM_CHUNK_SIZE, 17])
|
||||
const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64')))
|
||||
expect(reassembled.equals(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty:true for 0-byte files without opening a handle', async () => {
|
||||
const filePath = path.join(tmpDir, 'empty.txt')
|
||||
writeFileSync(filePath, '')
|
||||
|
||||
@@ -132,6 +132,7 @@ describe('FsHandler', () => {
|
||||
expect(methods).toContain('fs.createDir')
|
||||
expect(methods).toContain('fs.createDirNoClobber')
|
||||
expect(methods).toContain('fs.rename')
|
||||
expect(methods).toContain('fs.renameNoClobber')
|
||||
expect(methods).toContain('fs.copy')
|
||||
expect(methods).toContain('fs.realpath')
|
||||
expect(methods).toContain('fs.search')
|
||||
@@ -362,6 +363,43 @@ describe('FsHandler', () => {
|
||||
expect(content).toBe('content')
|
||||
})
|
||||
|
||||
it('rename preserves raw fs.rename overwrite semantics', async () => {
|
||||
const oldPath = path.join(tmpDir, 'old.txt')
|
||||
const newPath = path.join(tmpDir, 'existing.txt')
|
||||
writeFileSync(oldPath, 'new')
|
||||
writeFileSync(newPath, 'keep')
|
||||
|
||||
await dispatcher.callRequest('fs.rename', { oldPath, newPath })
|
||||
|
||||
expect(await fs.readFile(newPath, 'utf-8')).toBe('new')
|
||||
await expect(fs.access(oldPath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('renameNoClobber moves files when destination is available', async () => {
|
||||
const oldPath = path.join(tmpDir, 'old.txt')
|
||||
const newPath = path.join(tmpDir, 'new.txt')
|
||||
writeFileSync(oldPath, 'content')
|
||||
|
||||
await dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath })
|
||||
|
||||
await expect(fs.access(oldPath)).rejects.toThrow()
|
||||
expect(await fs.readFile(newPath, 'utf-8')).toBe('content')
|
||||
})
|
||||
|
||||
it('renameNoClobber does not overwrite an existing destination', async () => {
|
||||
const oldPath = path.join(tmpDir, 'old.txt')
|
||||
const newPath = path.join(tmpDir, 'existing.txt')
|
||||
writeFileSync(oldPath, 'new')
|
||||
writeFileSync(newPath, 'keep')
|
||||
|
||||
await expect(
|
||||
dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath })
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(await fs.readFile(newPath, 'utf-8')).toBe('keep')
|
||||
expect(await fs.readFile(oldPath, 'utf-8')).toBe('new')
|
||||
})
|
||||
|
||||
it('copy duplicates files', async () => {
|
||||
const src = path.join(tmpDir, 'src.txt')
|
||||
const dst = path.join(tmpDir, 'dst.txt')
|
||||
|
||||
@@ -23,6 +23,7 @@ import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-
|
||||
import { RelayStreamRegistry } from './fs-stream-registry'
|
||||
import { scanWorkspaceSpaceDirectory } from './workspace-space-scan'
|
||||
import { buildRelayCommandEnv } from './relay-command-env'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
|
||||
|
||||
type WatchState = {
|
||||
rootPath: string
|
||||
@@ -84,6 +85,7 @@ export class FsHandler {
|
||||
this.dispatcher.onRequest('fs.createDir', (p) => this.createDir(p))
|
||||
this.dispatcher.onRequest('fs.createDirNoClobber', (p) => this.createDirNoClobber(p))
|
||||
this.dispatcher.onRequest('fs.rename', (p) => this.rename(p))
|
||||
this.dispatcher.onRequest('fs.renameNoClobber', (p) => this.renameNoClobber(p))
|
||||
this.dispatcher.onRequest('fs.copy', (p) => this.copy(p))
|
||||
this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p))
|
||||
this.dispatcher.onRequest('fs.search', (p) => this.search(p))
|
||||
@@ -208,6 +210,15 @@ export class FsHandler {
|
||||
await rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
private async renameNoClobber(params: Record<string, unknown>) {
|
||||
const oldPath = expandTilde(params.oldPath as string)
|
||||
const newPath = expandTilde(params.newPath as string)
|
||||
// Why: user-facing file renames must not inherit fs.rename's overwrite
|
||||
// behavior; keep the guard inside the relay so SSH checks the remote FS.
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
private async copy(params: Record<string, unknown>) {
|
||||
const source = expandTilde(params.source as string)
|
||||
const destination = expandTilde(params.destination as string)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GitHandler } from './git-handler'
|
||||
import { RelayContext } from './context'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { execFileSync } from 'child_process'
|
||||
import {
|
||||
@@ -453,6 +453,59 @@ describe('GitHandler', () => {
|
||||
})
|
||||
).rejects.toThrow('outside the worktree')
|
||||
})
|
||||
|
||||
it('rejects untracked child paths through symlinked parents', async () => {
|
||||
gitInit(tmpDir)
|
||||
gitCommit(tmpDir, 'initial')
|
||||
const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-'))
|
||||
const outsideFile = path.join(outsideDir, 'keep.txt')
|
||||
writeFileSync(outsideFile, 'outside')
|
||||
symlinkSync(
|
||||
outsideDir,
|
||||
path.join(tmpDir, 'link'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
dispatcher.callRequest('git.discard', {
|
||||
worktreePath: tmpDir,
|
||||
filePath: 'link/keep.txt'
|
||||
})
|
||||
).rejects.toThrow('outside the worktree')
|
||||
await expect(fs.access(outsideFile)).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => {
|
||||
gitInit(tmpDir)
|
||||
gitCommit(tmpDir, 'initial')
|
||||
const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-'))
|
||||
const outsideFile = path.join(outsideDir, 'keep.txt')
|
||||
const untrackedFile = path.join(tmpDir, 'new.txt')
|
||||
writeFileSync(outsideFile, 'outside')
|
||||
writeFileSync(untrackedFile, 'untracked')
|
||||
symlinkSync(
|
||||
outsideDir,
|
||||
path.join(tmpDir, 'link'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
dispatcher.callRequest('git.bulkDiscard', {
|
||||
worktreePath: tmpDir,
|
||||
filePaths: ['new.txt', 'link/keep.txt']
|
||||
})
|
||||
).rejects.toThrow('outside the worktree')
|
||||
await expect(fs.access(outsideFile)).resolves.toBeUndefined()
|
||||
await expect(fs.access(untrackedFile)).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('conflictOperation', () => {
|
||||
|
||||
+33
-15
@@ -2,7 +2,6 @@
|
||||
protocol surface so local and SSH git behavior stay in one dispatch table. */
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { rm } from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
import type { RelayContext } from './context'
|
||||
@@ -36,6 +35,10 @@ import {
|
||||
} from '../shared/git-effective-upstream'
|
||||
import { loadGitHistoryFromExecutor } from '../shared/git-history'
|
||||
import { buildRelayCommandEnv } from './relay-command-env'
|
||||
import {
|
||||
removeSafeUntrackedDiscardTarget,
|
||||
removeSafeUntrackedDiscardTargets
|
||||
} from '../shared/git-discard-path-safety'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const MAX_GIT_BUFFER = 10 * 1024 * 1024
|
||||
@@ -222,7 +225,7 @@ export class GitHandler {
|
||||
const worktreePath = params.worktreePath as string
|
||||
const filePath = params.filePath as string
|
||||
|
||||
const resolved = this.assertInWorktree(worktreePath, filePath)
|
||||
this.assertInWorktree(worktreePath, filePath)
|
||||
|
||||
let tracked = false
|
||||
try {
|
||||
@@ -232,9 +235,14 @@ export class GitHandler {
|
||||
// untracked
|
||||
}
|
||||
|
||||
await (tracked
|
||||
? this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath)
|
||||
: rm(resolved, { force: true, recursive: true }))
|
||||
if (tracked) {
|
||||
await this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath)
|
||||
return
|
||||
}
|
||||
|
||||
await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) =>
|
||||
this.cleanUntrackedPaths(worktreePath, [targetPath])
|
||||
)
|
||||
}
|
||||
|
||||
private async bulkDiscard(params: Record<string, unknown>) {
|
||||
@@ -261,19 +269,29 @@ export class GitHandler {
|
||||
const untrackedPaths = filePaths.filter(
|
||||
(filePath) => !this.isTrackedPathSpec(filePath, trackedPathSpecs)
|
||||
)
|
||||
|
||||
for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) {
|
||||
const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE)
|
||||
await this.git(['restore', '--worktree', '--source=HEAD', '--', ...chunk], worktreePath)
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
untrackedPaths.map((filePath) =>
|
||||
rm(path.resolve(worktreePath, filePath), { force: true, recursive: true })
|
||||
)
|
||||
await removeSafeUntrackedDiscardTargets(
|
||||
worktreePath,
|
||||
untrackedPaths,
|
||||
(targetPaths) => this.cleanUntrackedPaths(worktreePath, targetPaths),
|
||||
async () => {
|
||||
for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) {
|
||||
const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE)
|
||||
await this.git(['restore', '--worktree', '--source=HEAD', '--', ...chunk], worktreePath)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async cleanUntrackedPaths(worktreePath: string, filePaths: readonly string[]) {
|
||||
for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
|
||||
const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
|
||||
if (chunk.length > 0) {
|
||||
// Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents.
|
||||
await this.git(['clean', '-ffdx', '--', ...chunk], worktreePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async conflictOperation(params: Record<string, unknown>) {
|
||||
const worktreePath = params.worktreePath as string
|
||||
return detectConflictOperation(worktreePath)
|
||||
|
||||
@@ -155,6 +155,22 @@
|
||||
animation: pane-focus-rim-flash 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.pane[data-terminal-attention] .xterm-container {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pane[data-terminal-attention] .xterm-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 4px 4px 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1.5px color-mix(in srgb, var(--terminal-pane-locate) 64%, transparent),
|
||||
inset 0 0 0 3px color-mix(in srgb, var(--terminal-pane-locate) 18%, transparent);
|
||||
}
|
||||
|
||||
@keyframes pane-focus-rim-flash {
|
||||
0% {
|
||||
opacity: 1;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
MessageSquarePlus,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Send,
|
||||
UndoDot,
|
||||
@@ -98,6 +100,7 @@ import {
|
||||
type PRCommentGroup
|
||||
} from '@/lib/pr-comment-groups'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees } from '@/store/selectors'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
|
||||
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
@@ -108,6 +111,10 @@ import {
|
||||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import {
|
||||
findGithubPrWorkspaceAttachment,
|
||||
getGithubPrWorkspaceAttachmentLabel
|
||||
} from '@/lib/github-pr-workspace-attachment'
|
||||
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
|
||||
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
@@ -125,8 +132,7 @@ import type {
|
||||
PRCheckDetail,
|
||||
PRCheckRunDetails,
|
||||
PRComment,
|
||||
TuiAgent,
|
||||
Worktree
|
||||
TuiAgent
|
||||
} from '../../../shared/types'
|
||||
|
||||
// Why: the GH item dialog can be opened from any work-item list surface and
|
||||
@@ -3355,19 +3361,6 @@ function buildFixBrokenChecksPrompt(item: GitHubWorkItem, checks: PRCheckDetail[
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function findWorkspaceAttachedToPR(
|
||||
worktrees: Worktree[],
|
||||
repoId: string,
|
||||
prNumber: number
|
||||
): Worktree | null {
|
||||
return (
|
||||
worktrees.find(
|
||||
(worktree) =>
|
||||
worktree.repoId === repoId && worktree.linkedPR === prNumber && !worktree.isArchived
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function pickDefaultAgent(
|
||||
defaultAgent: TuiAgent | 'blank' | null | undefined,
|
||||
detectedAgents: TuiAgent[]
|
||||
@@ -3533,7 +3526,7 @@ function ChecksTab({
|
||||
try {
|
||||
const prompt = buildFixBrokenChecksPrompt(item, list)
|
||||
const store = useAppStore.getState()
|
||||
const attachedWorkspace = findWorkspaceAttachedToPR(
|
||||
const attachedWorkspace = findGithubPrWorkspaceAttachment(
|
||||
store.allWorktrees(),
|
||||
targetRepoId,
|
||||
item.number
|
||||
@@ -4955,6 +4948,17 @@ export default function PullRequestPage({
|
||||
const workItemLabels = workItem?.labels
|
||||
const workItemType = workItem?.type
|
||||
const effectiveRepoId = repoId ?? workItem?.repoId ?? null
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const attachedWorkspace = useMemo(
|
||||
() =>
|
||||
workItem?.type === 'pr'
|
||||
? findGithubPrWorkspaceAttachment(allWorktrees, effectiveRepoId, workItem.number)
|
||||
: null,
|
||||
[allWorktrees, effectiveRepoId, workItem]
|
||||
)
|
||||
const attachedWorkspaceLabel = attachedWorkspace
|
||||
? getGithubPrWorkspaceAttachmentLabel(attachedWorkspace)
|
||||
: null
|
||||
|
||||
// Why: the cache key has to include the issue source preference so a user
|
||||
// toggling between origin/upstream for the same issue number doesn't read
|
||||
@@ -4995,6 +4999,39 @@ export default function PullRequestPage({
|
||||
setTab(nextTab)
|
||||
}, [workItemId, workItemType, initialTab])
|
||||
|
||||
const handleUseWorkItem = useCallback((): void => {
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
const targetRepoId = effectiveRepoId
|
||||
onUse(
|
||||
targetRepoId && targetRepoId !== workItem.repoId
|
||||
? { ...workItem, repoId: targetRepoId }
|
||||
: workItem
|
||||
)
|
||||
}, [effectiveRepoId, onUse, workItem])
|
||||
|
||||
const handleOpenOrUsePR = useCallback((): void => {
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
const targetRepoId = effectiveRepoId
|
||||
const currentAttached = findGithubPrWorkspaceAttachment(
|
||||
useAppStore.getState().allWorktrees(),
|
||||
targetRepoId,
|
||||
workItem.number
|
||||
)
|
||||
if (!currentAttached) {
|
||||
handleUseWorkItem()
|
||||
return
|
||||
}
|
||||
|
||||
const result = activateAndRevealWorktree(currentAttached.id)
|
||||
if (result === false) {
|
||||
toast.error('Unable to open the workspace attached to this pull request.')
|
||||
}
|
||||
}, [effectiveRepoId, handleUseWorkItem, workItem])
|
||||
|
||||
// Why: track comments added optimistically before the detail fetch resolves
|
||||
// so they can be merged into the fetch result instead of being overwritten.
|
||||
const optimisticCommentsRef = useRef<PRComment[]>([])
|
||||
@@ -5413,16 +5450,39 @@ export default function PullRequestPage({
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* Why: Orca's signature affordance — keep this primary so it stands out
|
||||
against GitHub's familiar surface. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => onUse(workItem)}
|
||||
className="gap-1.5 whitespace-nowrap"
|
||||
aria-label="Start workspace from PR"
|
||||
>
|
||||
Start workspace from PR
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu modal={false}>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleOpenOrUsePR}
|
||||
className="gap-1.5 whitespace-nowrap"
|
||||
aria-label={
|
||||
attachedWorkspace ? 'Open workspace attached to PR' : 'Start workspace from PR'
|
||||
}
|
||||
>
|
||||
{attachedWorkspace ? 'Open workspace' : 'Start workspace from PR'}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="icon-sm" aria-label="More PR workspace actions">
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</ButtonGroup>
|
||||
<DropdownMenuContent align="end">
|
||||
{attachedWorkspace ? (
|
||||
<DropdownMenuItem onSelect={handleUseWorkItem}>
|
||||
<Plus className="size-4" />
|
||||
Start new workspace
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(workItem.url)}>
|
||||
<ExternalLink className="size-4" />
|
||||
Open on GitHub
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] text-muted-foreground">
|
||||
@@ -5457,6 +5517,12 @@ export default function PullRequestPage({
|
||||
· updated {formatRelativeTime(workItem.updatedAt)}
|
||||
</span>
|
||||
</span>
|
||||
{attachedWorkspaceLabel ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<FolderKanban className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{attachedWorkspaceLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,46 +15,7 @@ import {
|
||||
CommandEmpty,
|
||||
CommandItem
|
||||
} from '@/components/ui/command'
|
||||
|
||||
/**
|
||||
* Simple fuzzy match: checks if all characters in the query appear in order
|
||||
* within the target string (case-insensitive). Returns a score (lower = better)
|
||||
* or -1 if no match.
|
||||
*/
|
||||
function fuzzyMatch(query: string, target: string): number {
|
||||
const q = query.toLowerCase()
|
||||
const t = target.toLowerCase()
|
||||
let qi = 0
|
||||
let score = 0
|
||||
let lastMatchIdx = -1
|
||||
|
||||
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
||||
if (t[ti] === q[qi]) {
|
||||
// Bonus for consecutive matches
|
||||
const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1
|
||||
score += gap
|
||||
// Bonus for matching after separator (/ or .)
|
||||
if (ti > 0 && (t[ti - 1] === '/' || t[ti - 1] === '.' || t[ti - 1] === '-')) {
|
||||
score -= 5 // reward
|
||||
}
|
||||
lastMatchIdx = ti
|
||||
qi++
|
||||
}
|
||||
}
|
||||
|
||||
if (qi < q.length) {
|
||||
return -1 // not all chars matched
|
||||
}
|
||||
|
||||
// Prefer matches where query appears in the filename (last segment)
|
||||
const lastSlash = target.lastIndexOf('/')
|
||||
const filename = target.slice(lastSlash + 1).toLowerCase()
|
||||
if (filename.includes(q)) {
|
||||
score -= 100 // strong reward for filename match
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
import { prepareQuickOpenFiles, rankQuickOpenFiles } from '@/components/quick-open-search'
|
||||
|
||||
/**
|
||||
* Parses the install-ripgrep guidance message produced by the relay's
|
||||
@@ -296,23 +257,11 @@ export default function QuickOpen(): React.JSX.Element | null {
|
||||
}
|
||||
}, [visible, activeWorktreeId, worktreePath, connectionId, excludePathsKey, filesRequestKey])
|
||||
|
||||
// Filter files by fuzzy match
|
||||
const filtered = useMemo(() => {
|
||||
const normalizedQuery = deferredQuery.trim()
|
||||
if (!normalizedQuery) {
|
||||
// Show first 50 files when no query
|
||||
return files.slice(0, 50).map((f) => ({ path: f, score: 0 }))
|
||||
}
|
||||
const results: { path: string; score: number }[] = []
|
||||
for (const f of files) {
|
||||
const score = fuzzyMatch(normalizedQuery, f)
|
||||
if (score !== -1) {
|
||||
results.push({ path: f, score })
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => a.score - b.score)
|
||||
return results.slice(0, 50)
|
||||
}, [deferredQuery, files])
|
||||
const indexedFiles = useMemo(() => prepareQuickOpenFiles(files), [files])
|
||||
const filtered = useMemo(
|
||||
() => rankQuickOpenFiles(deferredQuery, indexedFiles),
|
||||
[deferredQuery, indexedFiles]
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(relativePath: string) => {
|
||||
|
||||
@@ -42,9 +42,10 @@ import {
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoMap } from '@/store/selectors'
|
||||
import { useAllWorktrees, useRepoMap } from '@/store/selectors'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
@@ -98,6 +99,11 @@ import {
|
||||
} from '../../../shared/linear-links'
|
||||
import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFilterDropdowns'
|
||||
import { buildGitHubRepoUrl, parseGitHubIssueOrPRLink } from '@/lib/github-links'
|
||||
import {
|
||||
findGithubPrWorkspaceAttachment,
|
||||
getGithubPrWorkspaceAttachmentLabel
|
||||
} from '@/lib/github-pr-workspace-attachment'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import GitHubItemDialog, { type ItemDialogTab } from '@/components/GitHubItemDialog'
|
||||
import PullRequestPage from '@/components/PullRequestPage'
|
||||
@@ -1957,6 +1963,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoMap = useRepoMap()
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const fetchWorkItemsAcrossRepos = useAppStore((s) => s.fetchWorkItemsAcrossRepos)
|
||||
@@ -3797,6 +3804,26 @@ export default function TaskPage(): React.JSX.Element {
|
||||
[openComposerForItem]
|
||||
)
|
||||
|
||||
const handleOpenOrUseGitHubPR = useCallback(
|
||||
(item: GitHubWorkItem): void => {
|
||||
const currentAttached = findGithubPrWorkspaceAttachment(
|
||||
useAppStore.getState().allWorktrees(),
|
||||
item.repoId,
|
||||
item.number
|
||||
)
|
||||
if (!currentAttached) {
|
||||
handleUseWorkItem(item)
|
||||
return
|
||||
}
|
||||
|
||||
const result = activateAndRevealWorktree(currentAttached.id)
|
||||
if (result === false) {
|
||||
toast.error('Unable to open the workspace attached to this pull request.')
|
||||
}
|
||||
},
|
||||
[handleUseWorkItem]
|
||||
)
|
||||
|
||||
const openComposerForGitLabItem = useCallback(
|
||||
(item: GitLabWorkItem): void => {
|
||||
const linkedWorkItem: LinkedWorkItemSummary = {
|
||||
@@ -5144,6 +5171,13 @@ export default function TaskPage(): React.JSX.Element {
|
||||
{!showGitHubTaskSkeletons &&
|
||||
filteredWorkItems.map((item) => {
|
||||
const itemRepo = repoMap.get(item.repoId) ?? null
|
||||
const attachedWorkspace =
|
||||
item.type === 'pr'
|
||||
? findGithubPrWorkspaceAttachment(allWorktrees, item.repoId, item.number)
|
||||
: null
|
||||
const attachedWorkspaceLabel = attachedWorkspace
|
||||
? getGithubPrWorkspaceAttachmentLabel(attachedWorkspace)
|
||||
: null
|
||||
return (
|
||||
// Why: the row is a clickable container rather than a
|
||||
// <button> because it holds nested interactive elements
|
||||
@@ -5226,6 +5260,12 @@ export default function TaskPage(): React.JSX.Element {
|
||||
{formatPRDelta(item)}
|
||||
</span>
|
||||
) : null}
|
||||
{attachedWorkspaceLabel ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<FolderKanban className="size-3 shrink-0" />
|
||||
<span className="truncate">{attachedWorkspaceLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{item.labels.slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
@@ -5283,37 +5323,96 @@ export default function TaskPage(): React.JSX.Element {
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center justify-start gap-1 lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleUseWorkItem(item)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60"
|
||||
>
|
||||
Start
|
||||
<ArrowRight className="size-3" />
|
||||
</button>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
{item.type === 'pr' ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleOpenOrUseGitHubPR(item)
|
||||
}}
|
||||
className="bg-background/80"
|
||||
aria-label={
|
||||
attachedWorkspace
|
||||
? 'Open workspace attached to PR'
|
||||
: 'Start workspace from PR'
|
||||
}
|
||||
>
|
||||
{attachedWorkspace ? 'Open' : 'Start'}
|
||||
<ArrowRight className="size-3" />
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="bg-background/80"
|
||||
aria-label="More PR actions"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</ButtonGroup>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{attachedWorkspace ? (
|
||||
<DropdownMenuItem onSelect={() => handleUseWorkItem(item)}>
|
||||
<Plus className="size-4" />
|
||||
Start new workspace
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => window.api.shell.openUrl(item.url)}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Open in browser
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleUseWorkItem(item)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60"
|
||||
>
|
||||
Start
|
||||
<ArrowRight className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{item.type !== 'pr' ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<EllipsisVertical className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<EllipsisVertical className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => window.api.shell.openUrl(item.url)}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Open in browser
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => window.api.shell.openUrl(item.url)}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Open in browser
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type EditorRequestFileCloseDetail,
|
||||
requestEditorSaveQuiesce
|
||||
} from './editor/editor-autosave'
|
||||
import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload'
|
||||
import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
|
||||
import EditorAutosaveController from './editor/EditorAutosaveController'
|
||||
import type { TabGroupLayoutNode } from '../../../shared/types'
|
||||
import BrowserPane from './browser-pane/BrowserPane'
|
||||
@@ -1330,10 +1330,9 @@ function Terminal(): React.JSX.Element | null {
|
||||
// Warn on window close if there are unsaved editor files
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent): void => {
|
||||
// Why: updater restarts intentionally close the app even if a hidden
|
||||
// editor tab still reports dirty. Let ShipIt replace the bundle instead
|
||||
// of vetoing quitAndInstall and leaving the old version running.
|
||||
if (isUpdaterQuitAndInstallInProgress()) {
|
||||
// Why: update/manual restarts pre-save dirty tabs and then intentionally
|
||||
// close the app. Do not let stale dirty flags veto the relaunch path.
|
||||
if (isIntentionalAppRestartInProgress()) {
|
||||
return
|
||||
}
|
||||
const dirtyFiles = useAppStore.getState().openFiles.filter((f) => f.isDirty)
|
||||
@@ -1350,7 +1349,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
// close here. Explicit destructive terminal actions keep their own confirms.
|
||||
useEffect(() => {
|
||||
return window.api.ui.onWindowCloseRequested(({ isQuitting }) => {
|
||||
if (isUpdaterQuitAndInstallInProgress()) {
|
||||
if (isIntentionalAppRestartInProgress()) {
|
||||
window.api.ui.confirmWindowClose()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Columns2,
|
||||
Copy,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FileText,
|
||||
ListTree,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Rows2
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
@@ -30,6 +32,7 @@ import type { EditorToggleValue } from './EditorViewToggle'
|
||||
import type { EditorHeaderOpenFileState } from './editor-header'
|
||||
import { getEditorHeaderCopyState } from './editor-header'
|
||||
import { DiffNotesSendMenu } from './DiffNotesSendMenu'
|
||||
import { useEditorHeaderFileRename } from './editor-header-file-rename'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
@@ -104,7 +107,17 @@ export function EditorPanelHeader({
|
||||
}: EditorPanelHeaderProps): React.JSX.Element {
|
||||
const [pathMenuOpen, setPathMenuOpen] = useState(false)
|
||||
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
|
||||
const skipMenuFocusRestoreRef = useRef(false)
|
||||
const headerCopyState = getEditorHeaderCopyState(activeFile)
|
||||
const {
|
||||
canRename,
|
||||
currentFileName,
|
||||
isRenaming,
|
||||
renameInputRef,
|
||||
openRenameInput,
|
||||
commitRename,
|
||||
cancelRename
|
||||
} = useEditorHeaderFileRename(activeFile)
|
||||
const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId))
|
||||
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId])
|
||||
const fileDiffComments = useMemo(
|
||||
@@ -131,14 +144,43 @@ export function EditorPanelHeader({
|
||||
setPathMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="editor-header-path"
|
||||
onClick={onCopyPath}
|
||||
title={headerCopyState.pathTitle}
|
||||
>
|
||||
{headerCopyState.pathLabel}
|
||||
</button>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
data-editor-header-rename-input="true"
|
||||
aria-label={`Rename file ${currentFileName}`}
|
||||
defaultValue={currentFileName}
|
||||
// Why: the header is narrow in floating mode; this keeps the
|
||||
// edit field aligned with the path label without growing chrome.
|
||||
className="h-6 w-[16ch] min-w-[104px] max-w-full rounded-sm bg-input/40 px-1.5 py-0 font-mono text-xs text-foreground md:text-xs focus-visible:ring-[1px]"
|
||||
spellCheck={false}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
commitRename()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="editor-header-path"
|
||||
onClick={onCopyPath}
|
||||
title={headerCopyState.pathTitle}
|
||||
>
|
||||
{headerCopyState.pathLabel}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`editor-header-copy-toast${copiedPathVisible ? ' is-visible' : ''}`}
|
||||
aria-live="polite"
|
||||
@@ -155,7 +197,29 @@ export function EditorPanelHeader({
|
||||
style={{ left: pathMenuPoint.x, top: pathMenuPoint.y }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" sideOffset={0} align="start">
|
||||
<DropdownMenuContent
|
||||
className="w-56"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!skipMenuFocusRestoreRef.current) {
|
||||
return
|
||||
}
|
||||
skipMenuFocusRestoreRef.current = false
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={!canRename}
|
||||
onSelect={() => {
|
||||
skipMenuFocusRestoreRef.current = true
|
||||
openRenameInput()
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(activeFile.filePath)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { useWorktreeById } from '@/store/selectors'
|
||||
import { basename } from '@/lib/path'
|
||||
import { renameFileOnDisk } from '@/lib/rename-file'
|
||||
import { getUntitledFileRoot } from './untitled-file-rename-path'
|
||||
|
||||
type EditorHeaderFileRenameState = {
|
||||
canRename: boolean
|
||||
currentFileName: string
|
||||
isRenaming: boolean
|
||||
renameInputRef: React.RefObject<HTMLInputElement | null>
|
||||
openRenameInput: () => void
|
||||
commitRename: () => void
|
||||
cancelRename: () => void
|
||||
}
|
||||
|
||||
export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFileRenameState {
|
||||
const worktree = useWorktreeById(activeFile.worktreeId)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const renameCancelledRef = useRef(false)
|
||||
const currentFileName = basename(activeFile.filePath)
|
||||
const canRename =
|
||||
activeFile.mode === 'edit' && !activeFile.diffSource && !activeFile.conflict && !isRenaming
|
||||
|
||||
const openRenameInput = (): void => {
|
||||
if (!canRename) {
|
||||
return
|
||||
}
|
||||
renameCancelledRef.current = false
|
||||
setIsRenaming(true)
|
||||
}
|
||||
|
||||
const commitRename = (): void => {
|
||||
if (renameCancelledRef.current) {
|
||||
renameCancelledRef.current = false
|
||||
setIsRenaming(false)
|
||||
return
|
||||
}
|
||||
const input = renameInputRef.current
|
||||
if (!input) {
|
||||
setIsRenaming(false)
|
||||
return
|
||||
}
|
||||
const newName = input.value.trim()
|
||||
setIsRenaming(false)
|
||||
if (!newName || newName === currentFileName) {
|
||||
return
|
||||
}
|
||||
const worktreePath = getUntitledFileRoot(activeFile, worktree?.path ?? null)
|
||||
void renameFileOnDisk({
|
||||
oldPath: activeFile.filePath,
|
||||
newName,
|
||||
worktreeId: activeFile.worktreeId,
|
||||
worktreePath
|
||||
})
|
||||
}
|
||||
|
||||
const cancelRename = (): void => {
|
||||
renameCancelledRef.current = true
|
||||
setIsRenaming(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRenaming) {
|
||||
return
|
||||
}
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const el = renameInputRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
el.focus()
|
||||
const dotIndex = currentFileName.lastIndexOf('.')
|
||||
if (dotIndex > 0) {
|
||||
el.setSelectionRange(0, dotIndex)
|
||||
} else {
|
||||
el.select()
|
||||
}
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [currentFileName, isRenaming])
|
||||
|
||||
return {
|
||||
canRename,
|
||||
currentFileName,
|
||||
isRenaming,
|
||||
renameInputRef,
|
||||
openRenameInput,
|
||||
commitRename,
|
||||
cancelRename
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { PanelsTopLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -8,22 +8,40 @@ import {
|
||||
clampFloatingTerminalTriggerPosition,
|
||||
getDefaultFloatingTerminalTriggerPosition,
|
||||
parseFloatingTerminalTriggerPosition,
|
||||
type FloatingTerminalTriggerPosition
|
||||
resolveFloatingTerminalTriggerPosition,
|
||||
shouldReconcileFloatingTerminalTriggerPosition,
|
||||
type FloatingTerminalTriggerPosition,
|
||||
type FloatingTerminalTriggerPositionSource
|
||||
} from './floating-terminal-trigger-position'
|
||||
|
||||
// Why: v2 resets older parked positions that sat too low over bottom bars.
|
||||
const FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY = 'orca-floating-terminal-trigger-position-v2'
|
||||
const FLOATING_TERMINAL_TRIGGER_DRAG_THRESHOLD = 4
|
||||
|
||||
function readInitialTriggerPosition(): FloatingTerminalTriggerPosition {
|
||||
type FloatingTerminalTriggerPositionState = {
|
||||
position: FloatingTerminalTriggerPosition
|
||||
source: FloatingTerminalTriggerPositionSource
|
||||
}
|
||||
|
||||
function readInitialTriggerPosition(): FloatingTerminalTriggerPositionState {
|
||||
if (typeof window === 'undefined') {
|
||||
return getDefaultFloatingTerminalTriggerPosition()
|
||||
return {
|
||||
position: getDefaultFloatingTerminalTriggerPosition(),
|
||||
source: 'default'
|
||||
}
|
||||
}
|
||||
return (
|
||||
parseFloatingTerminalTriggerPosition(
|
||||
window.localStorage.getItem(FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY)
|
||||
) ?? getDefaultFloatingTerminalTriggerPosition()
|
||||
const persistedPosition = parseFloatingTerminalTriggerPosition(
|
||||
window.localStorage.getItem(FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY)
|
||||
)
|
||||
return persistedPosition
|
||||
? {
|
||||
position: persistedPosition,
|
||||
source: 'user'
|
||||
}
|
||||
: {
|
||||
position: getDefaultFloatingTerminalTriggerPosition(),
|
||||
source: 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function persistTriggerPosition(position: FloatingTerminalTriggerPosition): void {
|
||||
@@ -41,7 +59,14 @@ export function FloatingTerminalToggleButton({
|
||||
onToggle: () => void
|
||||
}): React.JSX.Element {
|
||||
const shortcutLabel = useShortcutLabel('floatingTerminal.toggle')
|
||||
const [position, setPosition] = useState(readInitialTriggerPosition)
|
||||
const initialPositionState = useRef<FloatingTerminalTriggerPositionState | null>(null)
|
||||
if (initialPositionState.current === null) {
|
||||
initialPositionState.current = readInitialTriggerPosition()
|
||||
}
|
||||
const positionSourceRef = useRef<FloatingTerminalTriggerPositionSource>(
|
||||
initialPositionState.current.source
|
||||
)
|
||||
const [position, setPosition] = useState(initialPositionState.current.position)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
startX: number
|
||||
@@ -53,22 +78,38 @@ export function FloatingTerminalToggleButton({
|
||||
const suppressClickRef = useRef(false)
|
||||
|
||||
const updatePosition = useCallback((nextPosition: FloatingTerminalTriggerPosition): void => {
|
||||
positionSourceRef.current = 'user'
|
||||
const clamped = clampFloatingTerminalTriggerPosition(nextPosition)
|
||||
setPosition(clamped)
|
||||
persistTriggerPosition(clamped)
|
||||
}, [])
|
||||
|
||||
const reconcilePosition = useCallback((): void => {
|
||||
setPosition((current) => {
|
||||
if (!shouldReconcileFloatingTerminalTriggerPosition(positionSourceRef.current)) {
|
||||
// Why: a startup-size viewport must not overwrite an intentional saved
|
||||
// drag position with the safety clamp before the renderer finishes sizing.
|
||||
return current
|
||||
}
|
||||
const next = resolveFloatingTerminalTriggerPosition(current, positionSourceRef.current)
|
||||
if (positionSourceRef.current === 'user') {
|
||||
persistTriggerPosition(next)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Why: Electron can mount before the renderer has final viewport dimensions;
|
||||
// default positions should re-anchor to bottom-right before first paint.
|
||||
reconcilePosition()
|
||||
}, [reconcilePosition])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = (): void => {
|
||||
setPosition((current) => {
|
||||
const clamped = clampFloatingTerminalTriggerPosition(current)
|
||||
persistTriggerPosition(clamped)
|
||||
return clamped
|
||||
})
|
||||
}
|
||||
const handleResize = (): void => reconcilePosition()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
}, [reconcilePosition])
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>): void => {
|
||||
if (event.button !== 0) {
|
||||
|
||||
+52
-1
@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clampFloatingTerminalTriggerPosition,
|
||||
getDefaultFloatingTerminalTriggerPosition,
|
||||
parseFloatingTerminalTriggerPosition
|
||||
hasUsableFloatingTerminalTriggerViewport,
|
||||
parseFloatingTerminalTriggerPosition,
|
||||
resolveFloatingTerminalTriggerPosition,
|
||||
shouldReconcileFloatingTerminalTriggerPosition
|
||||
} from './floating-terminal-trigger-position'
|
||||
|
||||
function stubViewport(width: number, height: number): void {
|
||||
@@ -32,10 +35,58 @@ describe('floating terminal trigger position', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('re-anchors default positions when startup viewport dimensions change', () => {
|
||||
stubViewport(0, 0)
|
||||
const initialPosition = getDefaultFloatingTerminalTriggerPosition()
|
||||
|
||||
stubViewport(1200, 800)
|
||||
|
||||
expect(resolveFloatingTerminalTriggerPosition(initialPosition, 'default')).toEqual({
|
||||
left: 1144,
|
||||
top: 696
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps user-placed positions instead of re-anchoring them on resize', () => {
|
||||
stubViewport(1200, 800)
|
||||
|
||||
expect(resolveFloatingTerminalTriggerPosition({ left: 900, top: 500 }, 'user')).toEqual({
|
||||
left: 900,
|
||||
top: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores malformed persisted positions', () => {
|
||||
stubViewport(640, 480)
|
||||
|
||||
expect(parseFloatingTerminalTriggerPosition('not-json')).toBeNull()
|
||||
expect(parseFloatingTerminalTriggerPosition('{"left":"1","top":2}')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clamp parsed positions before the viewport is settled', () => {
|
||||
stubViewport(0, 0)
|
||||
|
||||
expect(parseFloatingTerminalTriggerPosition('{"left":900,"top":500}')).toEqual({
|
||||
left: 900,
|
||||
top: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('detects whether the viewport is usable for persisted user-position clamps', () => {
|
||||
stubViewport(0, 0)
|
||||
expect(hasUsableFloatingTerminalTriggerViewport()).toBe(false)
|
||||
|
||||
stubViewport(1200, 800)
|
||||
expect(hasUsableFloatingTerminalTriggerViewport()).toBe(true)
|
||||
})
|
||||
|
||||
it('defers user-position reconciliation until the viewport is usable', () => {
|
||||
stubViewport(0, 0)
|
||||
|
||||
expect(shouldReconcileFloatingTerminalTriggerPosition('default')).toBe(true)
|
||||
expect(shouldReconcileFloatingTerminalTriggerPosition('user')).toBe(false)
|
||||
|
||||
stubViewport(1200, 800)
|
||||
expect(shouldReconcileFloatingTerminalTriggerPosition('user')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+27
-1
@@ -9,6 +9,8 @@ export type FloatingTerminalTriggerPosition = {
|
||||
top: number
|
||||
}
|
||||
|
||||
export type FloatingTerminalTriggerPositionSource = 'default' | 'user'
|
||||
|
||||
function getViewport(): { width: number; height: number } {
|
||||
return {
|
||||
width: typeof window === 'undefined' ? 1200 : window.innerWidth,
|
||||
@@ -40,6 +42,30 @@ export function clampFloatingTerminalTriggerPosition(
|
||||
}
|
||||
}
|
||||
|
||||
export function hasUsableFloatingTerminalTriggerViewport(): boolean {
|
||||
const viewport = getViewport()
|
||||
return (
|
||||
viewport.width >= TRIGGER_SIZE + DRAG_MARGIN * 2 &&
|
||||
viewport.height >= TRIGGER_SIZE + TITLEBAR_SAFE_TOP + DRAG_MARGIN
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldReconcileFloatingTerminalTriggerPosition(
|
||||
source: FloatingTerminalTriggerPositionSource
|
||||
): boolean {
|
||||
return source === 'default' || hasUsableFloatingTerminalTriggerViewport()
|
||||
}
|
||||
|
||||
export function resolveFloatingTerminalTriggerPosition(
|
||||
position: FloatingTerminalTriggerPosition,
|
||||
source: FloatingTerminalTriggerPositionSource
|
||||
): FloatingTerminalTriggerPosition {
|
||||
if (source === 'default') {
|
||||
return getDefaultFloatingTerminalTriggerPosition()
|
||||
}
|
||||
return clampFloatingTerminalTriggerPosition(position)
|
||||
}
|
||||
|
||||
export function parseFloatingTerminalTriggerPosition(
|
||||
serialized: string | null
|
||||
): FloatingTerminalTriggerPosition | null {
|
||||
@@ -51,7 +77,7 @@ export function parseFloatingTerminalTriggerPosition(
|
||||
if (!isFiniteCoordinate(parsed.left) || !isFiniteCoordinate(parsed.top)) {
|
||||
return null
|
||||
}
|
||||
return clampFloatingTerminalTriggerPosition({ left: parsed.left, top: parsed.top })
|
||||
return { left: parsed.left, top: parsed.top }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const PLATFORM_COPY: Record<
|
||||
android: {
|
||||
description: 'Scan with your Android camera to download the latest APK from GitHub Releases.',
|
||||
ctaLabel: 'Download APK',
|
||||
url: 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9'
|
||||
url: 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ export default function SmartWorkspaceNameField({
|
||||
const [linearLoading, setLinearLoading] = useState(false)
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
const localInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const selectedSourceRef = useRef<HTMLDivElement | null>(null)
|
||||
const tabsListRef = useRef<HTMLDivElement | null>(null)
|
||||
const repoSlugCacheRef = useRef<Map<string, RepoSlug | null>>(new Map())
|
||||
const handledCrossRepoUrlRef = useRef<string | null>(null)
|
||||
@@ -309,6 +310,9 @@ export default function SmartWorkspaceNameField({
|
||||
useEffect(() => {
|
||||
if (selectedSource) {
|
||||
setOpen(false)
|
||||
// Why: after Enter accepts a PR/issue row, the input unmounts. Keep the
|
||||
// keyboard flow on the source field so the next Enter advances to Agent.
|
||||
requestAnimationFrame(() => selectedSourceRef.current?.focus({ preventScroll: true }))
|
||||
}
|
||||
}, [selectedSource])
|
||||
|
||||
@@ -974,7 +978,25 @@ export default function SmartWorkspaceNameField({
|
||||
// parent; without them the inner truncate's intrinsic
|
||||
// min-content (long PR title) propagates up and pushes the
|
||||
// dialog wider than its max-w.
|
||||
<div className="flex h-9 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 text-sm shadow-xs focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50">
|
||||
<div
|
||||
ref={selectedSourceRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.currentTarget !== event.target ||
|
||||
event.key !== 'Enter' ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
onPlainEnter?.()
|
||||
}}
|
||||
className="flex h-9 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 text-sm shadow-xs outline-none focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50"
|
||||
>
|
||||
<SelectionIcon kind={selectedSource.kind} />
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-none text-foreground">
|
||||
{selectedSource.label}
|
||||
|
||||
@@ -365,7 +365,7 @@ export default function OnboardingFlow({
|
||||
busyLabel={busyLabel}
|
||||
onSkipToRepo={() => void flow.skipToRepo()}
|
||||
stepIndex={stepIndex}
|
||||
onBack={flow.back}
|
||||
onBack={flow.nestedScan ? flow.cancelNested : flow.back}
|
||||
showPrimary={currentStep.id !== 'repo' || flow.hasExistingProject}
|
||||
primaryBusy={shouldShowFooterBusy}
|
||||
primaryLabel={footerPrimaryLabel}
|
||||
|
||||
@@ -40,4 +40,23 @@ describe('RepoStep', () => {
|
||||
expect(html).toContain('Open a folder')
|
||||
expect(html).toContain('Clone a repo')
|
||||
})
|
||||
|
||||
it('disables nested import actions when no repositories are selected', () => {
|
||||
const html = renderRepoStep({
|
||||
nestedScan: {
|
||||
selectedPath: '/workspace/platform',
|
||||
selectedPathKind: 'non_git_folder',
|
||||
repos: [{ path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }],
|
||||
truncated: false,
|
||||
timedOut: false,
|
||||
durationMs: 4,
|
||||
maxDepth: 3
|
||||
},
|
||||
nestedGroupName: 'platform'
|
||||
})
|
||||
|
||||
expect(html).toContain('Import separately')
|
||||
expect(html).toContain('Import as project group')
|
||||
expect(html.match(/disabled=""/g)?.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,14 @@ import { buildAgentPickedPayload } from './agent-picked-payload'
|
||||
import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
|
||||
import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
buildNestedRepoImportActionTelemetry,
|
||||
buildNestedRepoImportResultTelemetry,
|
||||
buildNestedRepoScanTelemetry,
|
||||
createNestedRepoTelemetryAttemptId,
|
||||
shouldEmitNestedRepoImportSubmitTelemetry,
|
||||
type NestedRepoTelemetryRuntimeKind
|
||||
} from '../../../../shared/nested-repo-telemetry'
|
||||
import type { EventProps } from '../../../../shared/telemetry-events'
|
||||
import type {
|
||||
GlobalSettings,
|
||||
@@ -135,6 +143,10 @@ export function useOnboardingFlow(
|
||||
const [nestedScan, setNestedScan] = useState<NestedRepoScanResult | null>(null)
|
||||
const [nestedSelectedPaths, setNestedSelectedPaths] = useState<Set<string>>(new Set())
|
||||
const [nestedGroupName, setNestedGroupName] = useState('')
|
||||
const [nestedAttemptId, setNestedAttemptId] = useState<string | null>(null)
|
||||
const [nestedRuntimeKind, setNestedRuntimeKind] = useState<NestedRepoTelemetryRuntimeKind | null>(
|
||||
null
|
||||
)
|
||||
const [tourStarted, setTourStarted] = useState(false)
|
||||
const [busyLabel, setBusyLabel] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -554,11 +566,24 @@ export function useOnboardingFlow(
|
||||
]
|
||||
)
|
||||
|
||||
const showNestedRepoReview = useCallback((scan: NestedRepoScanResult, selectedPath: string) => {
|
||||
setNestedScan(scan)
|
||||
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
|
||||
setNestedGroupName(defaultProjectGroupNameForPath(selectedPath))
|
||||
}, [])
|
||||
const showNestedRepoReview = useCallback(
|
||||
(
|
||||
scan: NestedRepoScanResult,
|
||||
selectedPath: string,
|
||||
attemptId: string,
|
||||
runtimeKind: NestedRepoTelemetryRuntimeKind
|
||||
) => {
|
||||
setNestedScan(scan)
|
||||
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
|
||||
setNestedGroupName(defaultProjectGroupNameForPath(selectedPath))
|
||||
setNestedAttemptId(attemptId)
|
||||
setNestedRuntimeKind(runtimeKind)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const onboardingNestedRepoRuntimeKind: NestedRepoTelemetryRuntimeKind =
|
||||
settings?.activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local'
|
||||
|
||||
const startFeatureSetup = useCallback(async () => {
|
||||
if (
|
||||
@@ -614,9 +639,19 @@ export function useOnboardingFlow(
|
||||
setBusyLabel(kind === 'git' ? 'Scanning for repositories…' : 'Opening folder…')
|
||||
try {
|
||||
if (kind === 'git') {
|
||||
const attemptId = createNestedRepoTelemetryAttemptId()
|
||||
const scan = await scanNestedRepos(path)
|
||||
track(
|
||||
'add_repo_nested_scan_result',
|
||||
buildNestedRepoScanTelemetry({
|
||||
attemptId,
|
||||
surface: 'onboarding',
|
||||
runtimeKind: 'runtime',
|
||||
scan
|
||||
})
|
||||
)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
showNestedRepoReview(scan, path)
|
||||
showNestedRepoReview(scan, path, attemptId, 'runtime')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -645,9 +680,19 @@ export function useOnboardingFlow(
|
||||
try {
|
||||
let result = await window.api.repos.add({ path })
|
||||
if ('error' in result && result.error.includes('Not a valid git repository')) {
|
||||
const attemptId = createNestedRepoTelemetryAttemptId()
|
||||
const scan = await scanNestedRepos(path)
|
||||
track(
|
||||
'add_repo_nested_scan_result',
|
||||
buildNestedRepoScanTelemetry({
|
||||
attemptId,
|
||||
surface: 'onboarding',
|
||||
runtimeKind: 'local',
|
||||
scan
|
||||
})
|
||||
)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
showNestedRepoReview(scan, path)
|
||||
showNestedRepoReview(scan, path, attemptId, 'local')
|
||||
return
|
||||
}
|
||||
result = await window.api.repos.add({ path, kind: 'folder' })
|
||||
@@ -676,11 +721,35 @@ export function useOnboardingFlow(
|
||||
|
||||
const importNested = useCallback(
|
||||
async (mode: 'group' | 'separate') => {
|
||||
if (!nestedScan || nestedSelectedPaths.size === 0 || busyLabel !== null) {
|
||||
const attemptId = nestedAttemptId
|
||||
if (
|
||||
!nestedScan ||
|
||||
!attemptId ||
|
||||
!shouldEmitNestedRepoImportSubmitTelemetry({
|
||||
attemptId,
|
||||
selectedCount: nestedSelectedPaths.size,
|
||||
isBusy: busyLabel !== null
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
const foundCount = nestedScan.repos.length
|
||||
const selectedCount = nestedSelectedPaths.size
|
||||
const runtimeKind = nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind
|
||||
setError(null)
|
||||
setBusyLabel('Importing repositories…')
|
||||
track(
|
||||
'add_repo_nested_import_action',
|
||||
buildNestedRepoImportActionTelemetry({
|
||||
attemptId,
|
||||
surface: 'onboarding',
|
||||
runtimeKind,
|
||||
action: mode === 'group' ? 'import_group' : 'import_separate',
|
||||
foundCount,
|
||||
selectedCount
|
||||
})
|
||||
)
|
||||
let resultTracked = false
|
||||
try {
|
||||
const result = await importNestedRepos({
|
||||
parentPath: nestedScan.selectedPath,
|
||||
@@ -688,6 +757,19 @@ export function useOnboardingFlow(
|
||||
projectPaths: [...nestedSelectedPaths],
|
||||
mode
|
||||
})
|
||||
track(
|
||||
'add_repo_nested_import_result',
|
||||
buildNestedRepoImportResultTelemetry({
|
||||
attemptId,
|
||||
surface: 'onboarding',
|
||||
runtimeKind,
|
||||
mode,
|
||||
foundCount,
|
||||
selectedCount,
|
||||
result
|
||||
})
|
||||
)
|
||||
resultTracked = true
|
||||
const importedRepoIds =
|
||||
result?.projects
|
||||
.map((entry) => entry.projectId)
|
||||
@@ -701,6 +783,20 @@ export function useOnboardingFlow(
|
||||
}
|
||||
await completeRepo(projectId, true, 'open_folder')
|
||||
} catch (err) {
|
||||
if (!resultTracked) {
|
||||
track(
|
||||
'add_repo_nested_import_result',
|
||||
buildNestedRepoImportResultTelemetry({
|
||||
attemptId,
|
||||
surface: 'onboarding',
|
||||
runtimeKind,
|
||||
mode,
|
||||
foundCount,
|
||||
selectedCount,
|
||||
result: null
|
||||
})
|
||||
)
|
||||
}
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
|
||||
} finally {
|
||||
@@ -713,22 +809,54 @@ export function useOnboardingFlow(
|
||||
fetchWorktrees,
|
||||
importNestedRepos,
|
||||
nestedGroupName,
|
||||
nestedAttemptId,
|
||||
nestedScan,
|
||||
nestedSelectedPaths
|
||||
nestedSelectedPaths,
|
||||
nestedRuntimeKind,
|
||||
onboardingNestedRepoRuntimeKind
|
||||
]
|
||||
)
|
||||
|
||||
const trackNestedBackAndClear = useCallback(() => {
|
||||
if (nestedScan && nestedAttemptId) {
|
||||
track(
|
||||
'add_repo_nested_import_action',
|
||||
buildNestedRepoImportActionTelemetry({
|
||||
attemptId: nestedAttemptId,
|
||||
surface: 'onboarding',
|
||||
runtimeKind: nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind,
|
||||
action: 'back',
|
||||
foundCount: nestedScan.repos.length,
|
||||
selectedCount: nestedSelectedPaths.size
|
||||
})
|
||||
)
|
||||
}
|
||||
setNestedScan(null)
|
||||
setNestedSelectedPaths(new Set())
|
||||
setNestedGroupName('')
|
||||
setNestedAttemptId(null)
|
||||
setNestedRuntimeKind(null)
|
||||
setError(null)
|
||||
}, [
|
||||
nestedAttemptId,
|
||||
nestedRuntimeKind,
|
||||
nestedScan,
|
||||
nestedSelectedPaths.size,
|
||||
onboardingNestedRepoRuntimeKind
|
||||
])
|
||||
|
||||
// Why: lets the user back out of the nested-repo step in onboarding to
|
||||
// re-pick a folder/clone target. Mirrors the dialog's left-aligned Back.
|
||||
const cancelNested = useCallback(() => {
|
||||
if (busyLabel !== null) {
|
||||
return
|
||||
}
|
||||
setNestedScan(null)
|
||||
setNestedSelectedPaths(new Set())
|
||||
setNestedGroupName('')
|
||||
setError(null)
|
||||
}, [busyLabel])
|
||||
trackNestedBackAndClear()
|
||||
}, [busyLabel, trackNestedBackAndClear])
|
||||
|
||||
const canImportNestedForTelemetry = useCallback((): boolean => {
|
||||
return Boolean(nestedScan && nestedAttemptId && nestedSelectedPaths.size > 0)
|
||||
}, [nestedAttemptId, nestedScan, nestedSelectedPaths.size])
|
||||
|
||||
const clone = useCallback(async () => {
|
||||
// Why: re-entry guard — prevents Enter spamming from triggering duplicate clones.
|
||||
@@ -885,11 +1013,22 @@ export function useOnboardingFlow(
|
||||
advancedVia
|
||||
})
|
||||
if (closed) {
|
||||
if (nestedScan) {
|
||||
trackNestedBackAndClear()
|
||||
}
|
||||
emitPendingTourOutcome()
|
||||
}
|
||||
return closed
|
||||
},
|
||||
[busyLabel, closeWith, consumeStepDurationMs, currentStep.stepNumber, emitPendingTourOutcome]
|
||||
[
|
||||
busyLabel,
|
||||
closeWith,
|
||||
consumeStepDurationMs,
|
||||
currentStep.stepNumber,
|
||||
emitPendingTourOutcome,
|
||||
nestedScan,
|
||||
trackNestedBackAndClear
|
||||
]
|
||||
)
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
@@ -1058,9 +1197,13 @@ export function useOnboardingFlow(
|
||||
])
|
||||
|
||||
const back = useCallback(() => {
|
||||
if (nestedScan) {
|
||||
trackNestedBackAndClear()
|
||||
return
|
||||
}
|
||||
setTourStarted(false)
|
||||
setStepIndex((idx) => Math.max(idx - 1, 0))
|
||||
}, [])
|
||||
}, [nestedScan, trackNestedBackAndClear])
|
||||
|
||||
// Why: returns the user to the "Take the tour" intro without leaving the
|
||||
// tour step. Don't emit the tour outcome here — re-entry must still let
|
||||
@@ -1070,10 +1213,16 @@ export function useOnboardingFlow(
|
||||
setTourStarted(false)
|
||||
}, [])
|
||||
|
||||
const jumpToStep = useCallback((idx: number) => {
|
||||
setTourStarted(false)
|
||||
setStepIndex(Math.min(Math.max(idx, 0), STEPS.length - 1))
|
||||
}, [])
|
||||
const jumpToStep = useCallback(
|
||||
(idx: number) => {
|
||||
if (nestedScan && idx !== stepIndex) {
|
||||
trackNestedBackAndClear()
|
||||
}
|
||||
setTourStarted(false)
|
||||
setStepIndex(Math.min(Math.max(idx, 0), STEPS.length - 1))
|
||||
},
|
||||
[nestedScan, stepIndex, trackNestedBackAndClear]
|
||||
)
|
||||
|
||||
return {
|
||||
settings,
|
||||
@@ -1098,6 +1247,7 @@ export function useOnboardingFlow(
|
||||
setNestedGroupName,
|
||||
importNested,
|
||||
cancelNested,
|
||||
canImportNestedForTelemetry,
|
||||
hasExistingProject,
|
||||
serverPath,
|
||||
setServerPath,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
QUICK_OPEN_RESULT_LIMIT,
|
||||
prepareQuickOpenFiles,
|
||||
rankQuickOpenFiles
|
||||
} from './quick-open-search'
|
||||
|
||||
describe('quick-open-search', () => {
|
||||
it('returns the first 50 paths with score 0 for an empty query', () => {
|
||||
const files = Array.from({ length: 75 }, (_, index) => `src/file-${index}.ts`)
|
||||
|
||||
expect(rankQuickOpenFiles('', prepareQuickOpenFiles(files))).toEqual(
|
||||
files.slice(0, QUICK_OPEN_RESULT_LIMIT).map((path) => ({ path, score: 0 }))
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a whitespace-only query as empty', () => {
|
||||
const files = ['src/a.ts', 'src/b.ts', 'src/c.ts']
|
||||
|
||||
expect(rankQuickOpenFiles(' ', prepareQuickOpenFiles(files))).toEqual([
|
||||
{ path: 'src/a.ts', score: 0 },
|
||||
{ path: 'src/b.ts', score: 0 },
|
||||
{ path: 'src/c.ts', score: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers filename substring matches over path-only matches', () => {
|
||||
const files = ['button-area/deep/path/file.tsx', 'src/components/Button.tsx']
|
||||
|
||||
expect(
|
||||
rankQuickOpenFiles('button', prepareQuickOpenFiles(files)).map((item) => item.path)
|
||||
).toEqual(['src/components/Button.tsx', 'button-area/deep/path/file.tsx'])
|
||||
})
|
||||
|
||||
it('keeps first-seen order for tie-heavy results at the limit boundary', () => {
|
||||
const files = Array.from({ length: 10 }, (_, index) => `src/path-${index}.bin`)
|
||||
|
||||
expect(rankQuickOpenFiles('s', prepareQuickOpenFiles(files), 4)).toEqual([
|
||||
{ path: 'src/path-0.bin', score: 0 },
|
||||
{ path: 'src/path-1.bin', score: 0 },
|
||||
{ path: 'src/path-2.bin', score: 0 },
|
||||
{ path: 'src/path-3.bin', score: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns 50 top-ranked results from a 100k synthetic list', () => {
|
||||
const fillerCount = 99_940
|
||||
const topCandidateCount = 60
|
||||
const files = [
|
||||
...Array.from(
|
||||
{ length: fillerCount },
|
||||
(_, index) => `n-x-e-x-e-x-d-x-l-x-e/group-${index}/file.ts`
|
||||
),
|
||||
...Array.from({ length: topCandidateCount }, (_, index) => `bulk/special-${index}/needle.ts`)
|
||||
]
|
||||
|
||||
const results = rankQuickOpenFiles('needle', prepareQuickOpenFiles(files))
|
||||
|
||||
expect(results).toHaveLength(QUICK_OPEN_RESULT_LIMIT)
|
||||
expect(results.map((item) => item.path)).toEqual(
|
||||
Array.from(
|
||||
{ length: QUICK_OPEN_RESULT_LIMIT },
|
||||
(_, index) => `bulk/special-${index}/needle.ts`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns scores sorted ascending', () => {
|
||||
const files = [
|
||||
'src/components/QuickOpen.tsx',
|
||||
'quick/open/deep/path/file.tsx',
|
||||
'src/q-u-i-c-k-open.ts'
|
||||
]
|
||||
|
||||
const scores = rankQuickOpenFiles('quick', prepareQuickOpenFiles(files)).map(
|
||||
(item) => item.score
|
||||
)
|
||||
|
||||
expect(scores).toEqual([...scores].sort((a, b) => a - b))
|
||||
})
|
||||
|
||||
it('indexes normalized relative paths without changing path semantics', () => {
|
||||
const files = [
|
||||
'src/renderer/src/components/QuickOpen.tsx',
|
||||
'packages/windows-origin/src/App.tsx',
|
||||
'single-file.ts',
|
||||
'legacy\\provider\\raw-path.ts'
|
||||
]
|
||||
|
||||
expect(prepareQuickOpenFiles(files)).toEqual([
|
||||
{
|
||||
path: 'src/renderer/src/components/QuickOpen.tsx',
|
||||
lowerPath: 'src/renderer/src/components/quickopen.tsx',
|
||||
lowerFilename: 'quickopen.tsx',
|
||||
inputIndex: 0
|
||||
},
|
||||
{
|
||||
path: 'packages/windows-origin/src/App.tsx',
|
||||
lowerPath: 'packages/windows-origin/src/app.tsx',
|
||||
lowerFilename: 'app.tsx',
|
||||
inputIndex: 1
|
||||
},
|
||||
{
|
||||
path: 'single-file.ts',
|
||||
lowerPath: 'single-file.ts',
|
||||
lowerFilename: 'single-file.ts',
|
||||
inputIndex: 2
|
||||
},
|
||||
{
|
||||
path: 'legacy\\provider\\raw-path.ts',
|
||||
lowerPath: 'legacy\\provider\\raw-path.ts',
|
||||
lowerFilename: 'legacy\\provider\\raw-path.ts',
|
||||
inputIndex: 3
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no results for non-positive limits', () => {
|
||||
const files = prepareQuickOpenFiles(['src/a.ts'])
|
||||
|
||||
expect(rankQuickOpenFiles('a', files, 0)).toEqual([])
|
||||
expect(rankQuickOpenFiles('a', files, -1)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
export const QUICK_OPEN_RESULT_LIMIT = 50
|
||||
|
||||
export type QuickOpenIndexedFile = {
|
||||
path: string
|
||||
lowerPath: string
|
||||
lowerFilename: string
|
||||
inputIndex: number
|
||||
}
|
||||
|
||||
export type QuickOpenSearchResult = {
|
||||
path: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexedFile[] {
|
||||
return files.map((path, inputIndex) => {
|
||||
const lastSlash = path.lastIndexOf('/')
|
||||
return {
|
||||
path,
|
||||
lowerPath: path.toLowerCase(),
|
||||
lowerFilename: path.slice(lastSlash + 1).toLowerCase(),
|
||||
inputIndex
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function rankQuickOpenFiles(
|
||||
query: string,
|
||||
files: readonly QuickOpenIndexedFile[],
|
||||
limit = QUICK_OPEN_RESULT_LIMIT
|
||||
): QuickOpenSearchResult[] {
|
||||
if (limit <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) {
|
||||
return files.slice(0, limit).map((file) => ({ path: file.path, score: 0 }))
|
||||
}
|
||||
|
||||
const results: QuickOpenRankedResult[] = []
|
||||
for (const file of files) {
|
||||
const score = fuzzyMatchIndexedFile(normalizedQuery, file)
|
||||
if (score === -1) {
|
||||
continue
|
||||
}
|
||||
|
||||
insertTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit)
|
||||
}
|
||||
|
||||
return results.map(({ path, score }) => ({ path, score }))
|
||||
}
|
||||
|
||||
function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number {
|
||||
let qi = 0
|
||||
let score = 0
|
||||
let lastMatchIdx = -1
|
||||
|
||||
for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) {
|
||||
if (file.lowerPath[ti] === query[qi]) {
|
||||
// Preserve the existing Quick Open score semantics while avoiding
|
||||
// repeated lowercase work for each candidate on every keystroke.
|
||||
const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1
|
||||
score += gap
|
||||
if (
|
||||
ti > 0 &&
|
||||
(file.lowerPath[ti - 1] === '/' ||
|
||||
file.lowerPath[ti - 1] === '.' ||
|
||||
file.lowerPath[ti - 1] === '-')
|
||||
) {
|
||||
score -= 5
|
||||
}
|
||||
lastMatchIdx = ti
|
||||
qi++
|
||||
}
|
||||
}
|
||||
|
||||
if (qi < query.length) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (file.lowerFilename.includes(query)) {
|
||||
score -= 100
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
type QuickOpenRankedResult = QuickOpenSearchResult & {
|
||||
inputIndex: number
|
||||
}
|
||||
|
||||
function insertTopResult(
|
||||
results: QuickOpenRankedResult[],
|
||||
candidate: QuickOpenRankedResult,
|
||||
limit: number
|
||||
): void {
|
||||
const worst = results.at(-1)
|
||||
if (results.length === limit && worst && compareRankedResult(candidate, worst) >= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const insertAt = findInsertionIndex(results, candidate)
|
||||
results.splice(insertAt, 0, candidate)
|
||||
if (results.length > limit) {
|
||||
results.pop()
|
||||
}
|
||||
}
|
||||
|
||||
function findInsertionIndex(
|
||||
results: readonly QuickOpenRankedResult[],
|
||||
candidate: QuickOpenRankedResult
|
||||
): number {
|
||||
let low = 0
|
||||
let high = results.length
|
||||
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
if (compareRankedResult(candidate, results[mid]) < 0) {
|
||||
high = mid
|
||||
} else {
|
||||
low = mid + 1
|
||||
}
|
||||
}
|
||||
|
||||
return low
|
||||
}
|
||||
|
||||
function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number {
|
||||
return a.score - b.score || a.inputIndex - b.inputIndex
|
||||
}
|
||||
@@ -24,6 +24,9 @@ export function ExperimentalPane({
|
||||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const showPet = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_SEARCH_ENTRY.pet])
|
||||
const showAgentsView = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_SEARCH_ENTRY.activity])
|
||||
const showTerminalAttention = matchesSettingsSearch(searchQuery, [
|
||||
EXPERIMENTAL_SEARCH_ENTRY.terminalAttention
|
||||
])
|
||||
const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [
|
||||
EXPERIMENTAL_SEARCH_ENTRY.symlinks
|
||||
])
|
||||
@@ -108,6 +111,44 @@ export function ExperimentalPane({
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
|
||||
{showTerminalAttention ? (
|
||||
<SearchableSetting
|
||||
title="Terminal attention"
|
||||
description="Persistent pane highlight for terminal bell and agent-completion events."
|
||||
keywords={EXPERIMENTAL_SEARCH_ENTRY.terminalAttention.keywords}
|
||||
className="space-y-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 shrink space-y-0.5">
|
||||
<Label>Terminal attention</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keeps a pane-level highlight visible after terminal bell or agent-completion events
|
||||
until you interact with that pane. Experimental while we tune the signal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.experimentalTerminalAttention}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
experimentalTerminalAttention: !settings.experimentalTerminalAttention
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.experimentalTerminalAttention ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
|
||||
settings.experimentalTerminalAttention ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
|
||||
{showWorktreeSymlinks ? (
|
||||
<SearchableSetting
|
||||
title="Symlinks on worktrees"
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
export { MOBILE_SETTINGS_PANE_SEARCH_ENTRIES }
|
||||
|
||||
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
|
||||
const ORCA_ANDROID_RELEASE_URL = 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9'
|
||||
const ORCA_ANDROID_RELEASE_URL = 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10'
|
||||
|
||||
type MobileSettingsPaneProps = {
|
||||
settings: GlobalSettings
|
||||
|
||||
@@ -45,6 +45,7 @@ import { SettingsSidebar } from './SettingsSidebar'
|
||||
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
|
||||
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
|
||||
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
@@ -295,6 +296,9 @@ function Settings(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
|
||||
if (isIntentionalAppRestartInProgress()) {
|
||||
return
|
||||
}
|
||||
if (!hasUnsavedCommitPromptChanges) {
|
||||
return
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user