Merge remote-tracking branch 'origin/main' into brennanb2025/feature-interaction-state

# Conflicts:
#	src/renderer/src/store/slices/terminals.ts
This commit is contained in:
brennanb2025
2026-05-28 14:37:42 -07:00
164 changed files with 10435 additions and 1534 deletions
+12 -12
View File
@@ -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
+9 -9
View File
@@ -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/
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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: |
+5 -5
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+12 -12
View File
@@ -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
+2 -2
View File
@@ -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'
+1 -1
View File
@@ -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)
---
+37
View File
@@ -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.
+131
View File
@@ -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.
+289 -63
View File
@@ -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,
+10 -2
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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
+48
View File
@@ -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
})
})
})
+2 -1
View File
@@ -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
})
+48
View File
@@ -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(() => {})
+2 -2
View File
@@ -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')
+4 -4
View File
@@ -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',
+1
View File
@@ -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',
+52
View File
@@ -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"',
+43 -8
View File
@@ -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[]>,
+104 -5
View File
@@ -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'
+38 -5
View File
@@ -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)
}
}
}
+47 -3
View File
@@ -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()
+21
View File
@@ -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?.()
+4 -4
View File
@@ -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'
+152
View File
@@ -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)
})
})
+43 -6
View File
@@ -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] : []
+63
View File
@@ -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')
)
})
})
+127
View File
@@ -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
}
+105
View File
@@ -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
View File
@@ -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
View File
@@ -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
})
}
}
)
}
+22 -1
View File
@@ -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()
})
})
+11
View File
@@ -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)
})
+37
View File
@@ -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 () => {
+3 -3
View File
@@ -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)
}
)
+140 -1
View File
@@ -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: () => ({
+17 -19
View File
@@ -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') {
+376 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+2
View File
@@ -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 })
}
+1
View File
@@ -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>
+26 -1
View File
@@ -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,
+3 -3
View File
@@ -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 }
}
+161
View File
@@ -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([
{
+93 -16
View File
@@ -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)
})
+140
View File
@@ -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',
+5 -4
View File
@@ -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)
})
})
})
+18 -7
View File
@@ -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')
}
})
]
+7
View File
@@ -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') {
+4
View File
@@ -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,
+17 -2
View File
@@ -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
+17 -4
View File
@@ -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)
}
+3
View File
@@ -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
View File
@@ -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) {
+36 -3
View File
@@ -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
}
+45
View File
@@ -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, '')
+38
View File
@@ -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')
+11
View File
@@ -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)
+54 -1
View File
@@ -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
View File
@@ -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)
+16
View File
@@ -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;
+92 -26
View File
@@ -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>
+6 -57
View File
@@ -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) => {
+129 -30
View File
@@ -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>
)
+5 -6
View File
@@ -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) {
@@ -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)
})
})
@@ -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