mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/codex-broken-status
This commit is contained in:
@@ -9,6 +9,9 @@ on:
|
||||
- ready_for_review
|
||||
paths:
|
||||
- 'mobile/**'
|
||||
# Why: the mobile terminal link parsers are conformance-tested against
|
||||
# these shared fixtures; desktop-side fixture edits must re-run this suite.
|
||||
- 'src/shared/terminal-file-link-conformance.ts'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Cut Release
|
||||
|
||||
# Why: single entry point for manually and scheduled cutting releases.
|
||||
# Why: single entry point for manually cutting releases.
|
||||
# Replaces the old local `pnpm release:*` scripts and the standalone scheduled
|
||||
# RC workflow so releases are always reproducible from CI and can never be
|
||||
# accidentally tagged against an uncommitted or non-main working tree.
|
||||
@@ -37,16 +37,10 @@ on:
|
||||
type: string
|
||||
default: main
|
||||
dry_run:
|
||||
description: Validate a scheduled-RC run without creating a tag
|
||||
description: Validate an RC release cut without creating a tag
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
schedule:
|
||||
# Why: GitHub scheduled workflows are not guaranteed to start exactly on
|
||||
# time and can be delayed or dropped around high-load periods. We retry
|
||||
# across the full target hour and dedupe per PT slot. Cron is UTC, so run
|
||||
# during both PST and PDT equivalents and let the PT-hour gate decide.
|
||||
- cron: '*/5 10,11,22,23 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -182,8 +176,8 @@ jobs:
|
||||
|
||||
- name: Publish complete release-cut RC drafts from prior runs
|
||||
id: publish_drafts
|
||||
# Why: this must run even when the current slot already cut a tag; a
|
||||
# later cron retry may be the first chance to unstick a complete RC draft.
|
||||
# Why: a manual RC dispatch should unstick any complete RC draft before
|
||||
# deciding whether to cut another tag.
|
||||
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -889,6 +883,11 @@ jobs:
|
||||
if: matrix.platform == 'win'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Why: Some hosted Windows images start without PSGallery registered.
|
||||
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSRepository -Default -InstallationPolicy Trusted
|
||||
}
|
||||
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
||||
|
||||
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -194,6 +194,10 @@ describe('Electron runtime package contract', () => {
|
||||
|
||||
expect(installStep.if).toBe("matrix.platform == 'win'")
|
||||
expect(installStep.shell).toBe('pwsh')
|
||||
expect(installRun).toContain(
|
||||
'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
|
||||
)
|
||||
expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
|
||||
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
|
||||
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
|
||||
expect(installRun).toContain(
|
||||
@@ -217,6 +221,23 @@ describe('Electron runtime package contract', () => {
|
||||
expect(installRun).not.toMatch(/throw\s+\$_/)
|
||||
})
|
||||
|
||||
it('temporarily allows publishing Windows after verifying the signed installer only', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const parsedWorkflow = parse(releaseWorkflow)
|
||||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
|
||||
const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable')
|
||||
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
|
||||
|
||||
expect(outerVerifyIndex).toBeGreaterThan(-1)
|
||||
expect(innerVerifyIndex).toBe(-1)
|
||||
expect(publishIndex).toBe(outerVerifyIndex + 1)
|
||||
})
|
||||
|
||||
it('publishes both Linux release matrix entries', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {
|
||||
createWslFixture,
|
||||
listWslDistros,
|
||||
removeWslFixture
|
||||
} from './windows-apphang-repro/wsl-workspace-fixture.mjs'
|
||||
import { runGpuMode } from './windows-apphang-repro/terminal-activation-scenario.mjs'
|
||||
import { summarizeResult } from './windows-apphang-repro/apphang-report-summary.mjs'
|
||||
|
||||
const defaultCycles = 14
|
||||
const defaultOutputLines = 1_600
|
||||
|
||||
function parseArgs() {
|
||||
const args = {
|
||||
cycles: defaultCycles,
|
||||
distro: null,
|
||||
expect: 'none',
|
||||
gpuModes: ['on'],
|
||||
keep: false,
|
||||
outputLines: defaultOutputLines,
|
||||
reportPath: null,
|
||||
sourceControl: true,
|
||||
deadPtyReactivate: true
|
||||
}
|
||||
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
if (arg === '--keep') {
|
||||
args.keep = true
|
||||
continue
|
||||
}
|
||||
if (arg === '--no-source-control') {
|
||||
args.sourceControl = false
|
||||
continue
|
||||
}
|
||||
if (arg === '--no-dead-pty-reactivate') {
|
||||
args.deadPtyReactivate = false
|
||||
continue
|
||||
}
|
||||
const [name, value] = arg.split('=', 2)
|
||||
if (name === '--cycles') {
|
||||
args.cycles = parsePositiveInt(name, value)
|
||||
continue
|
||||
}
|
||||
if (name === '--distro') {
|
||||
args.distro = value?.trim() || null
|
||||
continue
|
||||
}
|
||||
if (name === '--expect') {
|
||||
if (!['none', 'repro', 'pass'].includes(value)) {
|
||||
throw new Error(`Unsupported --expect=${value}. Use none, repro, or pass.`)
|
||||
}
|
||||
args.expect = value
|
||||
continue
|
||||
}
|
||||
if (name === '--gpu') {
|
||||
const modes = (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
if (modes.length === 0 || modes.some((mode) => !['on', 'off', 'auto'].includes(mode))) {
|
||||
throw new Error(`Unsupported --gpu=${value}. Use on, off, auto, or a comma-list.`)
|
||||
}
|
||||
args.gpuModes = modes
|
||||
continue
|
||||
}
|
||||
if (name === '--output-lines') {
|
||||
args.outputLines = parsePositiveInt(name, value)
|
||||
continue
|
||||
}
|
||||
if (name === '--report') {
|
||||
args.reportPath = value?.trim() || null
|
||||
if (!args.reportPath) {
|
||||
throw new Error('--report requires a file path.')
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage:
|
||||
node config/scripts/repro-windows-apphang-terminal-activation.mjs [options]
|
||||
|
||||
Options:
|
||||
--expect=none|repro|pass none prints measurements, repro exits 0 only when hang evidence is observed,
|
||||
pass exits 1 if hang evidence is observed. Default: none.
|
||||
--gpu=on|off|auto[,mode...] Terminal GPU setting(s) to run. Default: on.
|
||||
--cycles=N Activation/output cycles per GPU mode. Default: ${defaultCycles}.
|
||||
--output-lines=N Lines emitted by each terminal stress command. Default: ${defaultOutputLines}.
|
||||
--report=PATH Write full JSON evidence to PATH and print a compact summary to stdout.
|
||||
--distro=NAME WSL distro to use. Default: first non docker-desktop distro.
|
||||
--no-source-control Do not open Source Control during the stress loop.
|
||||
--no-dead-pty-reactivate Do not kill PTYs and revisit workspaces after initial activation.
|
||||
--keep Keep disposable WSL/userData fixtures after the run.`)
|
||||
}
|
||||
|
||||
function parsePositiveInt(name, value) {
|
||||
const parsed = Number.parseInt(value ?? '', 10)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
|
||||
throw new Error(`${name} requires a positive integer.`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This repro harness is intentionally Windows-only.')
|
||||
}
|
||||
const args = parseArgs()
|
||||
const distros = listWslDistros()
|
||||
const distro = args.distro ?? distros[0]
|
||||
if (!distro) {
|
||||
throw new Error('No user WSL distro found. Install/enable WSL or pass --distro=NAME.')
|
||||
}
|
||||
console.log(
|
||||
`[apphang-repro] issue=https://github.com/stablyai/orca/issues/6874 distro=${distro} gpuModes=${args.gpuModes.join(',')} cycles=${args.cycles}`
|
||||
)
|
||||
const fixture = createWslFixture(distro)
|
||||
console.log(
|
||||
`[apphang-repro] fixture repo=${fixture.repoUncPath} plain=${fixture.plainUncPath} base=${fixture.baseLinuxPath}`
|
||||
)
|
||||
|
||||
const results = []
|
||||
try {
|
||||
for (const gpuMode of args.gpuModes) {
|
||||
results.push(await runGpuMode(gpuMode, args, fixture))
|
||||
if (args.expect === 'repro' && results.at(-1)?.reproduced) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!args.keep) {
|
||||
removeWslFixture(fixture)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
issue: 'https://github.com/stablyai/orca/issues/6874',
|
||||
expect: args.expect,
|
||||
distro,
|
||||
fixture: args.keep ? fixture : { removed: true, baseLinuxPath: fixture.baseLinuxPath },
|
||||
summary: results.map(summarizeResult),
|
||||
results
|
||||
}
|
||||
if (args.reportPath) {
|
||||
const reportPath = path.resolve(args.reportPath)
|
||||
mkdirSync(path.dirname(reportPath), { recursive: true })
|
||||
writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`)
|
||||
console.log(JSON.stringify({ reportPath, summary: payload.summary }, null, 2))
|
||||
} else {
|
||||
console.log(JSON.stringify(payload, null, 2))
|
||||
}
|
||||
|
||||
// Why: a broken harness run (CDP/setup/selector failure) proves nothing —
|
||||
// it must fail both --expect=repro and --expect=pass rather than being
|
||||
// miscounted as hang evidence or as a clean pass.
|
||||
const harnessErrors = results.filter((result) => result.harnessError)
|
||||
if (harnessErrors.length > 0) {
|
||||
const harnessErrorLines = harnessErrors
|
||||
.map((result) => ` gpu=${result.gpuMode}: ${result.harnessError}`)
|
||||
.join('\n')
|
||||
console.error(
|
||||
`[apphang-repro] Harness failed in ${harnessErrors.length} run(s); result inconclusive:\n${harnessErrorLines}`
|
||||
)
|
||||
if (args.expect !== 'none') {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
const reproduced = results.some((result) => result.reproduced)
|
||||
if (args.expect === 'repro' && !reproduced) {
|
||||
console.error(
|
||||
'[apphang-repro] Expected to reproduce the hang, but no hang evidence was observed.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
if (args.expect === 'pass' && reproduced) {
|
||||
console.error('[apphang-repro] Expected a clean pass, but hang evidence was observed.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export const DEFAULT_EXPECTED_SIGNER =
|
||||
'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US'
|
||||
|
||||
const POWERSHELL_SIGNATURE_SCRIPT = String.raw`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$signature = Get-AuthenticodeSignature -FilePath $env:ORCA_WINDOWS_INNER_EXECUTABLE
|
||||
$certificate = $signature.SignerCertificate
|
||||
[pscustomobject]@{
|
||||
status = $signature.Status.ToString()
|
||||
statusMessage = $signature.StatusMessage
|
||||
signerSubject = if ($null -eq $certificate) { $null } else { $certificate.Subject }
|
||||
signerIssuer = if ($null -eq $certificate) { $null } else { $certificate.Issuer }
|
||||
signerThumbprint = if ($null -eq $certificate) { $null } else { $certificate.Thumbprint }
|
||||
notBefore = if ($null -eq $certificate) { $null } else { $certificate.NotBefore.ToString('o') }
|
||||
notAfter = if ($null -eq $certificate) { $null } else { $certificate.NotAfter.ToString('o') }
|
||||
} | ConvertTo-Json -Compress
|
||||
`
|
||||
|
||||
export function normalizeSignerSubject(subject) {
|
||||
if (typeof subject !== 'string') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return subject
|
||||
.split(',')
|
||||
.map((part) => part.trim().replace(/\s*=\s*/u, '='))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
export function normalizeThumbprint(thumbprint) {
|
||||
if (typeof thumbprint !== 'string') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return thumbprint.replace(/[^0-9a-f]/giu, '').toUpperCase()
|
||||
}
|
||||
|
||||
export function parseExpectedSigners(value = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS) {
|
||||
const source = typeof value === 'string' && value.trim() !== '' ? value : DEFAULT_EXPECTED_SIGNER
|
||||
|
||||
return source
|
||||
.split(/[\r\n;]+/u)
|
||||
.map(normalizeSignerSubject)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function parseExpectedThumbprints(value = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS) {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.split(/[\r\n,;]+/u)
|
||||
.map(normalizeThumbprint)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function parseSignatureJson(stdout) {
|
||||
const trimmed = typeof stdout === 'string' ? stdout.trim() : ''
|
||||
if (trimmed === '') {
|
||||
throw new Error('PowerShell did not return signature JSON.')
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch (error) {
|
||||
throw new Error(`PowerShell returned malformed signature JSON: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function classifySignature(signature, options = {}) {
|
||||
const expectedSigners = options.expectedSigners ?? parseExpectedSigners()
|
||||
const expectedThumbprints = options.expectedThumbprints ?? parseExpectedThumbprints()
|
||||
const status = typeof signature?.status === 'string' ? signature.status : ''
|
||||
const signerSubject = normalizeSignerSubject(signature?.signerSubject)
|
||||
const signerThumbprint = normalizeThumbprint(signature?.signerThumbprint)
|
||||
const subjectAllowed = expectedSigners.includes(signerSubject)
|
||||
const thumbprintAllowed =
|
||||
expectedThumbprints.length > 0 &&
|
||||
signerThumbprint !== '' &&
|
||||
expectedThumbprints.includes(signerThumbprint)
|
||||
|
||||
if (status !== 'Valid') {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Windows inner executable signature status is ${status || '<missing>'}.`,
|
||||
signature
|
||||
}
|
||||
}
|
||||
|
||||
if (!subjectAllowed && !thumbprintAllowed) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Unexpected Windows inner executable signer: ${signerSubject || '<missing>'}.`,
|
||||
signature
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, signature }
|
||||
}
|
||||
|
||||
export function formatSignatureSummary(signature) {
|
||||
return [
|
||||
`Status: ${signature.status ?? '<missing>'}`,
|
||||
`Subject: ${normalizeSignerSubject(signature.signerSubject) || '<missing>'}`,
|
||||
`Issuer: ${signature.signerIssuer ?? '<missing>'}`,
|
||||
`Thumbprint: ${normalizeThumbprint(signature.signerThumbprint) || '<missing>'}`,
|
||||
`NotBefore: ${signature.notBefore ?? '<missing>'}`,
|
||||
`NotAfter: ${signature.notAfter ?? '<missing>'}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function validateExecutablePath(executablePath) {
|
||||
if (typeof executablePath !== 'string' || executablePath.trim() === '') {
|
||||
throw new Error('Usage: node config/scripts/verify-windows-inner-signature.mjs <Orca.exe>')
|
||||
}
|
||||
|
||||
if (!existsSync(executablePath)) {
|
||||
throw new Error(`Windows inner executable does not exist: ${executablePath}`)
|
||||
}
|
||||
|
||||
if (!statSync(executablePath).isFile()) {
|
||||
throw new Error(`Windows inner executable path is not a file: ${executablePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawnSync) {
|
||||
// Why: pwsh -Command does not reliably expose trailing process args to string commands.
|
||||
const result = spawnSyncImpl(
|
||||
'pwsh',
|
||||
[
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
POWERSHELL_SIGNATURE_SCRIPT
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_WINDOWS_INNER_EXECUTABLE: executablePath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
if (result.stderr?.trim()) {
|
||||
throw new Error(`PowerShell wrote to stderr while checking signature:\n${result.stderr.trim()}`)
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`PowerShell signature check failed with exit code ${result.status ?? '<unknown>'}.`
|
||||
)
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
export function verifyWindowsInnerSignature({
|
||||
executablePath,
|
||||
platform = process.platform,
|
||||
spawnSyncImpl = spawnSync,
|
||||
expectedSigners = parseExpectedSigners(),
|
||||
expectedThumbprints = parseExpectedThumbprints()
|
||||
}) {
|
||||
validateExecutablePath(executablePath)
|
||||
|
||||
if (platform !== 'win32') {
|
||||
throw new Error('Windows inner executable signature verification requires Windows.')
|
||||
}
|
||||
|
||||
const signature = parseSignatureJson(getPowerShellSignatureJson(executablePath, spawnSyncImpl))
|
||||
const classification = classifySignature(signature, { expectedSigners, expectedThumbprints })
|
||||
if (!classification.ok) {
|
||||
throw new Error(`${classification.message}\n${formatSignatureSummary(signature)}`)
|
||||
}
|
||||
|
||||
return signature
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
try {
|
||||
const signature = verifyWindowsInnerSignature({ executablePath: argv[0] })
|
||||
console.log('Verified Windows inner executable signature.')
|
||||
console.log(formatSignatureSummary(signature))
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_EXPECTED_SIGNER,
|
||||
classifySignature,
|
||||
getPowerShellSignatureJson,
|
||||
normalizeSignerSubject,
|
||||
normalizeThumbprint,
|
||||
parseExpectedSigners,
|
||||
parseExpectedThumbprints,
|
||||
parseSignatureJson,
|
||||
validateExecutablePath,
|
||||
verifyWindowsInnerSignature
|
||||
} from './verify-windows-inner-signature.mjs'
|
||||
|
||||
const validSignature = {
|
||||
status: 'Valid',
|
||||
statusMessage: 'Signature verified.',
|
||||
signerSubject: DEFAULT_EXPECTED_SIGNER,
|
||||
signerIssuer: 'CN=SignPath Foundation Root',
|
||||
signerThumbprint: 'aa bb cc dd',
|
||||
notBefore: '2026-01-01T00:00:00.0000000Z',
|
||||
notAfter: '2027-01-01T00:00:00.0000000Z'
|
||||
}
|
||||
|
||||
function withTempFile(callback) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-inner-signature-'))
|
||||
const filePath = join(dir, 'Orca.exe')
|
||||
writeFileSync(filePath, 'placeholder executable')
|
||||
|
||||
try {
|
||||
return callback(filePath, dir)
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('verify-windows-inner-signature', () => {
|
||||
const originalExpectedSigners = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
|
||||
const originalExpectedThumbprints = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
|
||||
delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalExpectedSigners === undefined) {
|
||||
delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
|
||||
} else {
|
||||
process.env.ORCA_WINDOWS_EXPECTED_SIGNERS = originalExpectedSigners
|
||||
}
|
||||
|
||||
if (originalExpectedThumbprints === undefined) {
|
||||
delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
|
||||
} else {
|
||||
process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS = originalExpectedThumbprints
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes signer subjects without widening exact matching', () => {
|
||||
expect(
|
||||
normalizeSignerSubject(
|
||||
' CN = SignPath Foundation , O=SignPath Foundation,L=Lewes,S=Delaware,C=US '
|
||||
)
|
||||
).toBe(DEFAULT_EXPECTED_SIGNER)
|
||||
expect(
|
||||
normalizeSignerSubject(
|
||||
'CN=Different Signer, O=SignPath Foundation, L=Lewes, S=Delaware, C=US'
|
||||
)
|
||||
).not.toBe(DEFAULT_EXPECTED_SIGNER)
|
||||
})
|
||||
|
||||
it('defaults the expected signer allowlist to SignPath Foundation', () => {
|
||||
expect(parseExpectedSigners('')).toEqual([DEFAULT_EXPECTED_SIGNER])
|
||||
expect(parseExpectedSigners(' ')).toEqual([DEFAULT_EXPECTED_SIGNER])
|
||||
})
|
||||
|
||||
it('parses semicolon and newline separated signer allowlists', () => {
|
||||
expect(parseExpectedSigners(`${DEFAULT_EXPECTED_SIGNER};\nCN=Backup, O=Backup`)).toEqual([
|
||||
DEFAULT_EXPECTED_SIGNER,
|
||||
'CN=Backup, O=Backup'
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes optional thumbprint allowlists', () => {
|
||||
expect(normalizeThumbprint('aa bb:cc')).toBe('AABBCC')
|
||||
expect(parseExpectedThumbprints('aa bb cc,11:22:33')).toEqual(['AABBCC', '112233'])
|
||||
})
|
||||
|
||||
it('rejects missing, nonexistent, and directory executable paths before PowerShell', () => {
|
||||
expect(() => validateExecutablePath('')).toThrow(/Usage:/)
|
||||
expect(() => validateExecutablePath(join(tmpdir(), 'missing-Orca.exe'))).toThrow(
|
||||
/does not exist/
|
||||
)
|
||||
|
||||
withTempFile((filePath, dir) => {
|
||||
expect(() => validateExecutablePath(dir)).toThrow(/not a file/)
|
||||
expect(() => validateExecutablePath(filePath)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('parses the exact JSON emitted by PowerShell', () => {
|
||||
expect(parseSignatureJson(JSON.stringify(validSignature))).toEqual(validSignature)
|
||||
expect(() => parseSignatureJson('')).toThrow(/did not return/)
|
||||
expect(() => parseSignatureJson(`${JSON.stringify(validSignature)}\nextra`)).toThrow(
|
||||
/malformed/
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a valid signature with an exact normalized signer subject', () => {
|
||||
const result = classifySignature({
|
||||
...validSignature,
|
||||
signerSubject: ' CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US '
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid status and unexpected signer subjects', () => {
|
||||
expect(classifySignature({ ...validSignature, status: 'NotSigned' }).message).toMatch(
|
||||
/status is NotSigned/
|
||||
)
|
||||
expect(
|
||||
classifySignature({
|
||||
...validSignature,
|
||||
signerSubject: 'CN=SignPath Foundation Test, O=SignPath Foundation, L=Vienna, C=AT'
|
||||
}).message
|
||||
).toMatch(/Unexpected Windows inner executable signer/)
|
||||
})
|
||||
|
||||
it('accepts an expected thumbprint as an alternate explicit allowlist', () => {
|
||||
expect(classifySignature({ ...validSignature, signerThumbprint: '00' }).ok).toBe(true)
|
||||
expect(
|
||||
classifySignature(
|
||||
{ ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' },
|
||||
{
|
||||
expectedSigners: [DEFAULT_EXPECTED_SIGNER],
|
||||
expectedThumbprints: ['AABBCCDD']
|
||||
}
|
||||
).ok
|
||||
).toBe(true)
|
||||
expect(
|
||||
classifySignature(
|
||||
{ ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' },
|
||||
{
|
||||
expectedSigners: [DEFAULT_EXPECTED_SIGNER],
|
||||
expectedThumbprints: ['001122']
|
||||
}
|
||||
).message
|
||||
).toMatch(/Unexpected Windows inner executable signer/)
|
||||
expect(
|
||||
classifySignature(validSignature, {
|
||||
expectedSigners: [DEFAULT_EXPECTED_SIGNER],
|
||||
expectedThumbprints: ['001122']
|
||||
}).ok
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('runs PowerShell with an argument array and fails on stderr or nonzero exit', () => {
|
||||
const calls = []
|
||||
const spawnSyncImpl = (command, args, options) => {
|
||||
calls.push({ command, args, options })
|
||||
return { status: 0, stdout: JSON.stringify(validSignature), stderr: '' }
|
||||
}
|
||||
|
||||
expect(getPowerShellSignatureJson('C:\\Path With Spaces\\Orca.exe', spawnSyncImpl)).toBe(
|
||||
JSON.stringify(validSignature)
|
||||
)
|
||||
expect(calls[0].command).toBe('pwsh')
|
||||
expect(calls[0].args).toContain('-Command')
|
||||
expect(calls[0].args.at(-1)).not.toBe('C:\\Path With Spaces\\Orca.exe')
|
||||
expect(calls[0].options).toEqual(
|
||||
expect.objectContaining({
|
||||
encoding: 'utf8',
|
||||
env: expect.objectContaining({
|
||||
ORCA_WINDOWS_INNER_EXECUTABLE: 'C:\\Path With Spaces\\Orca.exe'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
getPowerShellSignatureJson('Orca.exe', () => ({ status: 0, stdout: '{}', stderr: 'warning' }))
|
||||
).toThrow(/stderr/)
|
||||
expect(() =>
|
||||
getPowerShellSignatureJson('Orca.exe', () => ({ status: 7, stdout: '', stderr: '' }))
|
||||
).toThrow(/exit code 7/)
|
||||
})
|
||||
|
||||
it('verifies with injected Windows platform and spawn implementation', () => {
|
||||
withTempFile((filePath) => {
|
||||
const signature = verifyWindowsInnerSignature({
|
||||
executablePath: filePath,
|
||||
platform: 'win32',
|
||||
spawnSyncImpl: () => ({ status: 0, stdout: JSON.stringify(validSignature), stderr: '' })
|
||||
})
|
||||
|
||||
expect(signature).toEqual(validSignature)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not attempt real Authenticode verification outside Windows', () => {
|
||||
withTempFile((filePath) => {
|
||||
expect(() =>
|
||||
verifyWindowsInnerSignature({
|
||||
executablePath: filePath,
|
||||
platform: 'linux',
|
||||
spawnSyncImpl: () => {
|
||||
throw new Error('should not spawn')
|
||||
}
|
||||
})
|
||||
).toThrow(/requires Windows/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export function summarizeTrace(userDataDir) {
|
||||
const tracePath = path.join(userDataDir, 'logs', 'main.trace.ndjson')
|
||||
if (!existsSync(tracePath)) {
|
||||
return { tracePath, exists: false }
|
||||
}
|
||||
const lines = readFileSync(tracePath, 'utf8').trim().split(/\r?\n/).filter(Boolean)
|
||||
const parsed = []
|
||||
for (const line of lines.slice(-400)) {
|
||||
try {
|
||||
parsed.push(JSON.parse(line))
|
||||
} catch {
|
||||
parsed.push({ raw: line })
|
||||
}
|
||||
}
|
||||
const interesting = parsed.filter((entry) => {
|
||||
const name = String(entry.name ?? entry.event ?? entry.type ?? '')
|
||||
const text = JSON.stringify(entry)
|
||||
return (
|
||||
name.includes('git.exec') ||
|
||||
name.includes('renderer_memory') ||
|
||||
name.includes('sidebar_worktree_activate') ||
|
||||
text.includes('git.exec remote') ||
|
||||
text.includes('git.exec worktree') ||
|
||||
text.includes('sidebar_worktree_activate')
|
||||
)
|
||||
})
|
||||
return {
|
||||
tracePath,
|
||||
exists: true,
|
||||
totalLines: lines.length,
|
||||
interestingTail: interesting.slice(-40)
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeAppLogs(logs) {
|
||||
const webglContextWarnings = logs.filter((entry) =>
|
||||
entry.line.includes('Too many active WebGL contexts')
|
||||
)
|
||||
const webglContextLosses = logs.filter((entry) =>
|
||||
entry.line.toLowerCase().includes('webgl context lost')
|
||||
)
|
||||
const gpuProcessEvents = logs.filter((entry) => /gpu|d3d|angle/i.test(entry.line))
|
||||
return {
|
||||
lineCount: logs.length,
|
||||
webglContextWarningCount: webglContextWarnings.length,
|
||||
webglContextWarningsTail: webglContextWarnings.slice(-10),
|
||||
webglContextLossCount: webglContextLosses.length,
|
||||
webglContextLossesTail: webglContextLosses.slice(-10),
|
||||
gpuProcessEventCount: gpuProcessEvents.length,
|
||||
gpuProcessEventsTail: gpuProcessEvents.slice(-20)
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeResult(result) {
|
||||
const samples = result.samples.filter((sample) => sample && typeof sample === 'object')
|
||||
const maxActivationMs = Math.max(0, ...samples.map((sample) => sample.activationMs ?? 0))
|
||||
const maxPtyWaitMs = Math.max(0, ...samples.map((sample) => sample.ptyWaitMs ?? 0))
|
||||
const maxMainIpcMs = Math.max(0, ...samples.map((sample) => sample.mainIpcMs ?? 0))
|
||||
const maxRendererDriftMs = Math.max(0, ...samples.map((sample) => sample.rendererMaxDriftMs ?? 0))
|
||||
return {
|
||||
gpuMode: result.gpuMode,
|
||||
reproduced: result.reproduced,
|
||||
reproductionReason: result.reproductionReason,
|
||||
harnessError: result.harnessError ?? null,
|
||||
elapsedMs: result.elapsedMs,
|
||||
sampleCount: samples.length,
|
||||
maxActivationMs,
|
||||
maxPtyWaitMs,
|
||||
maxMainIpcMs,
|
||||
maxRendererDriftMs,
|
||||
finalWebglContextCounts: result.finalDiagnostics?.webglContextCounts ?? null,
|
||||
appLogSummary: result.appLogSummary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
appShutdownTimeoutMs,
|
||||
cdpPollTimeoutMs,
|
||||
delay,
|
||||
pollUntil,
|
||||
rendererActionTimeoutMs,
|
||||
runWithTimeout
|
||||
} from './repro-timing.mjs'
|
||||
import { createCompletedOnboardingProfile } from './wsl-workspace-fixture.mjs'
|
||||
|
||||
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
|
||||
|
||||
function isPortFree(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.once('error', () => {
|
||||
try {
|
||||
server.close()
|
||||
} catch {}
|
||||
resolve(false)
|
||||
})
|
||||
server.once('listening', () => server.close(() => resolve(true)))
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
export async function pickFreePort() {
|
||||
for (let port = 9533; port < 9633; port += 1) {
|
||||
if (await isPortFree(port)) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
throw new Error('Could not find a free CDP port in 9533..9632.')
|
||||
}
|
||||
|
||||
export function createGpuUserDataDirectory(gpuMode) {
|
||||
const userDataDir = mkdtempSync(path.join(os.tmpdir(), `orca-apphang-${gpuMode}-userdata-`))
|
||||
createCompletedOnboardingProfile(userDataDir)
|
||||
return userDataDir
|
||||
}
|
||||
|
||||
export function launchDevApp({ cdpPort, userDataDir }) {
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
Object.assign(env, {
|
||||
ELECTRON_ENABLE_LOGGING: '1',
|
||||
ELECTRON_ENABLE_STACK_DUMPING: '1',
|
||||
NODE_ENV: 'development',
|
||||
ORCA_DEV_USER_DATA_PATH: userDataDir,
|
||||
ORCA_SKIP_DEV_WEB_PREPARE: '1',
|
||||
ORCA_STARTUP_DIAGNOSTICS: '1',
|
||||
REMOTE_DEBUGGING_PORT: String(cdpPort),
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
})
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[path.join('config', 'scripts', 'run-electron-vite-dev.mjs')],
|
||||
{
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
const logs = []
|
||||
const collect = (source) => (chunk) => {
|
||||
const text = chunk.toString()
|
||||
for (const line of text.split(/\r?\n/).filter(Boolean)) {
|
||||
logs.push({ source, line, at: Date.now() })
|
||||
console.log(`[apphang-repro:${source}] ${line}`)
|
||||
}
|
||||
}
|
||||
child.stdout?.on('data', collect('stdout'))
|
||||
child.stderr?.on('data', collect('stderr'))
|
||||
return { child, logs }
|
||||
}
|
||||
|
||||
export async function stopDevApp(child) {
|
||||
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, appShutdownTimeoutMs)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
if (process.platform === 'win32') {
|
||||
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
killer.once('exit', () => undefined)
|
||||
return
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForCdp(port) {
|
||||
const url = `http://127.0.0.1:${port}/json`
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < cdpPollTimeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) {
|
||||
const targets = await response.json()
|
||||
if (Array.isArray(targets) && targets.some((target) => target.type)) {
|
||||
return targets
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
await delay(500)
|
||||
}
|
||||
throw new Error(`Timed out waiting for CDP targets on ${url}`)
|
||||
}
|
||||
|
||||
async function getMainPage(browser) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < cdpPollTimeoutMs) {
|
||||
for (const context of browser.contexts()) {
|
||||
const pages = context.pages()
|
||||
const page =
|
||||
pages.find((candidate) =>
|
||||
/^https?:\/\/127\.0\.0\.1:|^https?:\/\/localhost:/.test(candidate.url())
|
||||
) ?? pages[0]
|
||||
if (page) {
|
||||
return page
|
||||
}
|
||||
}
|
||||
await delay(250)
|
||||
}
|
||||
throw new Error('Timed out waiting for the Electron renderer page.')
|
||||
}
|
||||
|
||||
export async function connectToApp(cdpPort) {
|
||||
await waitForCdp(cdpPort)
|
||||
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
|
||||
const page = await getMainPage(browser)
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 })
|
||||
return { browser, page }
|
||||
}
|
||||
|
||||
export async function installRendererProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
if (globalThis.__orcaApphangProbe) {
|
||||
return
|
||||
}
|
||||
const probe = {
|
||||
intervalMs: 50,
|
||||
last: performance.now(),
|
||||
maxDriftMs: 0,
|
||||
samples: 0,
|
||||
startedAt: performance.now(),
|
||||
lastTickAt: performance.now()
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
const drift = Math.max(0, now - probe.last - probe.intervalMs)
|
||||
probe.maxDriftMs = Math.max(probe.maxDriftMs, drift)
|
||||
probe.last = now
|
||||
probe.lastTickAt = now
|
||||
probe.samples += 1
|
||||
}, probe.intervalMs)
|
||||
globalThis.__orcaApphangProbe = { probe, timer }
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForStoreReady(page) {
|
||||
await pollUntil(
|
||||
'renderer store exposure',
|
||||
() => page.evaluate(() => Boolean(window.__store && window.api)),
|
||||
Boolean,
|
||||
30_000
|
||||
)
|
||||
await pollUntil(
|
||||
'workspace session hydration',
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const state = window.__store?.getState?.()
|
||||
return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded)
|
||||
}),
|
||||
Boolean,
|
||||
45_000
|
||||
)
|
||||
}
|
||||
|
||||
export async function collectRendererDiagnostics(page) {
|
||||
if (!page) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await runWithTimeout(
|
||||
'renderer diagnostics',
|
||||
() =>
|
||||
page.evaluate(async () => {
|
||||
const timed = (label, promise) =>
|
||||
Promise.race([
|
||||
Promise.resolve(promise).then(
|
||||
(value) => ({ value }),
|
||||
(error) => ({ error: error instanceof Error ? error.message : String(error) })
|
||||
),
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ error: `Timed out collecting ${label}` }), 1_000)
|
||||
)
|
||||
])
|
||||
const readWebglIdentity = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
let gl = null
|
||||
try {
|
||||
gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl')
|
||||
if (!gl) {
|
||||
return { available: false, vendor: null, renderer: null }
|
||||
}
|
||||
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
|
||||
if (!debugInfo) {
|
||||
return { available: true, vendor: null, renderer: null }
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
vendor: String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) ?? '') || null,
|
||||
renderer: String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? '') || null
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
gl?.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
} catch {}
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
}
|
||||
const allPaneManagersDiagnostics = Array.from(
|
||||
window.__paneManagers?.entries?.() ?? []
|
||||
).map(([managerTabId, paneManager]) => ({
|
||||
tabId: managerTabId,
|
||||
diagnostics: paneManager?.getRenderingDiagnostics?.() ?? []
|
||||
}))
|
||||
const webglContextCounts = allPaneManagersDiagnostics.reduce(
|
||||
(acc, entry) => {
|
||||
acc.managerCount += 1
|
||||
acc.paneCount += entry.diagnostics.length
|
||||
for (const diagnostic of entry.diagnostics) {
|
||||
if (diagnostic.hasWebgl) {
|
||||
acc.attachedWebglCount += 1
|
||||
}
|
||||
if (diagnostic.webglAttachmentDeferred) {
|
||||
acc.deferredWebglCount += 1
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ attachedWebglCount: 0, deferredWebglCount: 0, managerCount: 0, paneCount: 0 }
|
||||
)
|
||||
const state = window.__store?.getState?.()
|
||||
const worktreeId = state?.activeWorktreeId ?? null
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
|
||||
const buffer = activePane?.terminal?.buffer?.active ?? null
|
||||
return {
|
||||
hasStore: Boolean(window.__store),
|
||||
workspaceSessionReady: state?.workspaceSessionReady ?? null,
|
||||
hydrationSucceeded: state?.hydrationSucceeded ?? null,
|
||||
activeView: state?.activeView ?? null,
|
||||
activeRepoId: state?.activeRepoId ?? null,
|
||||
activeWorktreeId: worktreeId,
|
||||
activeTabType: state?.activeTabType ?? null,
|
||||
activeTabId: tabId,
|
||||
terminalGpuAcceleration: state?.settings?.terminalGpuAcceleration ?? null,
|
||||
repoCount: state?.repos?.length ?? null,
|
||||
worktreeCountsByRepo: Object.fromEntries(
|
||||
Object.entries(state?.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [
|
||||
repoId,
|
||||
worktrees.length
|
||||
])
|
||||
),
|
||||
ptyIdsByTabId: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [],
|
||||
paneManagerCount: window.__paneManagers?.size ?? null,
|
||||
activePane: activePane
|
||||
? {
|
||||
id: activePane.id ?? null,
|
||||
leafId: activePane.leafId ?? null,
|
||||
ptyId: activePane.container?.dataset?.ptyId ?? null,
|
||||
cols: activePane.terminal?.cols ?? null,
|
||||
rows: activePane.terminal?.rows ?? null,
|
||||
buffer: buffer
|
||||
? {
|
||||
baseY: buffer.baseY,
|
||||
viewportY: buffer.viewportY,
|
||||
cursorY: buffer.cursorY,
|
||||
length: buffer.length
|
||||
}
|
||||
: null
|
||||
}
|
||||
: null,
|
||||
renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null,
|
||||
allPaneManagersDiagnostics,
|
||||
webglContextCounts,
|
||||
webglIdentity: readWebglIdentity(),
|
||||
rendererProbe: globalThis.__orcaApphangProbe?.probe ?? null,
|
||||
ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()),
|
||||
rendererDeliveryDebug: await timed(
|
||||
'renderer delivery debug',
|
||||
window.api?.pty?.getRendererDeliveryDebugSnapshot?.()
|
||||
)
|
||||
}
|
||||
}),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const cdpPollTimeoutMs = 90_000
|
||||
export const rendererActionTimeoutMs = 5_000
|
||||
export const activationTimeoutMs = 15_000
|
||||
export const ptyWaitTimeoutMs = 20_000
|
||||
export const terminalMarkerTimeoutMs = 45_000
|
||||
export const setupTimeoutMs = 90_000
|
||||
export const appShutdownTimeoutMs = 8_000
|
||||
export const severeRendererDriftMs = 2_000
|
||||
export const severeActivationMs = 5_000
|
||||
export const severeMainIpcMs = 2_000
|
||||
export const severePtyWaitMs = 10_000
|
||||
|
||||
export function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function runWithTimeout(label, action, timeoutMs) {
|
||||
const actionPromise = Promise.resolve().then(action)
|
||||
actionPromise.catch(() => undefined)
|
||||
const result = await Promise.race([
|
||||
actionPromise.then(
|
||||
(value) => ({ timedOut: false, value }),
|
||||
(error) => ({ timedOut: false, error })
|
||||
),
|
||||
delay(timeoutMs).then(() => ({ timedOut: true }))
|
||||
])
|
||||
if (result.timedOut) {
|
||||
throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`)
|
||||
}
|
||||
if ('error' in result) {
|
||||
throw result.error
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
|
||||
export async function pollUntil(label, read, predicate, timeoutMs, intervalMs = 100) {
|
||||
const startedAt = Date.now()
|
||||
let lastValue = null
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
lastValue = await runWithTimeout(label, read, rendererActionTimeoutMs)
|
||||
if (predicate(lastValue)) {
|
||||
return lastValue
|
||||
}
|
||||
await delay(intervalMs)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`)
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import {
|
||||
collectRendererDiagnostics,
|
||||
connectToApp,
|
||||
createGpuUserDataDirectory,
|
||||
installRendererProbe,
|
||||
launchDevApp,
|
||||
pickFreePort,
|
||||
stopDevApp,
|
||||
waitForStoreReady
|
||||
} from './electron-dev-session.mjs'
|
||||
import { summarizeAppLogs, summarizeTrace } from './apphang-report-summary.mjs'
|
||||
import {
|
||||
activationTimeoutMs,
|
||||
pollUntil,
|
||||
ptyWaitTimeoutMs,
|
||||
rendererActionTimeoutMs,
|
||||
runWithTimeout,
|
||||
setupTimeoutMs,
|
||||
severeActivationMs,
|
||||
severeMainIpcMs,
|
||||
severePtyWaitMs,
|
||||
severeRendererDriftMs,
|
||||
terminalMarkerTimeoutMs
|
||||
} from './repro-timing.mjs'
|
||||
import { safeRemoveLocalDirectory } from './wsl-workspace-fixture.mjs'
|
||||
|
||||
class ReproductionObservedError extends Error {
|
||||
constructor(message, evidence) {
|
||||
super(message)
|
||||
this.name = 'ReproductionObservedError'
|
||||
this.evidence = evidence
|
||||
}
|
||||
}
|
||||
|
||||
async function setupAppFixture(page, fixture, gpuMode, sourceControl) {
|
||||
return await runWithTimeout(
|
||||
'fixture registration in Orca',
|
||||
() =>
|
||||
page.evaluate(
|
||||
async ({ repoPath, plainPath, importedWorktreePaths, mode, openSourceControl }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is unavailable.')
|
||||
}
|
||||
const state = store.getState()
|
||||
await state.fetchSettings?.()
|
||||
await store.getState().updateSettings({ terminalGpuAcceleration: mode })
|
||||
|
||||
const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' })
|
||||
if ('error' in addResult) {
|
||||
throw new Error(addResult.error)
|
||||
}
|
||||
await store.getState().fetchRepos()
|
||||
let nextState = store.getState()
|
||||
const repo =
|
||||
nextState.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo
|
||||
await nextState.updateRepo(repo.id, {
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityPromptDismissedAt: Date.now(),
|
||||
importedExternalWorktreePaths: importedWorktreePaths,
|
||||
externalWorktreeInboxBaselinePaths: importedWorktreePaths
|
||||
})
|
||||
await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true })
|
||||
|
||||
const plainRepo = await store.getState().addNonGitFolder(plainPath)
|
||||
if (!plainRepo) {
|
||||
throw new Error('addNonGitFolder returned null.')
|
||||
}
|
||||
await store.getState().fetchWorktrees(plainRepo.id, { requireAuthoritative: true })
|
||||
|
||||
nextState = store.getState()
|
||||
nextState.setSidebarOpen(true)
|
||||
nextState.setGroupBy('none')
|
||||
nextState.setSortBy('recent')
|
||||
nextState.setShowActiveOnly(false)
|
||||
nextState.setActiveView('terminal')
|
||||
if (openSourceControl) {
|
||||
nextState.setRightSidebarOpen(true)
|
||||
nextState.setRightSidebarTab('source-control')
|
||||
}
|
||||
|
||||
const gitWorktrees = nextState.worktreesByRepo[repo.id] ?? []
|
||||
const plainWorktrees = nextState.worktreesByRepo[plainRepo.id] ?? []
|
||||
return {
|
||||
repoId: repo.id,
|
||||
repoPath: repo.path,
|
||||
plainRepoId: plainRepo.id,
|
||||
gitWorktrees: gitWorktrees.map((worktree) => ({
|
||||
id: worktree.id,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName,
|
||||
branch: worktree.branch,
|
||||
isMainWorktree: worktree.isMainWorktree
|
||||
})),
|
||||
plainWorktrees: plainWorktrees.map((worktree) => ({
|
||||
id: worktree.id,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName,
|
||||
branch: worktree.branch,
|
||||
isMainWorktree: worktree.isMainWorktree
|
||||
}))
|
||||
}
|
||||
},
|
||||
{
|
||||
repoPath: fixture.repoUncPath,
|
||||
plainPath: fixture.plainUncPath,
|
||||
importedWorktreePaths: fixture.worktreeUncPaths,
|
||||
mode: gpuMode,
|
||||
openSourceControl: sourceControl
|
||||
}
|
||||
),
|
||||
setupTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function clickWorktreeCard(page, worktreeId) {
|
||||
const rect = await runWithTimeout(
|
||||
`locate worktree card ${worktreeId}`,
|
||||
() =>
|
||||
page.evaluate((id) => {
|
||||
const rows = Array.from(document.querySelectorAll('[data-worktree-id]'))
|
||||
const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id)
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
row.scrollIntoView({ block: 'center', inline: 'nearest' })
|
||||
const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
if (bounds.width <= 0 || bounds.height <= 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
x: bounds.left + bounds.width / 2,
|
||||
y: bounds.top + bounds.height / 2,
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
}
|
||||
}, worktreeId),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
if (!rect) {
|
||||
throw new Error(`Could not find rendered worktree card for ${worktreeId}`)
|
||||
}
|
||||
await runWithTimeout(
|
||||
`click worktree card ${worktreeId}`,
|
||||
() => page.mouse.click(rect.x, rect.y),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForActiveWorktree(page, worktreeId) {
|
||||
return await pollUntil(
|
||||
`active worktree ${worktreeId}`,
|
||||
() =>
|
||||
page.evaluate((id) => {
|
||||
const state = window.__store?.getState?.()
|
||||
return {
|
||||
activeWorktreeId: state?.activeWorktreeId ?? null,
|
||||
activeTabId: state?.activeTabId ?? null,
|
||||
activeTabType: state?.activeTabType ?? null,
|
||||
tabs: state?.tabsByWorktree?.[id]?.map((tab) => tab.id) ?? []
|
||||
}
|
||||
}, worktreeId),
|
||||
(value) => value?.activeWorktreeId === worktreeId && value.activeTabType === 'terminal',
|
||||
activationTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForActivePty(page, worktreeId) {
|
||||
const startedAt = Date.now()
|
||||
const value = await pollUntil(
|
||||
`active PTY for ${worktreeId}`,
|
||||
() =>
|
||||
page.evaluate((id) => {
|
||||
const state = window.__store?.getState?.()
|
||||
const tabId =
|
||||
state?.activeWorktreeId === id && state.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: (state?.activeTabIdByWorktree?.[id] ?? null)
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
|
||||
const ptyId = pane?.container?.dataset?.ptyId ?? null
|
||||
return {
|
||||
tabId,
|
||||
ptyId,
|
||||
hasManager: Boolean(manager),
|
||||
paneCount: manager?.getPanes?.()?.length ?? 0,
|
||||
ptyIdsByTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
|
||||
}
|
||||
}, worktreeId),
|
||||
(value) => Boolean(value?.ptyId),
|
||||
ptyWaitTimeoutMs
|
||||
)
|
||||
return { ...value, waitMs: Date.now() - startedAt }
|
||||
}
|
||||
|
||||
function makeOutputCommand(marker, outputLines) {
|
||||
const payload = 'x'.repeat(180)
|
||||
return `printf '${marker}_START\\n'; i=1; while [ "$i" -le ${outputLines} ]; do printf '${marker}_%05d ${payload}\\n' "$i"; i=$((i+1)); done; printf '${marker}_DONE\\n'\r`
|
||||
}
|
||||
|
||||
async function getTerminalContent(page, charLimit = 20_000) {
|
||||
return await runWithTimeout(
|
||||
'terminal content',
|
||||
() =>
|
||||
page.evaluate((limit) => {
|
||||
const state = window.__store?.getState?.()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
|
||||
return (pane?.serializeAddon?.serialize?.() ?? '').slice(-limit)
|
||||
}, charLimit),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function sendOutputStress(page, ptyId, marker, outputLines) {
|
||||
await runWithTimeout(
|
||||
`write terminal stress ${marker}`,
|
||||
() =>
|
||||
page.evaluate(
|
||||
({ id, command }) => {
|
||||
window.api.pty.write(id, command)
|
||||
},
|
||||
{ id: ptyId, command: makeOutputCommand(marker, outputLines) }
|
||||
),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
await pollUntil(
|
||||
`terminal stress marker ${marker}`,
|
||||
async () => (await getTerminalContent(page)).includes(`${marker}_DONE`),
|
||||
Boolean,
|
||||
terminalMarkerTimeoutMs,
|
||||
200
|
||||
)
|
||||
}
|
||||
|
||||
async function pingMainIpc(page) {
|
||||
const startedAt = Date.now()
|
||||
await runWithTimeout(
|
||||
'main IPC ping',
|
||||
() => page.evaluate(() => window.api.pty.listSessions()),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
|
||||
async function readRendererProbe(page) {
|
||||
return await runWithTimeout(
|
||||
'renderer probe read',
|
||||
() => page.evaluate(() => globalThis.__orcaApphangProbe?.probe ?? null),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function killActivePty(page) {
|
||||
return await runWithTimeout(
|
||||
'kill active PTY',
|
||||
() =>
|
||||
page.evaluate(async () => {
|
||||
const state = window.__store?.getState?.()
|
||||
const tabId = state?.activeTabId ?? null
|
||||
const ptyIds = tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
|
||||
const ptyId = ptyIds[0] ?? null
|
||||
if (!ptyId) {
|
||||
return null
|
||||
}
|
||||
await window.api.pty.kill(ptyId)
|
||||
return ptyId
|
||||
}),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function resetRendererProbe(page) {
|
||||
// Why: probe drift is cumulative since installation; without a per-cycle
|
||||
// reset, one startup/setup stall gets re-reported as a hang in every
|
||||
// later cycle's rendererMaxDriftMs check.
|
||||
await runWithTimeout(
|
||||
'renderer probe reset',
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const probe = globalThis.__orcaApphangProbe?.probe
|
||||
if (!probe) {
|
||||
return
|
||||
}
|
||||
const now = performance.now()
|
||||
probe.last = now
|
||||
probe.lastTickAt = now
|
||||
probe.startedAt = now
|
||||
probe.maxDriftMs = 0
|
||||
probe.samples = 0
|
||||
}),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function runActivationCycle(page, target, args) {
|
||||
await resetRendererProbe(page)
|
||||
const marker = `ORCA_APPHANG_${target.index}_${Date.now()}`
|
||||
const cycleStartedAt = Date.now()
|
||||
const activationStartedAt = Date.now()
|
||||
await clickWorktreeCard(page, target.id)
|
||||
const activation = await waitForActiveWorktree(page, target.id)
|
||||
const activationMs = Date.now() - activationStartedAt
|
||||
const activePty = await waitForActivePty(page, target.id)
|
||||
const diagnosticsBeforeOutput = await collectRendererDiagnostics(page)
|
||||
const outputStartedAt = Date.now()
|
||||
await sendOutputStress(page, activePty.ptyId, marker, args.outputLines)
|
||||
const outputMs = Date.now() - outputStartedAt
|
||||
const mainIpcMs = await pingMainIpc(page)
|
||||
const rendererProbe = await readRendererProbe(page)
|
||||
const diagnosticsAfterOutput = await collectRendererDiagnostics(page)
|
||||
const elapsedMs = Date.now() - cycleStartedAt
|
||||
const sample = {
|
||||
index: target.index,
|
||||
kind: target.kind,
|
||||
worktreeId: target.id,
|
||||
displayName: target.displayName,
|
||||
elapsedMs,
|
||||
activationMs,
|
||||
outputMs,
|
||||
activation,
|
||||
ptyWaitMs: activePty.waitMs,
|
||||
ptyId: activePty.ptyId,
|
||||
tabId: activePty.tabId,
|
||||
mainIpcMs,
|
||||
rendererMaxDriftMs: rendererProbe?.maxDriftMs ?? null,
|
||||
renderingDiagnostics: diagnosticsAfterOutput?.renderingDiagnostics ?? null,
|
||||
webglIdentity: diagnosticsAfterOutput?.webglIdentity ?? null,
|
||||
diagnosticsBeforeOutput,
|
||||
diagnosticsAfterOutput
|
||||
}
|
||||
|
||||
const reproducedReasons = []
|
||||
if (activationMs > severeActivationMs) {
|
||||
reproducedReasons.push(`worktree activation took ${activationMs}ms`)
|
||||
}
|
||||
if (activePty.waitMs > severePtyWaitMs) {
|
||||
reproducedReasons.push(`PTY binding took ${activePty.waitMs}ms`)
|
||||
}
|
||||
if (mainIpcMs > severeMainIpcMs) {
|
||||
reproducedReasons.push(`main IPC ping took ${mainIpcMs}ms`)
|
||||
}
|
||||
if ((rendererProbe?.maxDriftMs ?? 0) > severeRendererDriftMs) {
|
||||
reproducedReasons.push(`renderer timer drift reached ${Math.round(rendererProbe.maxDriftMs)}ms`)
|
||||
}
|
||||
if (reproducedReasons.length > 0) {
|
||||
throw new ReproductionObservedError(reproducedReasons.join('; '), sample)
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
async function runDeadPtyReactivation(page, targets, args) {
|
||||
const samples = []
|
||||
for (const target of targets) {
|
||||
await clickWorktreeCard(page, target.id)
|
||||
await waitForActiveWorktree(page, target.id)
|
||||
await waitForActivePty(page, target.id)
|
||||
const killedPtyId = await killActivePty(page)
|
||||
samples.push({ worktreeId: target.id, killedPtyId })
|
||||
}
|
||||
for (const target of targets) {
|
||||
samples.push(
|
||||
await runActivationCycle(page, { ...target, kind: `${target.kind}:dead-pty` }, args)
|
||||
)
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
function selectTargets(setupResult, cycles) {
|
||||
const gitWorktrees = setupResult.gitWorktrees
|
||||
.filter((worktree) => worktree.path)
|
||||
.sort((a, b) => Number(b.isMainWorktree) - Number(a.isMainWorktree))
|
||||
.slice(0, 5)
|
||||
.map((worktree) => ({ ...worktree, kind: 'git-wsl' }))
|
||||
const plainWorktrees = setupResult.plainWorktrees.map((worktree) => ({
|
||||
...worktree,
|
||||
kind: 'plain-folder-wsl'
|
||||
}))
|
||||
const baseTargets = [...gitWorktrees, ...plainWorktrees]
|
||||
if (baseTargets.length === 0) {
|
||||
throw new Error('No worktrees were discovered for the repro fixture.')
|
||||
}
|
||||
return Array.from({ length: cycles }, (_, index) => ({
|
||||
...baseTargets[index % baseTargets.length],
|
||||
index: index + 1
|
||||
}))
|
||||
}
|
||||
|
||||
export async function runGpuMode(gpuMode, args, fixture) {
|
||||
const cdpPort = await pickFreePort()
|
||||
const userDataDir = createGpuUserDataDirectory(gpuMode)
|
||||
const launched = launchDevApp({ cdpPort, userDataDir })
|
||||
let browser = null
|
||||
let page = null
|
||||
const startedAt = Date.now()
|
||||
const result = {
|
||||
gpuMode,
|
||||
reproduced: false,
|
||||
reproductionReason: null,
|
||||
harnessError: null,
|
||||
startedAt,
|
||||
elapsedMs: null,
|
||||
cdpPort,
|
||||
userDataDir,
|
||||
appPid: launched.child.pid ?? null,
|
||||
setup: null,
|
||||
samples: [],
|
||||
finalDiagnostics: null,
|
||||
trace: null,
|
||||
appLogSummary: null,
|
||||
appLogsTail: null,
|
||||
cleanupErrors: []
|
||||
}
|
||||
|
||||
try {
|
||||
const connected = await connectToApp(cdpPort)
|
||||
browser = connected.browser
|
||||
page = connected.page
|
||||
await waitForStoreReady(page)
|
||||
await installRendererProbe(page)
|
||||
const identity = await runWithTimeout(
|
||||
'app identity',
|
||||
() => page.evaluate(() => window.api.app.getIdentity?.()),
|
||||
rendererActionTimeoutMs
|
||||
).catch(() => null)
|
||||
console.log(
|
||||
`[apphang-repro] connected gpu=${gpuMode} pid=${result.appPid} identity=${JSON.stringify(identity)}`
|
||||
)
|
||||
result.setup = await setupAppFixture(page, fixture, gpuMode, args.sourceControl)
|
||||
const targets = selectTargets(result.setup, args.cycles)
|
||||
console.log(
|
||||
`[apphang-repro] targets gpu=${gpuMode}: ${targets
|
||||
.map((target) => `${target.kind}:${target.displayName}`)
|
||||
.join(', ')}`
|
||||
)
|
||||
for (const target of targets) {
|
||||
console.log(`[apphang-repro] cycle=${target.index} gpu=${gpuMode} kind=${target.kind}`)
|
||||
result.samples.push(await runActivationCycle(page, target, args))
|
||||
}
|
||||
if (args.deadPtyReactivate) {
|
||||
const uniqueTargets = []
|
||||
const seen = new Set()
|
||||
for (const target of targets) {
|
||||
if (!seen.has(target.id)) {
|
||||
seen.add(target.id)
|
||||
uniqueTargets.push(target)
|
||||
}
|
||||
}
|
||||
console.log(`[apphang-repro] dead-pty-reactivation gpu=${gpuMode}`)
|
||||
result.samples.push(...(await runDeadPtyReactivation(page, uniqueTargets, args)))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ReproductionObservedError) {
|
||||
result.reproduced = true
|
||||
result.reproductionReason = error.message
|
||||
result.samples.push(error.evidence)
|
||||
} else {
|
||||
// Why: setup/CDP/selector failures are inconclusive, not hang evidence.
|
||||
// Conflating them with `reproduced` lets --expect=repro pass on a broken
|
||||
// harness; callers treat harnessError as a hard failure instead.
|
||||
result.harnessError = error instanceof Error ? error.stack || error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
result.elapsedMs = Date.now() - startedAt
|
||||
result.finalDiagnostics = await collectRendererDiagnostics(page)
|
||||
result.trace = summarizeTrace(userDataDir)
|
||||
result.appLogSummary = summarizeAppLogs(launched.logs)
|
||||
result.appLogsTail = launched.logs.slice(-120)
|
||||
if (browser) {
|
||||
await browser.close().catch(() => undefined)
|
||||
}
|
||||
await stopDevApp(launched.child)
|
||||
if (!args.keep) {
|
||||
safeRemoveLocalDirectory(userDataDir, result.cleanupErrors)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
|
||||
|
||||
function runWsl(distro, script, options = {}) {
|
||||
return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
input: script,
|
||||
timeout: options.timeoutMs ?? 120_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
}
|
||||
|
||||
export function listWslDistros() {
|
||||
const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10_000
|
||||
})
|
||||
return output
|
||||
.replaceAll(String.fromCharCode(0), '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().replace(/^\*\s*/, ''))
|
||||
.filter((line) => line && !line.toLowerCase().startsWith('docker-desktop'))
|
||||
}
|
||||
|
||||
function linuxPathToWslUnc(distro, linuxPath) {
|
||||
if (!linuxPath.startsWith('/')) {
|
||||
throw new Error(`Expected absolute Linux path, got ${linuxPath}`)
|
||||
}
|
||||
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
|
||||
}
|
||||
|
||||
export function createWslFixture(distro) {
|
||||
const script = String.raw`
|
||||
set -euo pipefail
|
||||
base="$(mktemp -d /tmp/orca-apphang-repro.XXXXXX)"
|
||||
repo="$base/repo"
|
||||
mkdir -p "$repo"
|
||||
cd "$repo"
|
||||
git init -q
|
||||
git config user.email apphang-repro@test.local
|
||||
git config user.name "AppHang Repro"
|
||||
mkdir -p src docs
|
||||
printf '# Orca Windows AppHang repro\n' > README.md
|
||||
for n in $(seq 1 25); do
|
||||
printf 'line %03d %s\n' "$n" "abcdefghijklmnopqrstuvwxyz0123456789" >> src/payload.txt
|
||||
done
|
||||
git add -A
|
||||
git commit -q -m "Initial repro fixture"
|
||||
for n in 1 2 3 4; do
|
||||
git branch "repro-wt-$n"
|
||||
git worktree add -q "$base/wt-$n" "repro-wt-$n"
|
||||
mkdir -p "$base/wt-$n/docs"
|
||||
printf 'worktree %s\n' "$n" > "$base/wt-$n/docs/wt.txt"
|
||||
done
|
||||
plain="$base/plain-folder"
|
||||
mkdir -p "$plain/subdir"
|
||||
printf 'plain folder for Orca AppHang repro\n' > "$plain/README.txt"
|
||||
printf '%s\n' "$base" "$repo" "$base/wt-1" "$base/wt-2" "$base/wt-3" "$base/wt-4" "$plain"
|
||||
`
|
||||
const lines = runWsl(distro, script, { timeoutMs: 120_000 }).trim().split(/\r?\n/).filter(Boolean)
|
||||
if (lines.length < 7) {
|
||||
throw new Error(`WSL fixture creation returned unexpected output: ${JSON.stringify(lines)}`)
|
||||
}
|
||||
const [base, repo, ...rest] = lines
|
||||
const plain = rest.at(-1)
|
||||
const worktrees = rest.slice(0, -1)
|
||||
return {
|
||||
distro,
|
||||
baseLinuxPath: base,
|
||||
repoLinuxPath: repo,
|
||||
plainLinuxPath: plain,
|
||||
worktreeLinuxPaths: worktrees,
|
||||
repoUncPath: linuxPathToWslUnc(distro, repo),
|
||||
plainUncPath: linuxPathToWslUnc(distro, plain),
|
||||
worktreeUncPaths: worktrees.map((entry) => linuxPathToWslUnc(distro, entry))
|
||||
}
|
||||
}
|
||||
|
||||
export function removeWslFixture(fixture) {
|
||||
if (!fixture?.baseLinuxPath) {
|
||||
return
|
||||
}
|
||||
const quoted = fixture.baseLinuxPath.replaceAll("'", "'\\''")
|
||||
runWsl(fixture.distro, `rm -rf '${quoted}'`, { timeoutMs: 30_000 })
|
||||
}
|
||||
|
||||
export function createCompletedOnboardingProfile(userDataDir) {
|
||||
mkdirSync(userDataDir, { recursive: true })
|
||||
const profile = {
|
||||
settings: {
|
||||
telemetry: {
|
||||
// Why: synthetic harness/benchmark activity must not send telemetry.
|
||||
// Explicit false with existedBeforeTelemetryRelease=false also keeps
|
||||
// the first-launch consent surface from blocking automation.
|
||||
optedIn: false,
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
existedBeforeTelemetryRelease: false
|
||||
}
|
||||
},
|
||||
onboarding: {
|
||||
flowVersion: 4,
|
||||
closedAt: 1,
|
||||
outcome: 'completed',
|
||||
lastCompletedStep: 5
|
||||
},
|
||||
ui: {
|
||||
contextualToursAutoEligible: false,
|
||||
contextualToursSeenIds: [
|
||||
'workspace-board',
|
||||
'browser',
|
||||
'tasks',
|
||||
'automations',
|
||||
'workspace-creation'
|
||||
],
|
||||
featureTipsSeenIds: [],
|
||||
featureInteractions: {},
|
||||
projectOrderManualDefaultNoticeDismissed: true
|
||||
}
|
||||
}
|
||||
writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`)
|
||||
}
|
||||
|
||||
export function safeRemoveLocalDirectory(dir, cleanupErrors) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
"../src/main/claude/hook-service.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex-accounts/fs-utils.ts",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.2m">
|
||||
<title>downloads: 3.2m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.4m">
|
||||
<title>downloads: 3.4m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.2m</text>
|
||||
<text x="93.5" y="14">3.2m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.4m</text>
|
||||
<text x="93.5" y="14">3.4m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 434 KiB |
+1
-1
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.18",
|
||||
"version": "0.0.20",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
|
||||
@@ -7,6 +7,11 @@ export default function MobileFilePreviewRoute() {
|
||||
hostId?: string | string[]
|
||||
worktreeId?: string | string[]
|
||||
relativePath?: string | string[]
|
||||
source?: string | string[]
|
||||
absolutePath?: string | string[]
|
||||
grantId?: string | string[]
|
||||
line?: string | string[]
|
||||
column?: string | string[]
|
||||
name?: string | string[]
|
||||
worktreeName?: string | string[]
|
||||
}>()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Animated, AppState, Linking, type AppStateStatus } from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
|
||||
import { File as FsFile, Paths } from 'expo-file-system'
|
||||
import {
|
||||
BackHandler,
|
||||
FlatList,
|
||||
@@ -48,7 +46,6 @@ import {
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
import type { RpcClient } from '../../../../src/transport/rpc-client'
|
||||
import type { RuntimeTerminalPathResolution } from '../../../../../src/shared/runtime-types'
|
||||
import { loadHosts } from '../../../../src/transport/host-store'
|
||||
import {
|
||||
loadTerminalAutocompleteEnabled,
|
||||
@@ -95,14 +92,16 @@ import {
|
||||
getVisibleTerminalAccessoryKeys,
|
||||
loadTerminalAccessoryLayout
|
||||
} from '../../../../src/terminal/terminal-accessory-layout'
|
||||
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
defaultTerminalLiveInputHandles,
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
pruneTerminalLiveInputHandles,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
import {
|
||||
getTerminalCommandKeyboardType,
|
||||
getTerminalLiveInputKeyboardType
|
||||
@@ -162,16 +161,13 @@ import {
|
||||
type MobileNewTabAgentOption,
|
||||
type MobileNewTabAgentSettings
|
||||
} from '../../../../src/session/mobile-new-tab-agent-options'
|
||||
import {
|
||||
buildMobileImagePastePayload,
|
||||
prepareMobileClipboardImageBase64,
|
||||
saveMobileClipboardImageAsTempFile,
|
||||
type MobileClipboardImageResizer
|
||||
} from '../../../../src/session/mobile-clipboard-image'
|
||||
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
|
||||
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
|
||||
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
|
||||
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
|
||||
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
@@ -233,52 +229,9 @@ import type {
|
||||
TerminalGestureInputQueue
|
||||
} from './mobile-session-route-types'
|
||||
|
||||
const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryInput>
|
||||
|
||||
// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
|
||||
// photos can exceed the upload byte budget; resize the raster down to fit before upload.
|
||||
// The image is staged to a temp file first because the iOS ImageManipulator loader
|
||||
// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI.
|
||||
const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
|
||||
const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
|
||||
const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
|
||||
let context: ReturnType<typeof ImageManipulator.manipulate> | null = null
|
||||
let rendered: Awaited<
|
||||
ReturnType<ReturnType<typeof ImageManipulator.manipulate>['renderAsync']>
|
||||
> | null = null
|
||||
let resultUri: string | null = null
|
||||
try {
|
||||
file.create({ overwrite: true })
|
||||
file.write(base64, { encoding: 'base64' })
|
||||
context = ImageManipulator.manipulate(file.uri)
|
||||
context.resize({ width: target.width, height: target.height })
|
||||
rendered = await context.renderAsync()
|
||||
const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
|
||||
resultUri = result.uri
|
||||
// Why: empty base64 would pass the downstream base64 check and upload a corrupt
|
||||
// image, so fail loudly here instead of silently sending an invalid payload.
|
||||
if (!result.base64) {
|
||||
throw new Error('Failed to encode resized clipboard image')
|
||||
}
|
||||
return { data: result.base64, width: result.width, height: result.height }
|
||||
} finally {
|
||||
rendered?.release()
|
||||
context?.release()
|
||||
if (resultUri) {
|
||||
try {
|
||||
new FsFile(resultUri).delete()
|
||||
} catch {
|
||||
// Best-effort cleanup; ImageManipulator saves into cache for every retry.
|
||||
}
|
||||
}
|
||||
try {
|
||||
file.delete()
|
||||
} catch {
|
||||
// Best-effort cleanup; the OS reclaims the cache directory regardless.
|
||||
}
|
||||
}
|
||||
}
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
|
||||
function getActiveTabIdForHandle(
|
||||
tabs: MobileSessionTab[],
|
||||
@@ -835,6 +788,21 @@ function FileReader({
|
||||
return renderSourceText(doc.content)
|
||||
}
|
||||
|
||||
function updateTerminalCwdFromStreamEvent(
|
||||
handle: string,
|
||||
data: Record<string, unknown>,
|
||||
terminalCwd: Map<string, string>
|
||||
): void {
|
||||
if (!('cwd' in data)) {
|
||||
return
|
||||
}
|
||||
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
|
||||
terminalCwd.set(handle, data.cwd)
|
||||
return
|
||||
}
|
||||
terminalCwd.delete(handle)
|
||||
}
|
||||
|
||||
export default function SessionScreen() {
|
||||
const {
|
||||
hostId,
|
||||
@@ -927,11 +895,14 @@ export default function SessionScreen() {
|
||||
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
|
||||
useState<MobileTerminalLinkOpenMode>('orca-browser')
|
||||
const [liveInputCapture, setLiveInputCapture] = useState('')
|
||||
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const {
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
toggleTerminalLiveInput
|
||||
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
|
||||
const [activeHandle, setActiveHandle] = useState<string | null>(null)
|
||||
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
|
||||
const activeSessionTabIdRef = useRef<string | null>(null)
|
||||
@@ -1023,6 +994,7 @@ export default function SessionScreen() {
|
||||
const terminalGestureInputBucketsRef = useRef<Map<string, TerminalGestureInputBucket>>(new Map())
|
||||
const terminalGestureInputQueuesRef = useRef<Map<string, TerminalGestureInputQueue>>(new Map())
|
||||
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
|
||||
const terminalCwdRef = useRef<Map<string, string>>(new Map())
|
||||
const initialModesSeenRef = useRef<Set<string>>(new Set())
|
||||
const deviceTokenRef = useRef<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
@@ -1034,6 +1006,7 @@ export default function SessionScreen() {
|
||||
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
|
||||
const liveInputRef = useRef<TextInput>(null)
|
||||
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
|
||||
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
|
||||
typeof Keyboard.addListener
|
||||
> | null>(null)
|
||||
@@ -1068,6 +1041,7 @@ export default function SessionScreen() {
|
||||
// render where client was still null/connecting, silently no-opping the
|
||||
// in-app-browser open). Route through a ref kept current every render.
|
||||
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
|
||||
|
||||
const initialEmptySessionAutoCreateRef = useRef<string | null>(null)
|
||||
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
|
||||
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
|
||||
@@ -1094,6 +1068,24 @@ export default function SessionScreen() {
|
||||
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
|
||||
|
||||
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
|
||||
const {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
handleLiveInputAccessoryBytes,
|
||||
handleLiveInputChange,
|
||||
handleLiveInputKeyPress,
|
||||
handleLiveInputSubmit
|
||||
} = useTerminalLiveInputCommit({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType: activeSessionTab?.type,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
})
|
||||
const canSend =
|
||||
connState === 'connected' &&
|
||||
activeHandle != null &&
|
||||
@@ -1114,7 +1106,6 @@ export default function SessionScreen() {
|
||||
sessionTabsRef.current = sessionTabs
|
||||
activeSessionTabIdRef.current = activeSessionTabId
|
||||
markdownDocsRef.current = markdownDocs
|
||||
liveInputTerminalHandlesRef.current = liveInputTerminalHandles
|
||||
const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState(
|
||||
createWarningState,
|
||||
initialCreateWarning
|
||||
@@ -1180,52 +1171,6 @@ export default function SessionScreen() {
|
||||
[clearToastHideTimer]
|
||||
)
|
||||
|
||||
// Why: direct input is now the mobile default, but only once per discovered
|
||||
// handle so a user's buffered-mode toggle survives tab/list refreshes.
|
||||
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
handles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet<string>) => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
liveHandles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const clearTerminalLiveInputDefault = useCallback(
|
||||
(handle: string) => {
|
||||
const liveHandles = new Set([
|
||||
...liveInputTerminalHandlesRef.current,
|
||||
...defaultedLiveInputTerminalHandlesRef.current
|
||||
])
|
||||
liveHandles.delete(handle)
|
||||
pruneTerminalHandlesFromLiveInput(liveHandles)
|
||||
},
|
||||
[pruneTerminalHandlesFromLiveInput]
|
||||
)
|
||||
|
||||
const dictation = useMobileDictation({
|
||||
client,
|
||||
enabled: canSend,
|
||||
@@ -1244,8 +1189,16 @@ export default function SessionScreen() {
|
||||
if (!insertHandle) {
|
||||
return
|
||||
}
|
||||
sendLiveTerminalInput(insertHandle, route.text)
|
||||
showToast('Dictation inserted')
|
||||
void (async () => {
|
||||
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle)
|
||||
if (!flushedPendingInput) {
|
||||
return
|
||||
}
|
||||
const sent = await sendLiveTerminalInput(insertHandle, route.text)
|
||||
if (sent) {
|
||||
showToast('Dictation inserted')
|
||||
}
|
||||
})()
|
||||
return
|
||||
}
|
||||
setInput((current) => appendBufferedDictation(current, route.text))
|
||||
@@ -1361,6 +1314,7 @@ export default function SessionScreen() {
|
||||
webReadyHandlesRef.current.clear()
|
||||
subscribeSeqRef.current.clear()
|
||||
layoutSeqRef.current.clear()
|
||||
terminalCwdRef.current.clear()
|
||||
setTerminalKeyboardMetrics(new Map())
|
||||
for (const term of terminalRefs.current.values()) {
|
||||
term.clear()
|
||||
@@ -1460,6 +1414,7 @@ export default function SessionScreen() {
|
||||
if (initializedHandlesRef.current.has(handle)) {
|
||||
return
|
||||
}
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const scrollbackCols = cols
|
||||
@@ -1555,7 +1510,10 @@ export default function SessionScreen() {
|
||||
}
|
||||
})()
|
||||
}
|
||||
} else if (data.type === 'metadata') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
} else if (data.type === 'data') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
// Why: log when data arrives but the WebView ref is missing
|
||||
// — this is the most likely cause of "blank but input works":
|
||||
// server stream is alive, sends flow, but writes are dropped
|
||||
@@ -1578,6 +1536,7 @@ export default function SessionScreen() {
|
||||
}
|
||||
dataRef.write(data.chunk as string)
|
||||
} else if (data.type === 'resized') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
// Why: inline resize event — the server changed the PTY dimensions
|
||||
// (mode toggle, desktop restore, or a width reflow). When the server
|
||||
// includes a fresh full-buffer snapshot (width reflow), reinitialize
|
||||
@@ -2680,19 +2639,23 @@ export default function SessionScreen() {
|
||||
terminalsRef.current = []
|
||||
setSessionTabs([])
|
||||
setActiveSessionTabId(null)
|
||||
setLiveInputCapture('')
|
||||
liveInputTerminalHandlesRef.current = new Set()
|
||||
defaultedLiveInputTerminalHandlesRef.current = new Set()
|
||||
setLiveInputTerminalHandles(new Set())
|
||||
clearPendingLiveInputCommit()
|
||||
setMarkdownDocs(new Map())
|
||||
setFileDocs(new Map())
|
||||
clearDelayedActionTimers()
|
||||
return () => {
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
|
||||
clearPendingLiveInputCommit()
|
||||
clearDelayedActionTimers()
|
||||
}
|
||||
}, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId])
|
||||
}, [
|
||||
clearDelayedActionTimers,
|
||||
clearPendingLiveInputCommit,
|
||||
clearTerminalCache,
|
||||
hostId,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected') {
|
||||
@@ -3097,46 +3060,62 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccessoryKey(bytes: string) {
|
||||
async function handleAccessoryKey(input: TerminalLiveAccessoryInput) {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await client.sendRequest('terminal.send', {
|
||||
terminal: activeHandle,
|
||||
text: bytes,
|
||||
const targetHandle = activeHandle
|
||||
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return
|
||||
}
|
||||
const currentClient = clientRef.current
|
||||
// Why: async IME flushing can outlive the original terminal selection.
|
||||
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle,
|
||||
activeHandle: activeHandleRef.current,
|
||||
activeSessionTabType: activeSessionTabTypeRef.current
|
||||
})
|
||||
if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') {
|
||||
return
|
||||
}
|
||||
await currentClient
|
||||
.sendRequest('terminal.send', {
|
||||
terminal: rawSendTarget,
|
||||
text: input.bytes,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
} catch {
|
||||
// Transient failure
|
||||
}
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
const sendLiveTerminalInput = useCallback(
|
||||
(handle: string, bytes: string) => {
|
||||
async (handle: string, bytes: string): Promise<boolean> => {
|
||||
const text = normalizeTerminalTextInput(bytes)
|
||||
if (text.length === 0) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!isTerminalLiveInputWithinByteLimit(text)) {
|
||||
triggerError()
|
||||
showToast('Input too large (max 256 KiB)', 1500)
|
||||
return
|
||||
return false
|
||||
}
|
||||
const rpc = clientRef.current
|
||||
// Why: callers suppress follow-up controls/toasts when this live send is stale.
|
||||
if (
|
||||
!rpc ||
|
||||
connStateRef.current !== 'connected' ||
|
||||
handle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal'
|
||||
) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
void rpc
|
||||
return rpc
|
||||
.sendRequest('terminal.send', {
|
||||
terminal: handle,
|
||||
text,
|
||||
@@ -3145,12 +3124,11 @@ export default function SessionScreen() {
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
.catch(() => {
|
||||
// Transient failure
|
||||
})
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
sendLiveTerminalInputRef.current = sendLiveTerminalInput
|
||||
|
||||
const focusLiveInput = useCallback(() => {
|
||||
if (!canSend || !liveInputEnabled) {
|
||||
@@ -3238,82 +3216,42 @@ export default function SessionScreen() {
|
||||
// Tap on a file path in terminal output → resolve it on the host and open it
|
||||
// as a file tab (mirrors desktop Cmd/Ctrl-click). Silent on a miss; the
|
||||
// WebView only emits this when the tap landed on a detected path.
|
||||
const handleFileTapActivationSeqRef = useRef(0)
|
||||
const handleFileTap = useCallback(
|
||||
(handle: string, pathText: string) => {
|
||||
(handle: string, pathText: string, line: number | null, column: number | null) => {
|
||||
if (handle !== activeHandleRef.current || !client) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const worktree = `id:${worktreeId}`
|
||||
const response = await client.sendRequest(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree, pathText },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
|
||||
if (!resolved.exists || resolved.isDirectory || !resolved.relativePath) {
|
||||
return
|
||||
}
|
||||
// Confirm the tap landed on something openable before giving feedback.
|
||||
triggerSelection()
|
||||
// Why: HTML opens in a browser pane (streamed from the desktop),
|
||||
// matching desktop's terminal-click behavior, instead of a file view.
|
||||
if (classifyMobileArtifact(resolved.relativePath) === 'html' && resolved.absolutePath) {
|
||||
void handleCreateBrowser('file://' + resolved.absolutePath)
|
||||
return
|
||||
}
|
||||
const openResponse = await client.sendRequest(
|
||||
'files.open',
|
||||
{ worktree, relativePath: resolved.relativePath },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
if (!openResponse.ok) {
|
||||
return
|
||||
}
|
||||
// Why: the host opens the file as a markdown/file/image tab (the type
|
||||
// depends on the file — .md opens as a 'markdown' tab), and from a terminal
|
||||
// the active tab stays on the terminal. Once the new tab syncs in, switch to
|
||||
// it by relativePath across ANY openable type. Poll since it arrives async.
|
||||
const openedPath = resolved.relativePath
|
||||
// Why: retries poll for the async-arriving tab, but once activation lands
|
||||
// a later retry would steal focus back from the user — short-circuit the
|
||||
// remaining ones once the opened tab is (or becomes) the active tab.
|
||||
let activated = false
|
||||
const activateOpenedTab = async (): Promise<void> => {
|
||||
if (activated) {
|
||||
return
|
||||
}
|
||||
await fetchSessionTabs()
|
||||
if (activated) {
|
||||
return
|
||||
}
|
||||
const opened = sessionTabsRef.current.find(
|
||||
(tab): tab is Extract<MobileSessionTab, { relativePath?: string }> =>
|
||||
'relativePath' in tab && tab.relativePath === openedPath
|
||||
)
|
||||
if (!opened) {
|
||||
return
|
||||
}
|
||||
if (activeSessionTabIdRef.current === opened.id) {
|
||||
activated = true
|
||||
return
|
||||
}
|
||||
switchSessionTabRef.current?.(opened)
|
||||
activated = true
|
||||
}
|
||||
scheduleDelayedAction(() => void activateOpenedTab(), 300)
|
||||
scheduleDelayedAction(() => void activateOpenedTab(), 900)
|
||||
scheduleDelayedAction(() => void activateOpenedTab(), 1800)
|
||||
} catch {
|
||||
// Resolution/open is best-effort; a failed tap silently no-ops.
|
||||
}
|
||||
})()
|
||||
const activationSeq = ++handleFileTapActivationSeqRef.current
|
||||
openMobileTerminalFileTap<MobileSessionTab>({
|
||||
client,
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
terminalHandle: handle,
|
||||
pathText,
|
||||
cwd: terminalCwdRef.current.get(handle) ?? null,
|
||||
line,
|
||||
column,
|
||||
pushPreviewRoute: (href) => router.push(href),
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
triggerOpenFeedback: triggerSelection,
|
||||
fetchSessionTabs,
|
||||
getSessionTabs: () => sessionTabsRef.current,
|
||||
getActiveSessionTabId: () => activeSessionTabIdRef.current,
|
||||
getActivationState: (activated) => ({
|
||||
activated,
|
||||
activationSeq,
|
||||
latestActivationSeq: handleFileTapActivationSeqRef.current,
|
||||
sourceTerminalHandle: handle,
|
||||
activeTerminalHandle: activeHandleRef.current,
|
||||
activeTabType: activeSessionTabTypeRef.current
|
||||
}),
|
||||
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
|
||||
scheduleDelayedAction
|
||||
})
|
||||
},
|
||||
[client, worktreeId, scheduleDelayedAction, fetchSessionTabs]
|
||||
[client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
|
||||
)
|
||||
|
||||
const handleOpenedFileDiffActivationSeqRef = useRef(0)
|
||||
@@ -3376,81 +3314,15 @@ export default function SessionScreen() {
|
||||
if (!activeHandle) {
|
||||
return
|
||||
}
|
||||
const nextEnabled = !liveInputTerminalHandles.has(activeHandle)
|
||||
setLiveInputTerminalHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (nextEnabled) {
|
||||
next.add(activeHandle)
|
||||
} else {
|
||||
next.delete(activeHandle)
|
||||
}
|
||||
liveInputTerminalHandlesRef.current = next
|
||||
return next
|
||||
})
|
||||
setLiveInputCapture('')
|
||||
const nextEnabled = toggleTerminalLiveInput(activeHandle)
|
||||
clearPendingLiveInputCommit()
|
||||
if (nextEnabled) {
|
||||
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
|
||||
} else {
|
||||
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
|
||||
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
|
||||
}
|
||||
const normalizedText = normalizeTerminalTextInput(text)
|
||||
if (normalizedText.length > 0) {
|
||||
sendLiveTerminalInput(activeHandle, normalizedText)
|
||||
}
|
||||
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])
|
||||
}, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput])
|
||||
|
||||
const allowTerminalGestureInput = useCallback(
|
||||
(handle: string, sequenceCount: number): boolean => {
|
||||
@@ -3650,11 +3522,11 @@ export default function SessionScreen() {
|
||||
}
|
||||
}, [])
|
||||
const startAccessoryRepeat = useCallback(
|
||||
(bytes: string) => {
|
||||
(input: TerminalLiveAccessoryInput) => {
|
||||
stopAccessoryRepeat()
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
void handleAccessoryKeyRef.current(bytes)
|
||||
void handleAccessoryKeyRef.current(input)
|
||||
}, 45)
|
||||
}, 400)
|
||||
},
|
||||
@@ -3673,11 +3545,13 @@ export default function SessionScreen() {
|
||||
clearToastHideTimer()
|
||||
clearDelayedActionTimers()
|
||||
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
|
||||
clearPendingLiveInputCommit()
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
clearSessionTabActionSheetKeyboardListener()
|
||||
stopAccessoryRepeat()
|
||||
},
|
||||
[
|
||||
clearPendingLiveInputCommit,
|
||||
clearDelayedActionTimers,
|
||||
clearSessionTabActionSheetKeyboardListener,
|
||||
clearTerminalCache,
|
||||
@@ -3801,84 +3675,38 @@ export default function SessionScreen() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const text = await Clipboard.getStringAsync()
|
||||
let payload: string | null = null
|
||||
if (text.length > 0) {
|
||||
const modes = ptyModesRef.current.get(activeHandle) || {
|
||||
bracketedPasteMode: false,
|
||||
altScreen: false,
|
||||
mouseTrackingMode: 'none',
|
||||
sgrMouseMode: false,
|
||||
sgrMousePixelsMode: false
|
||||
}
|
||||
const wrap = modes.bracketedPasteMode && !modes.altScreen
|
||||
// Why: strip embedded bracketed-paste markers from clipboard text so a
|
||||
// malicious copy containing `\x1b[201~` can't terminate paste mode early
|
||||
// and have the trailing bytes interpreted as shell commands. Matches
|
||||
// xterm.js / iTerm2 behavior.
|
||||
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
|
||||
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
|
||||
payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
|
||||
} else {
|
||||
const image = await Clipboard.getImageAsync({ format: 'png' })
|
||||
if (!image) {
|
||||
refreshCanPaste()
|
||||
return
|
||||
}
|
||||
const connectionId = await getActiveWorktreeConnectionId()
|
||||
const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
|
||||
const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
|
||||
connectionId
|
||||
})
|
||||
payload = buildMobileImagePastePayload(imagePath)
|
||||
}
|
||||
|
||||
const wrappedBytes = new TextEncoder().encode(payload).byteLength
|
||||
if (wrappedBytes > 256 * 1024) {
|
||||
triggerError()
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
|
||||
showToast('Paste too large (max 256 KiB)', 1500)
|
||||
return
|
||||
}
|
||||
await client.sendRequest('terminal.send', {
|
||||
terminal: activeHandle,
|
||||
text: payload,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
triggerSelection()
|
||||
refreshCanPaste()
|
||||
} catch (e) {
|
||||
triggerError()
|
||||
const err = e as { name?: string; message?: string }
|
||||
const isDisconnected = connState !== 'connected'
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
|
||||
if (isDisconnected) {
|
||||
showToast('Paste failed (disconnected)', 1500)
|
||||
} else if (err.message === 'Clipboard image is too large') {
|
||||
showToast('Image too large to paste', 1500)
|
||||
} else {
|
||||
showToast('Paste failed', 1500)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
const handlePaste = useMobileTerminalPaste({
|
||||
client,
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
canSend,
|
||||
connState,
|
||||
connStateRef,
|
||||
clientRef,
|
||||
deviceTokenRef,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getActiveWorktreeConnectionId,
|
||||
onError: triggerError,
|
||||
onSuccess: triggerSelection,
|
||||
ptyModesRef,
|
||||
refreshCanPaste,
|
||||
showToast
|
||||
])
|
||||
})
|
||||
|
||||
const flushPendingLiveInputBeforeAttachmentSend = useCallback(
|
||||
async (targetHandle: string): Promise<boolean> => {
|
||||
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
|
||||
// Why: image picking/upload and IME flushing can outlive the original tab.
|
||||
return (
|
||||
flushedPendingInput &&
|
||||
connStateRef.current === 'connected' &&
|
||||
targetHandle === activeHandleRef.current &&
|
||||
activeSessionTabTypeRef.current === 'terminal'
|
||||
)
|
||||
},
|
||||
[flushPendingLiveInputBeforeExternalSend]
|
||||
)
|
||||
|
||||
const { attachImage, isAttaching } = useMobileImageAttachment({
|
||||
client,
|
||||
@@ -3886,6 +3714,7 @@ export default function SessionScreen() {
|
||||
canSend,
|
||||
connState,
|
||||
deviceTokenRef,
|
||||
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
|
||||
getActiveWorktreeConnectionId,
|
||||
showToast,
|
||||
onSuccess: triggerSelection,
|
||||
@@ -5046,8 +4875,9 @@ export default function SessionScreen() {
|
||||
if (!key.repeatable) {
|
||||
return
|
||||
}
|
||||
void handleAccessoryKey(key.bytes)
|
||||
startAccessoryRepeat(key.bytes)
|
||||
const input = createTerminalLiveAccessoryInput(key)
|
||||
void handleAccessoryKey(input)
|
||||
startAccessoryRepeat(input)
|
||||
}}
|
||||
onPressOut={() => {
|
||||
if (key.repeatable) {
|
||||
@@ -5058,7 +4888,7 @@ export default function SessionScreen() {
|
||||
if (key.repeatable) {
|
||||
return
|
||||
}
|
||||
void handleAccessoryKey(key.bytes)
|
||||
void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
|
||||
}}
|
||||
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
|
||||
>
|
||||
@@ -5082,7 +4912,7 @@ export default function SessionScreen() {
|
||||
!canSend && styles.accessoryKeyDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={() => void handleAccessoryKey(key.bytes)}
|
||||
onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
setDeleteKeyTarget(key)
|
||||
|
||||
@@ -60,10 +60,12 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"oxfmt": "^0.52.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"react-test-renderer": "19.2.6",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.16",
|
||||
|
||||
Generated
+53
-10
@@ -67,7 +67,7 @@ importers:
|
||||
version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
expo-router:
|
||||
specifier: ^55.0.14
|
||||
version: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
|
||||
version: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
|
||||
expo-secure-store:
|
||||
specifier: ^55.0.13
|
||||
version: 55.0.13(expo@55.0.23)
|
||||
@@ -135,18 +135,24 @@ importers:
|
||||
'@types/react-native':
|
||||
specifier: ^0.73.0
|
||||
version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
|
||||
'@types/react-test-renderer':
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
expo-module-scripts:
|
||||
specifier: ^55.0.2
|
||||
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
|
||||
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
oxfmt:
|
||||
specifier: ^0.52.0
|
||||
version: 0.52.0
|
||||
oxlint:
|
||||
specifier: ^1.71.0
|
||||
version: 1.71.0
|
||||
react-test-renderer:
|
||||
specifier: 19.2.6
|
||||
version: 19.2.6(react@19.2.6)
|
||||
tsx:
|
||||
specifier: ^4.22.4
|
||||
version: 4.22.4
|
||||
@@ -1724,48 +1730,56 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.52.0':
|
||||
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
|
||||
@@ -1838,48 +1852,56 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.71.0':
|
||||
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
|
||||
@@ -2341,36 +2363,42 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.1.3':
|
||||
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
|
||||
@@ -4632,24 +4660,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -5424,6 +5456,11 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
react-test-renderer@19.2.6:
|
||||
resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==}
|
||||
peerDependencies:
|
||||
react: ^19.2.6
|
||||
|
||||
react@19.2.6:
|
||||
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7825,7 +7862,7 @@ snapshots:
|
||||
ws: 8.21.0
|
||||
zod: 3.25.76
|
||||
optionalDependencies:
|
||||
expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
|
||||
expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
|
||||
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- '@expo/dom-webview'
|
||||
@@ -8093,7 +8130,7 @@ snapshots:
|
||||
react: 19.2.6
|
||||
optionalDependencies:
|
||||
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
|
||||
expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -9055,14 +9092,14 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)':
|
||||
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
jest-matcher-utils: 30.3.0
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 30.3.0
|
||||
react: 19.2.6
|
||||
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
|
||||
react-test-renderer: 19.2.0(react@19.2.6)
|
||||
react-test-renderer: 19.2.6(react@19.2.6)
|
||||
redent: 3.0.0
|
||||
optionalDependencies:
|
||||
jest: 29.7.0(@types/node@25.9.3)
|
||||
@@ -10651,7 +10688,7 @@ snapshots:
|
||||
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
expo-json-utils: 55.0.2
|
||||
|
||||
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6):
|
||||
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@babel/cli': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
|
||||
@@ -10659,7 +10696,7 @@ snapshots:
|
||||
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
|
||||
'@expo/npm-proofread': 1.0.1
|
||||
'@expo/spawn-async': 1.7.2
|
||||
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
|
||||
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tsconfig/node18': 18.2.6
|
||||
'@types/jest': 29.5.14
|
||||
babel-plugin-dynamic-import-node: 2.3.3
|
||||
@@ -10734,7 +10771,7 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
expo-router@55.0.14(ce047ddbdcb481fe003f0510f5198c7e):
|
||||
expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa):
|
||||
dependencies:
|
||||
'@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
@@ -10771,7 +10808,7 @@ snapshots:
|
||||
use-latest-callback: 0.2.6(react@19.2.6)
|
||||
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
|
||||
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
@@ -12948,6 +12985,12 @@ snapshots:
|
||||
react-is: 19.2.6
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-test-renderer@19.2.6(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-is: 19.2.6
|
||||
scheduler: 0.27.0
|
||||
|
||||
react@19.2.6: {}
|
||||
|
||||
readdirp@3.6.0:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Pressable, ScrollView, View } from 'react-native'
|
||||
import { Code, Pencil } from 'lucide-react-native'
|
||||
import { MobileMarkdown } from '../components/MobileMarkdown'
|
||||
@@ -14,10 +14,20 @@ type Props = {
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
initialLine?: number
|
||||
}
|
||||
|
||||
export function MobileFileMarkdownPreview({ relativePath, content, truncated, byteLength }: Props) {
|
||||
const [mode, setMode] = useState<'preview' | 'source'>('preview')
|
||||
export function MobileFileMarkdownPreview({
|
||||
relativePath,
|
||||
content,
|
||||
truncated,
|
||||
byteLength,
|
||||
initialLine
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
|
||||
useEffect(() => {
|
||||
setMode(initialLine ? 'source' : 'preview')
|
||||
}, [initialLine, relativePath])
|
||||
const previewSelected = mode === 'preview'
|
||||
const sourceSelected = mode === 'source'
|
||||
|
||||
@@ -62,6 +72,7 @@ export function MobileFileMarkdownPreview({ relativePath, content, truncated, by
|
||||
content={content}
|
||||
truncated={truncated}
|
||||
byteLength={byteLength}
|
||||
initialLine={initialLine}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ActivityIndicator, Image, Pressable, ScrollView, Text, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { MobileFilePreviewResult } from './mobile-file-preview-request'
|
||||
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
|
||||
import { MobileFilePreviewEditableSource } from './MobileFilePreviewEditableSource'
|
||||
import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
|
||||
import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
|
||||
type Props = {
|
||||
preview: MobileFilePreviewResult
|
||||
relativePath: string
|
||||
title: string
|
||||
editable: boolean
|
||||
draftContent: string
|
||||
saveError: string
|
||||
lineColumn: MobileFilePreviewLineColumn | null
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
onDraftChange: (content: string) => void
|
||||
onImageError: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function MobileFilePreviewBody({ preview, ...options }: Props) {
|
||||
if (preview.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={styles.stateText}>{preview.message}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (preview.status === 'error' || preview.status === 'waiting') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.errorText}>{preview.message}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={options.onRetry}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (preview.status === 'empty') {
|
||||
return options.editable ? (
|
||||
<EditablePreviewSource {...options} />
|
||||
) : (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>Empty file</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (preview.kind === 'image') {
|
||||
return (
|
||||
<View style={styles.imageContainer}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.imageScrollContent}
|
||||
maximumZoomScale={4}
|
||||
minimumZoomScale={1}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
source={{ uri: preview.dataUri }}
|
||||
style={[styles.image, { width: options.imageWidth, height: options.imageHeight }]}
|
||||
resizeMode="contain"
|
||||
onError={options.onImageError}
|
||||
accessibilityLabel={`${options.title} image`}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (preview.kind === 'markdown') {
|
||||
return options.editable ? (
|
||||
<EditablePreviewSource {...options} />
|
||||
) : (
|
||||
<MobileFileMarkdownPreview
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
initialLine={options.lineColumn?.line}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (preview.kind === 'html') {
|
||||
return options.editable ? (
|
||||
<EditablePreviewSource {...options} />
|
||||
) : (
|
||||
<MobileFilePreviewSourceText
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
initialLine={options.lineColumn?.line}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (options.editable) {
|
||||
return <EditablePreviewSource {...options} />
|
||||
}
|
||||
return (
|
||||
<MobileFilePreviewSourceText
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
initialLine={options.lineColumn?.line}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EditablePreviewSource(options: {
|
||||
title: string
|
||||
draftContent: string
|
||||
saveError: string
|
||||
lineColumn: MobileFilePreviewLineColumn | null
|
||||
onDraftChange: (content: string) => void
|
||||
}) {
|
||||
return (
|
||||
<MobileFilePreviewEditableSource
|
||||
title={options.title}
|
||||
draftContent={options.draftContent}
|
||||
saveError={options.saveError}
|
||||
lineColumn={options.lineColumn}
|
||||
onDraftChange={options.onDraftChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Text, TextInput, View } from 'react-native'
|
||||
import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
|
||||
import { textOffsetForLineColumn } from './mobile-file-preview-line-column'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
draftContent: string
|
||||
lineColumn: MobileFilePreviewLineColumn | null
|
||||
saveError?: string
|
||||
onDraftChange: (content: string) => void
|
||||
}
|
||||
|
||||
export function MobileFilePreviewEditableSource({
|
||||
title,
|
||||
draftContent,
|
||||
lineColumn,
|
||||
saveError,
|
||||
onDraftChange
|
||||
}: Props) {
|
||||
const selectionTargetKey = lineColumn
|
||||
? `${title}:${lineColumn.line}:${lineColumn.column ?? ''}`
|
||||
: ''
|
||||
const [selection, setSelection] = useState<{ start: number; end: number } | null>(null)
|
||||
const [revealedTargetKey, setRevealedTargetKey] = useState('')
|
||||
|
||||
// Why: line/column opens should reveal once, then user cursor movement owns selection.
|
||||
useEffect(() => {
|
||||
if (!lineColumn || !selectionTargetKey || revealedTargetKey === selectionTargetKey) {
|
||||
return
|
||||
}
|
||||
const offset = textOffsetForLineColumn(draftContent, lineColumn)
|
||||
const initialSelection = { start: offset, end: offset }
|
||||
setSelection(initialSelection)
|
||||
setRevealedTargetKey(selectionTargetKey)
|
||||
}, [draftContent, lineColumn, revealedTargetKey, selectionTargetKey])
|
||||
|
||||
return (
|
||||
<View style={styles.editContainer}>
|
||||
{saveError ? <Text style={styles.saveErrorText}>{saveError}</Text> : null}
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
value={draftContent}
|
||||
onChangeText={onDraftChange}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
selection={selection ?? undefined}
|
||||
onSelectionChange={() => setSelection(null)}
|
||||
accessibilityLabel={`${title} editor`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
import {
|
||||
hasUnsavedMobileTerminalArtifactDraft,
|
||||
isEditableMobileTerminalArtifactPreview,
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult
|
||||
} from './mobile-file-preview-editability'
|
||||
|
||||
describe('MobileFilePreviewScreen', () => {
|
||||
it('treats empty terminal artifact text previews as editable', () => {
|
||||
expect(isEditableMobileTerminalArtifactPreview({ status: 'empty', kind: 'text' })).toBe(true)
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview({
|
||||
status: 'ready',
|
||||
kind: 'text',
|
||||
content: '',
|
||||
truncated: false,
|
||||
byteLength: 0
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview({
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: 'data:image/png;base64,aW1n'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats truncated terminal artifact text previews as read-only', () => {
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview({
|
||||
status: 'ready',
|
||||
kind: 'text',
|
||||
content: 'partial',
|
||||
truncated: true,
|
||||
byteLength: 1024
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
|
||||
const sourceKey = sourceKeyForPreview({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
|
||||
expect(
|
||||
hasUnsavedMobileTerminalArtifactDraft({
|
||||
source: 'terminalArtifact',
|
||||
draftSourceKey: sourceKey,
|
||||
previewSourceKey: sourceKey,
|
||||
draftContent: '{"ok":false}',
|
||||
savedContent: '{"ok":true}'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps dirty terminal artifact drafts when a reload fails or waits', () => {
|
||||
expect(
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult(true, {
|
||||
status: 'error',
|
||||
message: 'Unable to reach desktop filesystem',
|
||||
reconnect: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult(true, {
|
||||
status: 'waiting',
|
||||
message: 'Waiting for desktop...',
|
||||
reconnect: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult(false, {
|
||||
status: 'error',
|
||||
message: 'Unable to load preview',
|
||||
reconnect: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +1,30 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
useWindowDimensions
|
||||
} from 'react-native'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert, BackHandler, Pressable, Text, View, useWindowDimensions } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { ChevronLeft, Save } from 'lucide-react-native'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { useForceReconnect, useHostClient } from '../transport/client-context'
|
||||
import {
|
||||
loadMobileFilePreview,
|
||||
previewError,
|
||||
saveMobileTerminalArtifactPreview,
|
||||
type MobileFilePreviewSource,
|
||||
type MobileFilePreviewResult
|
||||
} from './mobile-file-preview-request'
|
||||
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
|
||||
import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
|
||||
import { MobileFilePreviewBody } from './MobileFilePreviewBody'
|
||||
import {
|
||||
displayNameFromPreviewPath,
|
||||
type MobileFilePreviewRouteState
|
||||
} from './mobile-file-preview-route'
|
||||
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
import { normalizeMobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
|
||||
import {
|
||||
hasUnsavedMobileTerminalArtifactDraft,
|
||||
isEditableMobileTerminalArtifactPreview,
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult
|
||||
} from './mobile-file-preview-editability'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
|
||||
type Props = {
|
||||
@@ -39,30 +39,116 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
const [preview, setPreview] = useState<MobileFilePreviewResult>(() =>
|
||||
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
|
||||
)
|
||||
const [draftContent, setDraftContent] = useState('')
|
||||
const [savedContent, setSavedContent] = useState('')
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const draftContentRef = useRef(draftContent)
|
||||
const savedContentRef = useRef(savedContent)
|
||||
const draftSourceKeyRef = useRef<string | null>(null)
|
||||
const { width, height } = useWindowDimensions()
|
||||
const routePreviewSource = useMemo(
|
||||
() => (previewParams ? previewSourceFromRoute(previewParams) : null),
|
||||
[previewParams]
|
||||
)
|
||||
const [previewSource, setPreviewSource] = useState<MobileFilePreviewSource | null>(
|
||||
routePreviewSource
|
||||
)
|
||||
const previewSourceKey = useMemo(() => sourceKeyForPreview(previewSource), [previewSource])
|
||||
const routePreviewSourceKey = useMemo(
|
||||
() => sourceKeyForPreview(routePreviewSource),
|
||||
[routePreviewSource]
|
||||
)
|
||||
const previewSourceKeyRef = useRef(previewSourceKey)
|
||||
const lineColumn = useMemo(
|
||||
() =>
|
||||
previewParams
|
||||
? normalizeMobileFilePreviewLineColumn(previewParams.line, previewParams.column)
|
||||
: null,
|
||||
[previewParams]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewSource(routePreviewSource)
|
||||
draftSourceKeyRef.current = null
|
||||
}, [routePreviewSource])
|
||||
|
||||
useEffect(() => {
|
||||
previewSourceKeyRef.current = previewSourceKey
|
||||
}, [previewSourceKey])
|
||||
|
||||
useEffect(() => {
|
||||
draftContentRef.current = draftContent
|
||||
}, [draftContent])
|
||||
|
||||
useEffect(() => {
|
||||
savedContentRef.current = savedContent
|
||||
}, [savedContent])
|
||||
|
||||
const loadPreview = useCallback(async () => {
|
||||
if (!previewParams) {
|
||||
const loadSourceKey = previewSourceKey
|
||||
if (!previewParams || !previewSource || loadSourceKey !== routePreviewSourceKey) {
|
||||
setPreview(previewError(route.ok ? 'Unable to load preview' : route.message))
|
||||
return
|
||||
}
|
||||
const preserveDirtyDraft =
|
||||
draftSourceKeyRef.current === previewSourceKey &&
|
||||
draftContentRef.current !== savedContentRef.current
|
||||
if (!client || connState !== 'connected') {
|
||||
if (preserveDirtyDraft) {
|
||||
setSaveError('Waiting for desktop...')
|
||||
return
|
||||
}
|
||||
setPreview({ status: 'waiting', message: 'Waiting for desktop...', reconnect: true })
|
||||
return
|
||||
}
|
||||
setPreview({ status: 'loading', message: 'Loading preview...' })
|
||||
if (!preserveDirtyDraft) {
|
||||
setPreview({ status: 'loading', message: 'Loading preview...' })
|
||||
}
|
||||
setSaveError('')
|
||||
try {
|
||||
const result = await loadMobileFilePreview(
|
||||
client,
|
||||
previewParams.worktreeId,
|
||||
previewParams.relativePath
|
||||
)
|
||||
const result = await loadMobileFilePreview(client, previewSource, undefined, {
|
||||
onTerminalArtifactSourceRefreshed: setPreviewSource,
|
||||
refreshGrant: true
|
||||
})
|
||||
if (previewSourceKeyRef.current !== loadSourceKey) {
|
||||
return
|
||||
}
|
||||
if (shouldKeepDirtyDraftOnPreviewLoadResult(preserveDirtyDraft, result)) {
|
||||
setSaveError(result.message)
|
||||
return
|
||||
}
|
||||
const loadedContent =
|
||||
result.status === 'ready' && result.kind !== 'image'
|
||||
? result.content
|
||||
: result.status === 'empty'
|
||||
? ''
|
||||
: null
|
||||
if (loadedContent !== null) {
|
||||
if (!preserveDirtyDraft) {
|
||||
setDraftContent(loadedContent)
|
||||
setSavedContent(loadedContent)
|
||||
}
|
||||
draftSourceKeyRef.current = previewSourceKey
|
||||
}
|
||||
setPreview(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unable to load preview'
|
||||
if (preserveDirtyDraft) {
|
||||
setSaveError(message)
|
||||
return
|
||||
}
|
||||
setPreview(previewError(message))
|
||||
}
|
||||
}, [client, connState, previewParams, route])
|
||||
}, [
|
||||
client,
|
||||
connState,
|
||||
previewParams,
|
||||
previewSource,
|
||||
previewSourceKey,
|
||||
route,
|
||||
routePreviewSourceKey
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
void loadPreview()
|
||||
@@ -84,12 +170,70 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
void loadPreview()
|
||||
}, [connState, forceReconnect, loadPreview, preview, previewParams])
|
||||
|
||||
const title = previewParams?.name ?? displayNameFromPreviewPath(previewParams?.relativePath ?? '')
|
||||
const displayPath =
|
||||
previewParams?.source === 'terminalArtifact'
|
||||
? (previewParams.absolutePath ?? '')
|
||||
: (previewParams?.relativePath ?? '')
|
||||
const title = previewParams?.name ?? displayNameFromPreviewPath(displayPath)
|
||||
const worktreeLabel = getWorktreeLabel(
|
||||
previewParams?.worktreeName,
|
||||
previewParams?.worktreeId ?? ''
|
||||
)
|
||||
const meta = previewParams ? `${worktreeLabel} - ${previewParams.relativePath}` : 'Preview'
|
||||
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
|
||||
const isEditableTerminalArtifact =
|
||||
previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
|
||||
const canSaveArtifact =
|
||||
isEditableTerminalArtifact &&
|
||||
draftSourceKeyRef.current === previewSourceKey &&
|
||||
draftContent !== savedContent
|
||||
const hasUnsavedTerminalArtifactDraft = hasUnsavedMobileTerminalArtifactDraft({
|
||||
source: previewSource?.source,
|
||||
draftSourceKey: draftSourceKeyRef.current,
|
||||
previewSourceKey,
|
||||
draftContent,
|
||||
savedContent
|
||||
})
|
||||
|
||||
const saveArtifact = useCallback(async () => {
|
||||
if (!client || previewSource?.source !== 'terminalArtifact' || !canSaveArtifact || saving) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSaveError('')
|
||||
try {
|
||||
const result = await saveMobileTerminalArtifactPreview(client, previewSource, draftContent, {
|
||||
baseContent: savedContent,
|
||||
onTerminalArtifactSourceRefreshed: setPreviewSource
|
||||
})
|
||||
if (result.status === 'saved') {
|
||||
setSavedContent(draftContent)
|
||||
} else {
|
||||
setSaveError(saveErrorMessageFromPreviewResult(result))
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unable to save file'
|
||||
setSaveError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [canSaveArtifact, client, draftContent, previewSource, savedContent, saving])
|
||||
|
||||
const requestBack = useCallback(() => {
|
||||
if (!hasUnsavedTerminalArtifactDraft) {
|
||||
router.back()
|
||||
return true
|
||||
}
|
||||
Alert.alert('Discard changes?', 'Unsaved edits will be lost.', [
|
||||
{ text: 'Stay', style: 'cancel' },
|
||||
{ text: 'Discard', style: 'destructive', onPress: () => router.back() }
|
||||
])
|
||||
return true
|
||||
}, [hasUnsavedTerminalArtifactDraft, router])
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', requestBack)
|
||||
return () => subscription.remove()
|
||||
}, [requestBack])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -97,7 +241,7 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||||
onPress={() => router.back()}
|
||||
onPress={requestBack}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Back to files"
|
||||
>
|
||||
@@ -111,110 +255,40 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
{meta}
|
||||
</Text>
|
||||
</View>
|
||||
{isEditableTerminalArtifact ? (
|
||||
<Pressable
|
||||
style={[styles.saveButton, (!canSaveArtifact || saving) && styles.saveButtonDisabled]}
|
||||
onPress={() => void saveArtifact()}
|
||||
disabled={!canSaveArtifact || saving}
|
||||
accessibilityLabel="Save terminal artifact"
|
||||
>
|
||||
<Save size={18} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
{renderPreviewBody(preview, {
|
||||
relativePath: previewParams?.relativePath ?? '',
|
||||
title: title || 'File',
|
||||
imageWidth: Math.max(1, width - spacing.md * 2),
|
||||
imageHeight: Math.max(240, height - 160),
|
||||
onImageError: () =>
|
||||
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false }),
|
||||
onRetry: retry
|
||||
})}
|
||||
<MobileFilePreviewBody
|
||||
preview={preview}
|
||||
relativePath={displayPath}
|
||||
title={title || 'File'}
|
||||
editable={isEditableTerminalArtifact}
|
||||
draftContent={draftContent}
|
||||
saveError={saveError}
|
||||
lineColumn={lineColumn}
|
||||
imageWidth={Math.max(1, width - spacing.md * 2)}
|
||||
imageHeight={Math.max(240, height - 160)}
|
||||
onDraftChange={setDraftContent}
|
||||
onImageError={() =>
|
||||
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false })
|
||||
}
|
||||
onRetry={retry}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewBody(
|
||||
preview: MobileFilePreviewResult,
|
||||
options: {
|
||||
relativePath: string
|
||||
title: string
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
onImageError: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
) {
|
||||
if (preview.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={styles.stateText}>{preview.message}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.status === 'error' || preview.status === 'waiting') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.errorText}>{preview.message}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={options.onRetry}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.status === 'empty') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>Empty file</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'image') {
|
||||
return (
|
||||
<View style={styles.imageContainer}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.imageScrollContent}
|
||||
maximumZoomScale={4}
|
||||
minimumZoomScale={1}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
source={{ uri: preview.dataUri }}
|
||||
style={[styles.image, { width: options.imageWidth, height: options.imageHeight }]}
|
||||
resizeMode="contain"
|
||||
onError={options.onImageError}
|
||||
accessibilityLabel={`${options.title} image`}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'markdown') {
|
||||
return (
|
||||
<MobileFileMarkdownPreview
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'html') {
|
||||
return (
|
||||
<MobileFilePreviewSourceText
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileFilePreviewSourceText
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
/>
|
||||
)
|
||||
function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string {
|
||||
return result.status === 'error' || result.status === 'waiting'
|
||||
? result.message
|
||||
: 'Unable to save file'
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { ScrollView, Text } from 'react-native'
|
||||
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
|
||||
import { formatPreviewByteLength } from './mobile-file-preview-request'
|
||||
import { scrollOffsetForPreviewLine } from './mobile-file-preview-line-column'
|
||||
import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
|
||||
@@ -9,19 +10,44 @@ export function MobileFilePreviewSourceText({
|
||||
relativePath,
|
||||
content,
|
||||
truncated,
|
||||
byteLength
|
||||
byteLength,
|
||||
initialLine
|
||||
}: {
|
||||
relativePath: string
|
||||
content: string
|
||||
truncated?: boolean
|
||||
byteLength?: number
|
||||
initialLine?: number
|
||||
}) {
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
const revealedRef = useRef(false)
|
||||
const syntax = useMemo(
|
||||
() => buildMobileFilePreviewSyntax(relativePath, content),
|
||||
[content, relativePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
revealedRef.current = false
|
||||
}, [content, initialLine, relativePath])
|
||||
|
||||
const revealInitialLine = () => {
|
||||
if (!initialLine || revealedRef.current) {
|
||||
return
|
||||
}
|
||||
revealedRef.current = true
|
||||
scrollRef.current?.scrollTo({
|
||||
y: scrollOffsetForPreviewLine(initialLine),
|
||||
animated: false
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.textContent}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.textContent}
|
||||
onContentSizeChange={revealInitialLine}
|
||||
>
|
||||
{truncated ? (
|
||||
<MobileFilePreviewTruncatedNote byteLength={byteLength ?? content.length} />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
MobileFilePreviewResult,
|
||||
MobileFilePreviewSource
|
||||
} from './mobile-file-preview-request'
|
||||
|
||||
export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
|
||||
return (
|
||||
(preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
|
||||
preview.status === 'empty'
|
||||
)
|
||||
}
|
||||
|
||||
export function hasUnsavedMobileTerminalArtifactDraft({
|
||||
source,
|
||||
draftSourceKey,
|
||||
previewSourceKey,
|
||||
draftContent,
|
||||
savedContent
|
||||
}: {
|
||||
source?: MobileFilePreviewSource['source']
|
||||
draftSourceKey: string | null
|
||||
previewSourceKey: string | null
|
||||
draftContent: string
|
||||
savedContent: string
|
||||
}): boolean {
|
||||
return (
|
||||
source === 'terminalArtifact' &&
|
||||
draftSourceKey === previewSourceKey &&
|
||||
draftContent !== savedContent
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldKeepDirtyDraftOnPreviewLoadResult(
|
||||
preserveDirtyDraft: boolean,
|
||||
result: MobileFilePreviewResult
|
||||
): result is Extract<MobileFilePreviewResult, { status: 'error' | 'waiting' }> {
|
||||
return preserveDirtyDraft && (result.status === 'error' || result.status === 'waiting')
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeMobileFilePreviewLineColumn,
|
||||
scrollOffsetForPreviewLine,
|
||||
textOffsetForLineColumn
|
||||
} from './mobile-file-preview-line-column'
|
||||
|
||||
describe('mobile-file-preview-line-column', () => {
|
||||
it('normalizes positive route params and rejects invalid lines', () => {
|
||||
expect(normalizeMobileFilePreviewLineColumn('12', '3')).toEqual({ line: 12, column: 3 })
|
||||
expect(normalizeMobileFilePreviewLineColumn('12', undefined)).toEqual({
|
||||
line: 12,
|
||||
column: null
|
||||
})
|
||||
expect(normalizeMobileFilePreviewLineColumn('0', '3')).toBeNull()
|
||||
expect(normalizeMobileFilePreviewLineColumn('12x', '3')).toBeNull()
|
||||
expect(normalizeMobileFilePreviewLineColumn(undefined, '3')).toBeNull()
|
||||
})
|
||||
|
||||
it('converts one-based line and column values to a text offset', () => {
|
||||
const content = 'alpha\nbravo\ncharlie'
|
||||
|
||||
expect(textOffsetForLineColumn(content, { line: 1, column: 1 })).toBe(0)
|
||||
expect(textOffsetForLineColumn(content, { line: 2, column: 3 })).toBe(8)
|
||||
expect(textOffsetForLineColumn(content, { line: 3, column: null })).toBe(12)
|
||||
})
|
||||
|
||||
it('caps out-of-range line and column values safely', () => {
|
||||
const content = 'alpha\nbravo'
|
||||
|
||||
expect(textOffsetForLineColumn(content, { line: 99, column: 1 })).toBe(content.length)
|
||||
expect(textOffsetForLineColumn(content, { line: 1, column: 99 })).toBe(5)
|
||||
})
|
||||
|
||||
it('maps line numbers to the mono preview scroll offset', () => {
|
||||
expect(scrollOffsetForPreviewLine(1)).toBe(0)
|
||||
expect(scrollOffsetForPreviewLine(4)).toBe(57)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
export type MobileFilePreviewLineColumn = {
|
||||
line: number
|
||||
column: number | null
|
||||
}
|
||||
|
||||
const MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT = 19
|
||||
|
||||
export function normalizeMobileFilePreviewLineColumn(
|
||||
line: string | undefined,
|
||||
column: string | undefined
|
||||
): MobileFilePreviewLineColumn | null {
|
||||
const normalizedLine = parsePositiveInteger(line)
|
||||
if (normalizedLine === null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
line: normalizedLine,
|
||||
column: parsePositiveInteger(column)
|
||||
}
|
||||
}
|
||||
|
||||
export function textOffsetForLineColumn(
|
||||
content: string,
|
||||
target: MobileFilePreviewLineColumn
|
||||
): number {
|
||||
let lineStart = 0
|
||||
for (let currentLine = 1; currentLine < target.line; currentLine += 1) {
|
||||
const nextBreak = content.indexOf('\n', lineStart)
|
||||
if (nextBreak === -1) {
|
||||
return content.length
|
||||
}
|
||||
lineStart = nextBreak + 1
|
||||
}
|
||||
|
||||
const lineEnd = content.indexOf('\n', lineStart)
|
||||
const cappedLineEnd = lineEnd === -1 ? content.length : lineEnd
|
||||
const columnOffset = Math.max(0, (target.column ?? 1) - 1)
|
||||
return Math.min(cappedLineEnd, lineStart + columnOffset)
|
||||
}
|
||||
|
||||
export function scrollOffsetForPreviewLine(line: number): number {
|
||||
return Math.max(0, line - 1) * MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined): number | null {
|
||||
if (!value || !/^\d+$/.test(value)) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : null
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
createMobileFilePreviewRequest,
|
||||
formatPreviewByteLength,
|
||||
loadMobileFilePreview,
|
||||
normalizeMobileFilePreviewResponse
|
||||
normalizeMobileFilePreviewResponse,
|
||||
saveMobileTerminalArtifactPreview
|
||||
} from './mobile-file-preview-request'
|
||||
|
||||
function ok(result: unknown): RpcSuccess {
|
||||
@@ -21,6 +22,12 @@ function clientWith(response: RpcResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
function clientWithResponses(responses: RpcResponse[]) {
|
||||
return {
|
||||
sendRequest: vi.fn(async () => responses.shift()!)
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile-file-preview-request', () => {
|
||||
it('selects readPreview for raster images and read for text-like files', () => {
|
||||
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
|
||||
@@ -54,6 +61,471 @@ describe('mobile-file-preview-request', () => {
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
|
||||
})
|
||||
|
||||
it('loads terminal artifacts through grant-scoped artifact RPCs', async () => {
|
||||
const client = clientWith(ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }))
|
||||
|
||||
await expect(
|
||||
loadMobileFilePreview(client, {
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.read', expect.anything())
|
||||
})
|
||||
|
||||
it('refreshes an expired terminal artifact grant and retries the read once', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_expired'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })
|
||||
])
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
|
||||
await expect(
|
||||
loadMobileFilePreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
},
|
||||
undefined,
|
||||
{ onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
|
||||
worktree: 'id:wt-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
terminal: 'term-1'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
})
|
||||
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes a stale terminal artifact grant and retries the read once', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_stale'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })
|
||||
])
|
||||
|
||||
await expect(
|
||||
loadMobileFilePreview(client, {
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1'
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
|
||||
worktree: 'id:wt-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminal: 'term-1'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminal_file_grant_expired'],
|
||||
['terminal_file_grant_mismatch'],
|
||||
['terminal_file_grant_stale']
|
||||
])(
|
||||
'does not refresh a %s terminal artifact grant when disabled for a dirty editor',
|
||||
async (error) => {
|
||||
const client = clientWithResponses([fail(error)])
|
||||
|
||||
await expect(
|
||||
loadMobileFilePreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
},
|
||||
undefined,
|
||||
{ refreshGrant: false }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Reload preview before saving',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('saves terminal artifacts through the same exact-path grant', async () => {
|
||||
const client = clientWith(ok({ ok: true }))
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
},
|
||||
'{"ok":false}'
|
||||
)
|
||||
).resolves.toEqual({ status: 'saved' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
|
||||
const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
},
|
||||
'{"ok":false}'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Reload preview before saving',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes an expired terminal artifact grant and retries save when the file still matches the base content', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_expired'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }),
|
||||
ok({ ok: true })
|
||||
])
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
).resolves.toEqual({ status: 'saved' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
|
||||
worktree: 'id:wt-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
terminal: 'term-1'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(4, 'files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the dirty draft when a refreshed terminal artifact save finds changed remote content', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_stale'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ content: '{"ok":"changed"}', truncated: false, byteLength: 16 })
|
||||
])
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'File changed on desktop. Reload preview before saving',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed refreshed artifact read instead of treating it as changed desktop content', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_stale'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
fail('provider unavailable')
|
||||
])
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}' }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Unable to reach the desktop filesystem',
|
||||
reconnect: true
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a malformed refreshed artifact read instead of treating it as changed desktop content', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_stale'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ truncated: false, byteLength: 11 })
|
||||
])
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}' }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Unable to load preview',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retry a dirty save when grant refresh resolves to a different artifact', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_expired'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/b.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/b.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
})
|
||||
])
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/a.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: '/tmp/link.json'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Reload preview before saving',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
|
||||
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
|
||||
@@ -116,6 +588,7 @@ describe('mobile-file-preview-request', () => {
|
||||
true
|
||||
],
|
||||
['provider unavailable', 'Unable to reach the desktop filesystem', true],
|
||||
['terminal_file_grant_stale', 'Reload preview before saving', false],
|
||||
['permission denied', 'Unable to load preview', false]
|
||||
])('maps preview failure %s', (message, expected, reconnect) => {
|
||||
expect(normalizeMobileFilePreviewResponse('src/app.ts', fail(message))).toEqual({
|
||||
|
||||
@@ -1,195 +1,256 @@
|
||||
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import type { RpcFailure, RpcResponse } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isMarkdownPath } from './file-tree'
|
||||
import {
|
||||
normalizeMobileFilePreviewResponse,
|
||||
previewError,
|
||||
type MobileFilePreviewResult
|
||||
} from './mobile-file-preview-response'
|
||||
import {
|
||||
refreshTerminalArtifactSourceAfterGrantFailure,
|
||||
type MobileTerminalArtifactPreviewSource,
|
||||
type TerminalArtifactRetryOptions
|
||||
} from './mobile-terminal-artifact-grant-refresh'
|
||||
|
||||
export {
|
||||
formatPreviewByteLength,
|
||||
normalizeMobileFilePreviewResponse,
|
||||
previewError
|
||||
} from './mobile-file-preview-response'
|
||||
export type {
|
||||
MobileFilePreviewResult,
|
||||
MobileFilePreviewTextKind
|
||||
} from './mobile-file-preview-response'
|
||||
|
||||
export type MobileFilePreviewReadMethod = 'files.read' | 'files.readPreview'
|
||||
export type MobileTerminalArtifactPreviewReadMethod =
|
||||
| 'files.readTerminalArtifact'
|
||||
| 'files.readTerminalArtifactPreview'
|
||||
|
||||
export type MobileFilePreviewSource =
|
||||
| {
|
||||
source: 'worktree'
|
||||
worktreeId: string
|
||||
relativePath: string
|
||||
}
|
||||
| MobileTerminalArtifactPreviewSource
|
||||
|
||||
export type MobileFilePreviewRequest = {
|
||||
method: MobileFilePreviewReadMethod
|
||||
method: MobileFilePreviewReadMethod | MobileTerminalArtifactPreviewReadMethod
|
||||
params: {
|
||||
worktree: string
|
||||
relativePath: string
|
||||
relativePath?: string
|
||||
absolutePath?: string
|
||||
grantId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
|
||||
|
||||
export type MobileFilePreviewResult =
|
||||
| {
|
||||
status: 'loading'
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
status: 'waiting'
|
||||
message: string
|
||||
reconnect: true
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
kind: 'image'
|
||||
dataUri: string
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
kind: MobileFilePreviewTextKind
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
}
|
||||
| {
|
||||
status: 'empty'
|
||||
kind: MobileFilePreviewTextKind
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
reconnect: boolean
|
||||
}
|
||||
|
||||
type MobileFilePreviewClient = Pick<RpcClient, 'sendRequest'>
|
||||
type TerminalArtifactSource = MobileTerminalArtifactPreviewSource
|
||||
type TerminalArtifactSaveOptions = TerminalArtifactRetryOptions & {
|
||||
baseContent?: string
|
||||
}
|
||||
|
||||
export function createMobileFilePreviewRequest(
|
||||
worktreeId: string,
|
||||
relativePath: string
|
||||
worktreeIdOrSource: string | MobileFilePreviewSource,
|
||||
relativePath?: string
|
||||
): MobileFilePreviewRequest {
|
||||
const source =
|
||||
typeof worktreeIdOrSource === 'string'
|
||||
? { source: 'worktree' as const, worktreeId: worktreeIdOrSource, relativePath: relativePath! }
|
||||
: worktreeIdOrSource
|
||||
if (source.source === 'terminalArtifact') {
|
||||
const method =
|
||||
classifyMobileArtifact(source.absolutePath) === 'image'
|
||||
? 'files.readTerminalArtifactPreview'
|
||||
: 'files.readTerminalArtifact'
|
||||
return {
|
||||
method,
|
||||
params: {
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
absolutePath: source.absolutePath,
|
||||
grantId: source.grantId
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
method: classifyMobileArtifact(relativePath) === 'image' ? 'files.readPreview' : 'files.read',
|
||||
method:
|
||||
classifyMobileArtifact(source.relativePath) === 'image' ? 'files.readPreview' : 'files.read',
|
||||
params: {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
relativePath: source.relativePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMobileFilePreview(
|
||||
client: MobileFilePreviewClient,
|
||||
worktreeId: string,
|
||||
relativePath: string
|
||||
worktreeIdOrSource: string | MobileFilePreviewSource,
|
||||
relativePath?: string,
|
||||
options: TerminalArtifactRetryOptions = {}
|
||||
): Promise<MobileFilePreviewResult> {
|
||||
const request = createMobileFilePreviewRequest(worktreeId, relativePath)
|
||||
const response = await client.sendRequest(request.method, request.params)
|
||||
return normalizeMobileFilePreviewResponse(relativePath, response)
|
||||
let source = worktreeIdOrSource
|
||||
let request = createMobileFilePreviewRequest(source, relativePath)
|
||||
let response = await client.sendRequest(request.method, request.params)
|
||||
if (!response.ok && typeof source !== 'string' && source.source === 'terminalArtifact') {
|
||||
const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
client,
|
||||
source,
|
||||
response,
|
||||
options
|
||||
)
|
||||
if (refreshed) {
|
||||
source = refreshed
|
||||
options.onTerminalArtifactSourceRefreshed?.(refreshed)
|
||||
request = createMobileFilePreviewRequest(source, relativePath)
|
||||
response = await client.sendRequest(request.method, request.params)
|
||||
}
|
||||
}
|
||||
const previewPath = typeof source === 'string' ? relativePath! : previewPathForSource(source)
|
||||
return normalizeMobileFilePreviewResponse(previewPath, response)
|
||||
}
|
||||
|
||||
export function normalizeMobileFilePreviewResponse(
|
||||
relativePath: string,
|
||||
response: RpcResponse
|
||||
): MobileFilePreviewResult {
|
||||
export async function saveMobileTerminalArtifactPreview(
|
||||
client: MobileFilePreviewClient,
|
||||
source: TerminalArtifactSource,
|
||||
content: string,
|
||||
options: TerminalArtifactSaveOptions = {}
|
||||
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
|
||||
let writeSource = source
|
||||
if (typeof options.baseContent === 'string') {
|
||||
const verified = await verifyTerminalArtifactBaseContent(
|
||||
client,
|
||||
writeSource,
|
||||
options.baseContent,
|
||||
options
|
||||
)
|
||||
if (verified.status === 'error') {
|
||||
return verified.error
|
||||
}
|
||||
writeSource = verified.source
|
||||
if (verified.refreshed) {
|
||||
options.onTerminalArtifactSourceRefreshed?.(verified.source)
|
||||
}
|
||||
}
|
||||
let response = await writeTerminalArtifactPreview(client, writeSource, content)
|
||||
if (response.ok) {
|
||||
return { status: 'saved' }
|
||||
}
|
||||
|
||||
if (typeof options.baseContent !== 'string') {
|
||||
return previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
client,
|
||||
writeSource,
|
||||
response,
|
||||
options
|
||||
)
|
||||
if (!refreshed) {
|
||||
return previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
const verified = await verifyTerminalArtifactBaseContent(client, refreshed, options.baseContent, {
|
||||
refreshGrant: false
|
||||
})
|
||||
if (verified.status === 'error') {
|
||||
return verified.error
|
||||
}
|
||||
options.onTerminalArtifactSourceRefreshed?.(refreshed)
|
||||
writeSource = verified.source
|
||||
response = await writeTerminalArtifactPreview(client, writeSource, content)
|
||||
if (!response.ok) {
|
||||
return previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
|
||||
const result = (response as RpcSuccess).result
|
||||
if (classifyMobileArtifact(relativePath) === 'image') {
|
||||
return normalizeImagePreviewResult(result)
|
||||
}
|
||||
return normalizeTextPreviewResult(relativePath, result)
|
||||
return { status: 'saved' }
|
||||
}
|
||||
|
||||
export function previewError(message: string): MobileFilePreviewResult {
|
||||
const normalized = message.toLowerCase()
|
||||
if (normalized === 'binary_file' || normalized.includes('binary_file')) {
|
||||
return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
|
||||
async function verifyTerminalArtifactBaseContent(
|
||||
client: MobileFilePreviewClient,
|
||||
source: TerminalArtifactSource,
|
||||
baseContent: string,
|
||||
options: TerminalArtifactRetryOptions
|
||||
): Promise<
|
||||
| { status: 'ok'; source: TerminalArtifactSource; refreshed: boolean }
|
||||
| { status: 'error'; error: MobileFilePreviewResult }
|
||||
> {
|
||||
let readSource = source
|
||||
let request = createMobileFilePreviewRequest(readSource)
|
||||
let response = await client.sendRequest(request.method, request.params)
|
||||
let refreshed = false
|
||||
if (!response.ok) {
|
||||
const nextSource = await refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
client,
|
||||
readSource,
|
||||
response,
|
||||
options
|
||||
)
|
||||
if (!nextSource) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
}
|
||||
readSource = nextSource
|
||||
refreshed = true
|
||||
request = createMobileFilePreviewRequest(readSource)
|
||||
response = await client.sendRequest(request.method, request.params)
|
||||
}
|
||||
if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
|
||||
return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
normalized.includes('remote connection dropped') ||
|
||||
normalized.includes('provider unavailable') ||
|
||||
normalized.includes('disconnected')
|
||||
) {
|
||||
return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
|
||||
const latest = normalizeMobileFilePreviewResponse(readSource.absolutePath, response)
|
||||
if (latest.status === 'error' || latest.status === 'waiting') {
|
||||
return { status: 'error', error: latest }
|
||||
}
|
||||
if (
|
||||
normalized.includes('enoent') ||
|
||||
normalized.includes('no such file') ||
|
||||
normalized.includes('not found') ||
|
||||
normalized.includes('does not exist')
|
||||
) {
|
||||
return { status: 'error', message: 'File not found', reconnect: false }
|
||||
if (!terminalArtifactPreviewMatchesBase(latest, baseContent)) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: {
|
||||
status: 'error',
|
||||
message: 'File changed on desktop. Reload preview before saving',
|
||||
reconnect: false
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status: 'error', message: 'Unable to load preview', reconnect: false }
|
||||
return { status: 'ok', source: readSource, refreshed }
|
||||
}
|
||||
|
||||
export function formatPreviewByteLength(byteLength: number): string {
|
||||
if (!Number.isFinite(byteLength) || byteLength < 0) {
|
||||
return 'unknown size'
|
||||
}
|
||||
if (byteLength < 1024) {
|
||||
return `${byteLength} B`
|
||||
}
|
||||
if (byteLength < 1024 * 1024) {
|
||||
return `${Math.round(byteLength / 1024)} KB`
|
||||
}
|
||||
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
|
||||
function writeTerminalArtifactPreview(
|
||||
client: MobileFilePreviewClient,
|
||||
source: TerminalArtifactSource,
|
||||
content: string
|
||||
): Promise<RpcResponse> {
|
||||
return client.sendRequest('files.writeTerminalArtifact', {
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
absolutePath: source.absolutePath,
|
||||
grantId: source.grantId,
|
||||
content
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
const preview = result as {
|
||||
content?: unknown
|
||||
isBinary?: unknown
|
||||
isImage?: unknown
|
||||
mimeType?: unknown
|
||||
}
|
||||
if (
|
||||
preview.isBinary !== true ||
|
||||
preview.isImage !== true ||
|
||||
typeof preview.mimeType !== 'string' ||
|
||||
preview.mimeType.length === 0 ||
|
||||
typeof preview.content !== 'string' ||
|
||||
preview.content.length === 0
|
||||
) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: `data:${preview.mimeType};base64,${preview.content}`
|
||||
function terminalArtifactPreviewMatchesBase(
|
||||
preview: MobileFilePreviewResult,
|
||||
baseContent: string
|
||||
): boolean {
|
||||
if (preview.status === 'empty') {
|
||||
return baseContent.length === 0
|
||||
}
|
||||
return preview.status === 'ready' && preview.kind !== 'image' && preview.content === baseContent
|
||||
}
|
||||
|
||||
function normalizeTextPreviewResult(
|
||||
relativePath: string,
|
||||
result: unknown
|
||||
): MobileFilePreviewResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return previewError('Unable to load preview')
|
||||
}
|
||||
const preview = result as {
|
||||
content?: unknown
|
||||
truncated?: unknown
|
||||
byteLength?: unknown
|
||||
isBinary?: unknown
|
||||
}
|
||||
if (preview.isBinary === true) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
if (typeof preview.content !== 'string') {
|
||||
return previewError('Unable to load preview')
|
||||
}
|
||||
const kind = textKindForPreviewPath(relativePath)
|
||||
if (preview.content.length === 0) {
|
||||
return { status: 'empty', kind }
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind,
|
||||
content: preview.content,
|
||||
truncated: preview.truncated === true,
|
||||
byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
|
||||
}
|
||||
}
|
||||
|
||||
function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
|
||||
if (classifyMobileArtifact(relativePath) === 'html') {
|
||||
return 'html'
|
||||
}
|
||||
return isMarkdownPath(relativePath) ? 'markdown' : 'text'
|
||||
function previewPathForSource(source: MobileFilePreviewSource): string {
|
||||
return source.source === 'terminalArtifact' ? source.absolutePath : source.relativePath
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { isMarkdownPath } from './file-tree'
|
||||
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
|
||||
|
||||
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
|
||||
|
||||
export type MobileFilePreviewResult =
|
||||
| {
|
||||
status: 'loading'
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
status: 'waiting'
|
||||
message: string
|
||||
reconnect: true
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
kind: 'image'
|
||||
dataUri: string
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
kind: MobileFilePreviewTextKind
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
}
|
||||
| {
|
||||
status: 'empty'
|
||||
kind: MobileFilePreviewTextKind
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
reconnect: boolean
|
||||
}
|
||||
|
||||
export function normalizeMobileFilePreviewResponse(
|
||||
relativePath: string,
|
||||
response: RpcResponse
|
||||
): MobileFilePreviewResult {
|
||||
if (!response.ok) {
|
||||
return previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
|
||||
const result = (response as RpcSuccess).result
|
||||
if (classifyMobileArtifact(relativePath) === 'image') {
|
||||
return normalizeImagePreviewResult(result)
|
||||
}
|
||||
return normalizeTextPreviewResult(relativePath, result)
|
||||
}
|
||||
|
||||
export function previewError(message: string): MobileFilePreviewResult {
|
||||
const normalized = message.toLowerCase()
|
||||
if (normalized === 'binary_file' || normalized.includes('binary_file')) {
|
||||
return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
|
||||
}
|
||||
if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
|
||||
return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
|
||||
}
|
||||
if (isTerminalArtifactGrantError(normalized)) {
|
||||
return { status: 'error', message: 'Reload preview before saving', reconnect: false }
|
||||
}
|
||||
if (
|
||||
normalized.includes('remote connection dropped') ||
|
||||
normalized.includes('provider unavailable') ||
|
||||
normalized.includes('disconnected') ||
|
||||
normalized.includes('reconnect the ssh target')
|
||||
) {
|
||||
return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
|
||||
}
|
||||
if (
|
||||
normalized.includes('enoent') ||
|
||||
normalized.includes('no such file') ||
|
||||
normalized.includes('not found') ||
|
||||
normalized.includes('does not exist')
|
||||
) {
|
||||
return { status: 'error', message: 'File not found', reconnect: false }
|
||||
}
|
||||
return { status: 'error', message: 'Unable to load preview', reconnect: false }
|
||||
}
|
||||
|
||||
export function formatPreviewByteLength(byteLength: number): string {
|
||||
if (!Number.isFinite(byteLength) || byteLength < 0) {
|
||||
return 'unknown size'
|
||||
}
|
||||
if (byteLength < 1024) {
|
||||
return `${byteLength} B`
|
||||
}
|
||||
if (byteLength < 1024 * 1024) {
|
||||
return `${Math.round(byteLength / 1024)} KB`
|
||||
}
|
||||
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
const preview = result as {
|
||||
content?: unknown
|
||||
isBinary?: unknown
|
||||
isImage?: unknown
|
||||
mimeType?: unknown
|
||||
}
|
||||
if (
|
||||
preview.isBinary !== true ||
|
||||
preview.isImage !== true ||
|
||||
typeof preview.mimeType !== 'string' ||
|
||||
preview.mimeType.length === 0 ||
|
||||
typeof preview.content !== 'string' ||
|
||||
preview.content.length === 0
|
||||
) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: `data:${preview.mimeType};base64,${preview.content}`
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTextPreviewResult(
|
||||
relativePath: string,
|
||||
result: unknown
|
||||
): MobileFilePreviewResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return previewError('Unable to load preview')
|
||||
}
|
||||
const preview = result as {
|
||||
content?: unknown
|
||||
truncated?: unknown
|
||||
byteLength?: unknown
|
||||
isBinary?: unknown
|
||||
}
|
||||
if (preview.isBinary === true) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
if (typeof preview.content !== 'string') {
|
||||
return previewError('Unable to load preview')
|
||||
}
|
||||
const kind = textKindForPreviewPath(relativePath)
|
||||
if (preview.content.length === 0) {
|
||||
return { status: 'empty', kind }
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind,
|
||||
content: preview.content,
|
||||
truncated: preview.truncated === true,
|
||||
byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
|
||||
}
|
||||
}
|
||||
|
||||
function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
|
||||
if (classifyMobileArtifact(relativePath) === 'html') {
|
||||
return 'html'
|
||||
}
|
||||
return isMarkdownPath(relativePath) ? 'markdown' : 'text'
|
||||
}
|
||||
@@ -22,12 +22,70 @@ describe('mobile-file-preview-route', () => {
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
relativePath,
|
||||
source: 'worktree',
|
||||
line: undefined,
|
||||
column: undefined,
|
||||
name: 'note.md',
|
||||
worktreeName: 'Orca'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes worktree preview line and column params', () => {
|
||||
const route = normalizeMobileFilePreviewRouteParams({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
relativePath: 'src/app.ts',
|
||||
line: '120',
|
||||
column: '7'
|
||||
})
|
||||
|
||||
expect(route).toEqual({
|
||||
ok: true,
|
||||
params: {
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
relativePath: 'src/app.ts',
|
||||
source: 'worktree',
|
||||
line: '120',
|
||||
column: '7',
|
||||
name: undefined,
|
||||
worktreeName: undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes terminal artifact params without requiring a relative path', () => {
|
||||
const route = normalizeMobileFilePreviewRouteParams({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminal: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
line: '12',
|
||||
column: '3'
|
||||
})
|
||||
|
||||
expect(route).toEqual({
|
||||
ok: true,
|
||||
params: {
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminal: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
line: '12',
|
||||
column: '3'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing, empty, or array-valued required params', () => {
|
||||
expect(normalizeMobileFilePreviewRouteParams({ hostId: 'h', worktreeId: 'w' })).toEqual({
|
||||
ok: false,
|
||||
|
||||
@@ -3,7 +3,15 @@ export type MobileFilePreviewParamValue = string | string[] | undefined
|
||||
export type MobileFilePreviewRouteParams = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
relativePath: string
|
||||
relativePath?: string
|
||||
source?: 'worktree' | 'terminalArtifact'
|
||||
absolutePath?: string
|
||||
grantId?: string
|
||||
terminal?: string
|
||||
pathText?: string
|
||||
cwd?: string
|
||||
line?: string
|
||||
column?: string
|
||||
name?: string
|
||||
worktreeName?: string
|
||||
}
|
||||
@@ -21,6 +29,14 @@ type RawPreviewRouteParams = {
|
||||
hostId?: MobileFilePreviewParamValue
|
||||
worktreeId?: MobileFilePreviewParamValue
|
||||
relativePath?: MobileFilePreviewParamValue
|
||||
source?: MobileFilePreviewParamValue
|
||||
absolutePath?: MobileFilePreviewParamValue
|
||||
grantId?: MobileFilePreviewParamValue
|
||||
terminal?: MobileFilePreviewParamValue
|
||||
pathText?: MobileFilePreviewParamValue
|
||||
cwd?: MobileFilePreviewParamValue
|
||||
line?: MobileFilePreviewParamValue
|
||||
column?: MobileFilePreviewParamValue
|
||||
name?: MobileFilePreviewParamValue
|
||||
worktreeName?: MobileFilePreviewParamValue
|
||||
}
|
||||
@@ -39,7 +55,35 @@ export function normalizeMobileFilePreviewRouteParams(
|
||||
const hostId = singleParam(params.hostId)
|
||||
const worktreeId = singleParam(params.worktreeId)
|
||||
const relativePath = singleParam(params.relativePath)
|
||||
if (!hostId || !worktreeId || !relativePath) {
|
||||
const source = singleParam(params.source)
|
||||
const absolutePath = singleParam(params.absolutePath)
|
||||
const grantId = singleParam(params.grantId)
|
||||
if (!hostId || !worktreeId) {
|
||||
return { ok: false, message: 'Unable to load preview' }
|
||||
}
|
||||
if (source === 'terminalArtifact') {
|
||||
if (!absolutePath || !grantId) {
|
||||
return { ok: false, message: 'Unable to load preview' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
params: {
|
||||
hostId,
|
||||
worktreeId,
|
||||
source,
|
||||
absolutePath,
|
||||
grantId,
|
||||
terminal: optionalSingleParam(params.terminal),
|
||||
pathText: optionalSingleParam(params.pathText),
|
||||
cwd: optionalSingleParam(params.cwd),
|
||||
line: optionalSingleParam(params.line),
|
||||
column: optionalSingleParam(params.column),
|
||||
name: optionalSingleParam(params.name),
|
||||
worktreeName: optionalSingleParam(params.worktreeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!relativePath) {
|
||||
return { ok: false, message: 'Unable to load preview' }
|
||||
}
|
||||
return {
|
||||
@@ -48,6 +92,9 @@ export function normalizeMobileFilePreviewRouteParams(
|
||||
hostId,
|
||||
worktreeId,
|
||||
relativePath,
|
||||
source: 'worktree',
|
||||
line: optionalSingleParam(params.line),
|
||||
column: optionalSingleParam(params.column),
|
||||
name: optionalSingleParam(params.name),
|
||||
worktreeName: optionalSingleParam(params.worktreeName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
|
||||
describe('mobile-file-preview-source', () => {
|
||||
it('uses structured preview keys so colons in paths cannot collide', () => {
|
||||
const first = sourceKeyForPreview({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt:1',
|
||||
absolutePath: '/tmp/a',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'b:c'
|
||||
})
|
||||
const second = sourceKeyForPreview({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt',
|
||||
absolutePath: '1:/tmp/a:b',
|
||||
grantId: 'grant-2',
|
||||
terminalHandle: 'c'
|
||||
})
|
||||
|
||||
expect(first).toBe(JSON.stringify(['terminal', 'wt:1', '/tmp/a', 'b:c']))
|
||||
expect(second).toBe(JSON.stringify(['terminal', 'wt', '1:/tmp/a:b', 'c']))
|
||||
expect(first).not.toBe(second)
|
||||
expect(
|
||||
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
|
||||
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { MobileFilePreviewRouteParams } from './mobile-file-preview-route'
|
||||
import type { MobileFilePreviewSource } from './mobile-file-preview-request'
|
||||
|
||||
export function previewSourceFromRoute(
|
||||
params: MobileFilePreviewRouteParams
|
||||
): MobileFilePreviewSource | null {
|
||||
if (params.source === 'terminalArtifact') {
|
||||
if (!params.absolutePath || !params.grantId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: params.worktreeId,
|
||||
absolutePath: params.absolutePath,
|
||||
grantId: params.grantId,
|
||||
...(params.terminal ? { terminalHandle: params.terminal } : {}),
|
||||
...(params.pathText ? { pathText: params.pathText } : {}),
|
||||
...(params.cwd ? { cwd: params.cwd } : {})
|
||||
}
|
||||
}
|
||||
if (!params.relativePath) {
|
||||
return null
|
||||
}
|
||||
return { source: 'worktree', worktreeId: params.worktreeId, relativePath: params.relativePath }
|
||||
}
|
||||
|
||||
export function sourceKeyForPreview(source: MobileFilePreviewSource | null): string | null {
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
return source.source === 'terminalArtifact'
|
||||
? JSON.stringify([
|
||||
'terminal',
|
||||
source.worktreeId,
|
||||
source.absolutePath,
|
||||
source.terminalHandle ?? ''
|
||||
])
|
||||
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
|
||||
}
|
||||
@@ -73,6 +73,17 @@ export const filePreviewStyles = StyleSheet.create({
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.42
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.editorSurface
|
||||
@@ -136,5 +147,23 @@ export const filePreviewStyles = StyleSheet.create({
|
||||
},
|
||||
image: {
|
||||
backgroundColor: colors.editorSurface
|
||||
},
|
||||
editContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.editorSurface,
|
||||
padding: spacing.md
|
||||
},
|
||||
saveErrorText: {
|
||||
marginBottom: spacing.sm,
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
editInput: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
padding: 0
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
|
||||
|
||||
type MobileFilePreviewClient = Pick<RpcClient, 'sendRequest'>
|
||||
|
||||
export type MobileTerminalArtifactPreviewSource = {
|
||||
source: 'terminalArtifact'
|
||||
worktreeId: string
|
||||
absolutePath: string
|
||||
grantId: string
|
||||
terminalHandle?: string
|
||||
pathText?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export type TerminalArtifactRetryOptions = {
|
||||
onTerminalArtifactSourceRefreshed?: (source: MobileTerminalArtifactPreviewSource) => void
|
||||
refreshGrant?: boolean
|
||||
}
|
||||
|
||||
export async function refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
client: MobileFilePreviewClient,
|
||||
source: MobileTerminalArtifactPreviewSource,
|
||||
response: RpcResponse,
|
||||
options: TerminalArtifactRetryOptions = {}
|
||||
): Promise<MobileTerminalArtifactPreviewSource | null> {
|
||||
if (response.ok || !isTerminalArtifactGrantFailure(response, options)) {
|
||||
return null
|
||||
}
|
||||
const refreshed = await client.sendRequest('files.resolveTerminalPath', {
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
pathText: source.pathText ?? source.absolutePath,
|
||||
...(source.cwd ? { cwd: source.cwd } : {}),
|
||||
...(source.terminalHandle ? { terminal: source.terminalHandle } : {})
|
||||
})
|
||||
if (!refreshed.ok) {
|
||||
return null
|
||||
}
|
||||
const result = (refreshed as RpcSuccess).result
|
||||
if (!isTerminalArtifactResolution(result)) {
|
||||
return null
|
||||
}
|
||||
if (result.openTarget.absolutePath !== source.absolutePath) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: source.worktreeId,
|
||||
absolutePath: result.openTarget.absolutePath,
|
||||
grantId: result.openTarget.grantId,
|
||||
...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}),
|
||||
...(source.pathText ? { pathText: source.pathText } : {}),
|
||||
...(source.cwd ? { cwd: source.cwd } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalArtifactGrantFailure(
|
||||
response: RpcFailure,
|
||||
options: TerminalArtifactRetryOptions
|
||||
): boolean {
|
||||
if (options.refreshGrant === false) {
|
||||
return false
|
||||
}
|
||||
return isTerminalArtifactGrantError(`${response.error.code} ${response.error.message}`)
|
||||
}
|
||||
|
||||
function isTerminalArtifactResolution(result: unknown): result is {
|
||||
exists: true
|
||||
isDirectory: false
|
||||
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string }
|
||||
} {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return false
|
||||
}
|
||||
const resolution = result as {
|
||||
exists?: unknown
|
||||
isDirectory?: unknown
|
||||
openTarget?: { kind?: unknown; absolutePath?: unknown; grantId?: unknown }
|
||||
}
|
||||
return (
|
||||
resolution.exists === true &&
|
||||
resolution.isDirectory === false &&
|
||||
resolution.openTarget?.kind === 'absolute-file' &&
|
||||
typeof resolution.openTarget.absolutePath === 'string' &&
|
||||
typeof resolution.openTarget.grantId === 'string'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Why: the runtime rejects stale terminal-artifact grants with these codes, but
|
||||
// the RPC mux flattens error shapes to code+message strings, so mobile matches
|
||||
// by substring. Single source so grant refresh and preview errors can't drift.
|
||||
export function isTerminalArtifactGrantError(text: string): boolean {
|
||||
const normalized = text.toLowerCase()
|
||||
return (
|
||||
normalized.includes('terminal_file_grant_expired') ||
|
||||
normalized.includes('terminal_file_grant_mismatch') ||
|
||||
normalized.includes('terminal_file_grant_stale')
|
||||
)
|
||||
}
|
||||
@@ -118,4 +118,30 @@ describe('attachMobileImageToTerminal', () => {
|
||||
const sendCall = client.calls.find((c) => c.method === 'terminal.send')
|
||||
expect(sendCall?.params).not.toHaveProperty('client')
|
||||
})
|
||||
|
||||
it('waits for pending live input before sending the image payload', async () => {
|
||||
const client = clientWithResponses([
|
||||
{
|
||||
id: 'start',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'no' },
|
||||
_meta: { runtimeId: 'r' }
|
||||
},
|
||||
ok('save', '/tmp/pending.png')
|
||||
])
|
||||
const beforeTerminalSend = vi.fn(async () => false)
|
||||
|
||||
const sent = await attachMobileImageToTerminal('library', {
|
||||
client,
|
||||
terminal: 'term-pending',
|
||||
deviceToken: null,
|
||||
getConnectionId: async () => null,
|
||||
pickImage: vi.fn().mockResolvedValue({ base64: 'DDDD' }),
|
||||
beforeTerminalSend
|
||||
})
|
||||
|
||||
expect(sent).toBe(false)
|
||||
expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending')
|
||||
expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ export type AttachMobileImageDeps = {
|
||||
// start — lets the UI show a sending spinner only for the transfer, not the
|
||||
// (potentially long) time the picker is open.
|
||||
readonly onUploadStart?: () => void
|
||||
readonly beforeTerminalSend?: (terminal: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
// Uploads a picked image to the host and pastes the resulting file path into the
|
||||
@@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal(
|
||||
deviceToken,
|
||||
getConnectionId,
|
||||
pickImage,
|
||||
onUploadStart
|
||||
onUploadStart,
|
||||
beforeTerminalSend
|
||||
}: AttachMobileImageDeps
|
||||
): Promise<boolean> {
|
||||
const picked = await pickImage(source)
|
||||
@@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal(
|
||||
// Why: a generated image path is terminal image injection, so it's always
|
||||
// bracketed (matching desktop paste) regardless of terminal mode.
|
||||
const payload = buildMobileImagePastePayload(imagePath)
|
||||
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
|
||||
return false
|
||||
}
|
||||
await client.sendRequest('terminal.send', {
|
||||
terminal,
|
||||
text: payload,
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open'
|
||||
|
||||
function ok(result: unknown) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function createClient(responses: unknown[]) {
|
||||
return {
|
||||
sendRequest: vi.fn(async () => responses.shift())
|
||||
}
|
||||
}
|
||||
|
||||
function activeTerminalState(activated: boolean) {
|
||||
return {
|
||||
activated,
|
||||
activationSeq: 1,
|
||||
latestActivationSeq: 1,
|
||||
sourceTerminalHandle: 'terminal-1',
|
||||
activeTerminalHandle: 'terminal-1',
|
||||
activeTabType: 'terminal'
|
||||
}
|
||||
}
|
||||
|
||||
describe('openMobileTerminalFileTap', () => {
|
||||
it('opens absolute terminal artifacts through the grant-backed preview route', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'local',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
}
|
||||
})
|
||||
])
|
||||
const pushPreviewRoute = vi.fn()
|
||||
const triggerOpenFeedback = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminalHandle: 'terminal-1',
|
||||
line: 12,
|
||||
column: 3,
|
||||
pushPreviewRoute,
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback,
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: '/tmp/result.json', terminal: 'terminal-1' },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
expect(pushPreviewRoute).toHaveBeenCalledWith({
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
params: expect.objectContaining({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminal: 'terminal-1',
|
||||
line: '12',
|
||||
column: '3'
|
||||
})
|
||||
})
|
||||
expect(triggerOpenFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
|
||||
})
|
||||
|
||||
it('preserves the worktree-contained files.open flow', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
}),
|
||||
ok({ opened: true })
|
||||
])
|
||||
const scheduleDelayedAction = vi.fn((callback: () => void) => callback())
|
||||
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
|
||||
const switchSessionTab = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'src/index.ts',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [openedTab],
|
||||
getActiveSessionTabId: () => 'terminal-tab',
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab,
|
||||
scheduleDelayedAction
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.open',
|
||||
{ worktree: 'id:wt-1', relativePath: 'src/index.ts' },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
|
||||
})
|
||||
|
||||
it('opens worktree-contained line references through the preview route', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
})
|
||||
])
|
||||
const pushPreviewRoute = vi.fn()
|
||||
const triggerOpenFeedback = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: 'Orca',
|
||||
pathText: 'src/index.ts:120:7',
|
||||
line: 120,
|
||||
column: 7,
|
||||
pushPreviewRoute,
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback,
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pushPreviewRoute).toHaveBeenCalledWith({
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
params: expect.objectContaining({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'worktree',
|
||||
relativePath: 'src/index.ts',
|
||||
line: '120',
|
||||
column: '7',
|
||||
worktreeName: 'Orca'
|
||||
})
|
||||
})
|
||||
expect(triggerOpenFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
|
||||
})
|
||||
|
||||
it('encodes worktree HTML paths before opening a browser tab', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'public/report #1?.html',
|
||||
absolutePath: '/repo/public/report #1?.html',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'public/report #1?.html',
|
||||
absolutePath: '/repo/public/report #1?.html'
|
||||
}
|
||||
})
|
||||
])
|
||||
const openBrowser = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'public/report #1?.html',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser,
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(openBrowser).toHaveBeenCalledWith('file:///repo/public/report%20%231%3F.html')
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
|
||||
})
|
||||
|
||||
it('passes the terminal cwd when resolving relative taps', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
}),
|
||||
ok({ opened: true })
|
||||
])
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'index.ts',
|
||||
terminalHandle: 'term-1',
|
||||
cwd: '/repo/src',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'term-1', cwd: '/repo/src' },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open SSH worktree HTML paths as local browser file URLs', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'report.html',
|
||||
absolutePath: '/home/me/repo/report.html',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'ssh',
|
||||
relativePath: 'report.html',
|
||||
absolutePath: '/home/me/repo/report.html'
|
||||
}
|
||||
}),
|
||||
ok({ opened: true })
|
||||
])
|
||||
const openBrowser = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'report.html',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser,
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(openBrowser).not.toHaveBeenCalled()
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.open',
|
||||
{ worktree: 'id:wt-1', relativePath: 'report.html' },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not navigate an absolute artifact after the user leaves the source terminal', async () => {
|
||||
let resolveRequest: (value: unknown) => void = () => {}
|
||||
const client = {
|
||||
sendRequest: vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
)
|
||||
}
|
||||
let activeTerminalHandle: string | null = 'terminal-1'
|
||||
const pushPreviewRoute = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminalHandle: 'terminal-1',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute,
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: (activated) => ({
|
||||
...activeTerminalState(activated),
|
||||
activeTerminalHandle
|
||||
}),
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
|
||||
activeTerminalHandle = 'terminal-2'
|
||||
resolveRequest(
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'local',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
}
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pushPreviewRoute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not activate a worktree file tab after a newer tap supersedes it', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
}),
|
||||
ok({ opened: true })
|
||||
])
|
||||
const callbacks: (() => void)[] = []
|
||||
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
|
||||
const switchSessionTab = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'src/index.ts',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [openedTab],
|
||||
getActiveSessionTabId: () => 'terminal-tab',
|
||||
getActivationState: (activated) => ({
|
||||
...activeTerminalState(activated),
|
||||
latestActivationSeq: 2
|
||||
}),
|
||||
switchSessionTab,
|
||||
scheduleDelayedAction: (callback) => callbacks.push(callback)
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
callbacks.forEach((callback) => callback())
|
||||
await Promise.resolve()
|
||||
|
||||
expect(switchSessionTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
|
||||
import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
|
||||
import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route'
|
||||
import { classifyMobileArtifact } from './mobile-artifact-kind'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab'
|
||||
|
||||
type TerminalFileTapSessionTab = {
|
||||
id: string
|
||||
relativePath?: string
|
||||
}
|
||||
|
||||
type OpenMobileTerminalFileTapOptions<T extends TerminalFileTapSessionTab> = {
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
worktreeName?: string
|
||||
terminalHandle?: string | null
|
||||
pathText: string
|
||||
cwd?: string | null
|
||||
line: number | null
|
||||
column: number | null
|
||||
pushPreviewRoute: (href: ReturnType<typeof createMobileFilePreviewHref>) => void
|
||||
openBrowser: (url: string) => void
|
||||
triggerOpenFeedback: () => void
|
||||
fetchSessionTabs: () => Promise<void>
|
||||
getSessionTabs: () => readonly T[]
|
||||
getActiveSessionTabId: () => string | null
|
||||
getActivationState: (activated: boolean) => {
|
||||
activated: boolean
|
||||
activationSeq: number
|
||||
latestActivationSeq: number
|
||||
sourceTerminalHandle: string
|
||||
activeTerminalHandle: string | null
|
||||
activeTabType: string | null
|
||||
}
|
||||
switchSessionTab: (tab: T) => void
|
||||
scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
|
||||
}
|
||||
|
||||
export function openMobileTerminalFileTap<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>
|
||||
): void {
|
||||
void openMobileTerminalFileTapAsync(options).catch(() => {
|
||||
// Terminal file taps are best-effort: a failed host resolution should leave
|
||||
// terminal focus/input untouched, matching the existing silent miss behavior.
|
||||
})
|
||||
}
|
||||
|
||||
async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>
|
||||
): Promise<void> {
|
||||
const worktree = `id:${options.worktreeId}`
|
||||
const response = await options.client.sendRequest(
|
||||
'files.resolveTerminalPath',
|
||||
{
|
||||
worktree,
|
||||
pathText: options.pathText,
|
||||
...(options.terminalHandle && options.terminalHandle.trim().length > 0
|
||||
? { terminal: options.terminalHandle }
|
||||
: {}),
|
||||
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {})
|
||||
},
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
|
||||
if (!resolved.exists || resolved.isDirectory) {
|
||||
return
|
||||
}
|
||||
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved.openTarget?.kind === 'absolute-file') {
|
||||
options.triggerOpenFeedback()
|
||||
options.pushPreviewRoute(
|
||||
createMobileFilePreviewHref({
|
||||
hostId: options.hostId,
|
||||
worktreeId: options.worktreeId,
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: resolved.openTarget.absolutePath,
|
||||
grantId: resolved.openTarget.grantId,
|
||||
pathText: options.pathText,
|
||||
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}),
|
||||
...(options.terminalHandle && options.terminalHandle.trim().length > 0
|
||||
? { terminal: options.terminalHandle }
|
||||
: {}),
|
||||
name: displayNameFromPath(resolved.openTarget.absolutePath),
|
||||
...(options.line !== null ? { line: String(options.line) } : {}),
|
||||
...(options.column !== null ? { column: String(options.column) } : {}),
|
||||
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const openedPath =
|
||||
resolved.openTarget?.kind === 'worktree-file'
|
||||
? resolved.openTarget.relativePath
|
||||
: resolved.relativePath
|
||||
if (!openedPath) {
|
||||
return
|
||||
}
|
||||
options.triggerOpenFeedback()
|
||||
if (options.line !== null || options.column !== null) {
|
||||
options.pushPreviewRoute(
|
||||
createMobileFilePreviewHref({
|
||||
hostId: options.hostId,
|
||||
worktreeId: options.worktreeId,
|
||||
source: 'worktree',
|
||||
relativePath: openedPath,
|
||||
name: displayNameFromPath(openedPath),
|
||||
...(options.line !== null ? { line: String(options.line) } : {}),
|
||||
...(options.column !== null ? { column: String(options.column) } : {}),
|
||||
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
classifyMobileArtifact(openedPath) === 'html' &&
|
||||
resolved.openTarget?.kind === 'worktree-file' &&
|
||||
resolved.openTarget.provider === 'local'
|
||||
) {
|
||||
options.openBrowser(filesystemPathToFileUri(resolved.openTarget.absolutePath))
|
||||
return
|
||||
}
|
||||
const openResponse = await options.client.sendRequest(
|
||||
'files.open',
|
||||
{ worktree, relativePath: openedPath },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
if (!openResponse.ok) {
|
||||
return
|
||||
}
|
||||
scheduleOpenedWorktreeTabActivation(options, openedPath)
|
||||
}
|
||||
|
||||
function scheduleOpenedWorktreeTabActivation<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>,
|
||||
openedPath: string
|
||||
): void {
|
||||
let activated = false
|
||||
const activateOpenedTab = async (): Promise<void> => {
|
||||
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) {
|
||||
return
|
||||
}
|
||||
await options.fetchSessionTabs()
|
||||
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) {
|
||||
return
|
||||
}
|
||||
const opened = options.getSessionTabs().find((tab) => tab.relativePath === openedPath)
|
||||
if (!opened) {
|
||||
return
|
||||
}
|
||||
if (options.getActiveSessionTabId() !== opened.id) {
|
||||
options.switchSessionTab(opened)
|
||||
}
|
||||
activated = true
|
||||
}
|
||||
|
||||
options.scheduleDelayedAction(() => void activateOpenedTab(), 300)
|
||||
options.scheduleDelayedAction(() => void activateOpenedTab(), 900)
|
||||
options.scheduleDelayedAction(() => void activateOpenedTab(), 1800)
|
||||
}
|
||||
|
||||
function displayNameFromPath(path: string): string | undefined {
|
||||
return path.split(/[\\/]/).filter(Boolean).at(-1)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = {
|
||||
readonly showToast: ShowToast
|
||||
readonly onSuccess: () => void
|
||||
readonly onError: () => void
|
||||
readonly beforeTerminalSend?: (terminal: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
type MobileImageAttachment = {
|
||||
@@ -46,7 +47,8 @@ export function useMobileImageAttachment({
|
||||
getActiveWorktreeConnectionId,
|
||||
showToast,
|
||||
onSuccess,
|
||||
onError
|
||||
onError,
|
||||
beforeTerminalSend
|
||||
}: UseMobileImageAttachmentArgs): MobileImageAttachment {
|
||||
const [isAttaching, setIsAttaching] = useState(false)
|
||||
const attachImage = useCallback(
|
||||
@@ -61,7 +63,8 @@ export function useMobileImageAttachment({
|
||||
deviceToken: deviceTokenRef.current,
|
||||
getConnectionId: getActiveWorktreeConnectionId,
|
||||
pickImage: pickMobileImage,
|
||||
onUploadStart: () => setIsAttaching(true)
|
||||
onUploadStart: () => setIsAttaching(true),
|
||||
beforeTerminalSend
|
||||
})
|
||||
// Cancelled picker: no error, no toast.
|
||||
if (sent) {
|
||||
@@ -88,6 +91,7 @@ export function useMobileImageAttachment({
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
beforeTerminalSend,
|
||||
canSend,
|
||||
client,
|
||||
connState,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, type RefObject } from 'react'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { File as FsFile, Paths } from 'expo-file-system'
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
|
||||
import type { TerminalModes } from '../terminal/TerminalWebView'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
buildMobileImagePastePayload,
|
||||
prepareMobileClipboardImageBase64,
|
||||
saveMobileClipboardImageAsTempFile,
|
||||
type MobileClipboardImageResizer
|
||||
} from './mobile-clipboard-image'
|
||||
|
||||
const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
|
||||
|
||||
// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
|
||||
// photos can exceed the upload byte budget; resize the raster down to fit before upload.
|
||||
// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file.
|
||||
const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
|
||||
const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
|
||||
const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
|
||||
let context: ReturnType<typeof ImageManipulator.manipulate> | null = null
|
||||
let rendered: Awaited<
|
||||
ReturnType<ReturnType<typeof ImageManipulator.manipulate>['renderAsync']>
|
||||
> | null = null
|
||||
let resultUri: string | null = null
|
||||
try {
|
||||
file.create({ overwrite: true })
|
||||
file.write(base64, { encoding: 'base64' })
|
||||
context = ImageManipulator.manipulate(file.uri)
|
||||
context.resize({ width: target.width, height: target.height })
|
||||
rendered = await context.renderAsync()
|
||||
const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
|
||||
resultUri = result.uri
|
||||
// Why: empty base64 would pass the downstream base64 check and upload a corrupt
|
||||
// image, so fail loudly here instead of silently sending an invalid payload.
|
||||
if (!result.base64) {
|
||||
throw new Error('Failed to encode resized clipboard image')
|
||||
}
|
||||
return { data: result.base64, width: result.width, height: result.height }
|
||||
} finally {
|
||||
rendered?.release()
|
||||
context?.release()
|
||||
if (resultUri) {
|
||||
try {
|
||||
new FsFile(resultUri).delete()
|
||||
} catch {
|
||||
// Best-effort cleanup; ImageManipulator saves into cache for every retry.
|
||||
}
|
||||
}
|
||||
try {
|
||||
file.delete()
|
||||
} catch {
|
||||
// Best-effort cleanup; the OS reclaims the cache directory regardless.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMobileTerminalClipboardTextPayload(
|
||||
text: string,
|
||||
modes: TerminalModes | undefined
|
||||
): string {
|
||||
const wrap = modes?.bracketedPasteMode === true && !modes.altScreen
|
||||
// Why: strip embedded bracketed-paste markers so copied text cannot terminate
|
||||
// paste mode early and turn trailing bytes into shell commands.
|
||||
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
|
||||
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
|
||||
return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
|
||||
}
|
||||
|
||||
type UseMobileTerminalPasteOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
readonly activeSessionTabTypeRef: RefObject<string | null>
|
||||
readonly canSend: boolean
|
||||
readonly client: RpcClient | null
|
||||
readonly clientRef: RefObject<RpcClient | null>
|
||||
readonly connState: ConnectionState
|
||||
readonly connStateRef: RefObject<ConnectionState>
|
||||
readonly deviceTokenRef: RefObject<string | null>
|
||||
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
|
||||
readonly getActiveWorktreeConnectionId: () => Promise<string | null>
|
||||
readonly onError: () => void
|
||||
readonly onSuccess: () => void
|
||||
readonly ptyModesRef: RefObject<Map<string, TerminalModes>>
|
||||
readonly refreshCanPaste: () => void
|
||||
readonly showToast: (message: string, durationMs?: number) => void
|
||||
}
|
||||
|
||||
export function useMobileTerminalPaste({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
canSend,
|
||||
client,
|
||||
clientRef,
|
||||
connState,
|
||||
connStateRef,
|
||||
deviceTokenRef,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getActiveWorktreeConnectionId,
|
||||
onError,
|
||||
onSuccess,
|
||||
ptyModesRef,
|
||||
refreshCanPaste,
|
||||
showToast
|
||||
}: UseMobileTerminalPasteOptions): () => Promise<void> {
|
||||
return useCallback(async () => {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
}
|
||||
const targetHandle = activeHandle
|
||||
try {
|
||||
const text = await Clipboard.getStringAsync()
|
||||
let payload: string | null = null
|
||||
if (text.length > 0) {
|
||||
payload = buildMobileTerminalClipboardTextPayload(
|
||||
text,
|
||||
ptyModesRef.current.get(targetHandle)
|
||||
)
|
||||
} else {
|
||||
const image = await Clipboard.getImageAsync({ format: 'png' })
|
||||
if (!image) {
|
||||
refreshCanPaste()
|
||||
return
|
||||
}
|
||||
const connectionId = await getActiveWorktreeConnectionId()
|
||||
const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
|
||||
const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
|
||||
connectionId
|
||||
})
|
||||
payload = buildMobileImagePastePayload(imagePath)
|
||||
}
|
||||
|
||||
const wrappedBytes = new TextEncoder().encode(payload).byteLength
|
||||
if (wrappedBytes > 256 * 1024) {
|
||||
onError()
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
|
||||
showToast('Paste too large (max 256 KiB)', 1500)
|
||||
return
|
||||
}
|
||||
// Why: paste lives in the accessory row and must not overtake pending IME text.
|
||||
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
|
||||
if (!flushedPendingInput) {
|
||||
return
|
||||
}
|
||||
const currentClient = clientRef.current
|
||||
if (
|
||||
!currentClient ||
|
||||
connStateRef.current !== 'connected' ||
|
||||
targetHandle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal'
|
||||
) {
|
||||
return
|
||||
}
|
||||
await currentClient.sendRequest('terminal.send', {
|
||||
terminal: targetHandle,
|
||||
text: payload,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
onSuccess()
|
||||
refreshCanPaste()
|
||||
} catch (e) {
|
||||
onError()
|
||||
const err = e as { name?: string; message?: string }
|
||||
const isDisconnected = connState !== 'connected'
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
|
||||
if (isDisconnected) {
|
||||
showToast('Paste failed (disconnected)', 1500)
|
||||
} else if (err.message === 'Clipboard image is too large') {
|
||||
showToast('Image too large to paste', 1500)
|
||||
} else {
|
||||
showToast('Paste failed', 1500)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
canSend,
|
||||
client,
|
||||
clientRef,
|
||||
connState,
|
||||
connStateRef,
|
||||
deviceTokenRef,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getActiveWorktreeConnectionId,
|
||||
onError,
|
||||
onSuccess,
|
||||
ptyModesRef,
|
||||
refreshCanPaste,
|
||||
showToast
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
type DisabledTerminalLiveInputHandlesPreference
|
||||
} from '../storage/preferences'
|
||||
import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference'
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
readDisabledTerminalLiveInputHandlesPreference: vi.fn(),
|
||||
saveDisabledTerminalLiveInputHandles: vi.fn()
|
||||
}))
|
||||
|
||||
type TerminalLiveInputModePreferenceHarness = {
|
||||
readonly current: ReturnType<typeof useTerminalLiveInputModePreference>
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
readonly promise: Promise<T>
|
||||
readonly resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve: ((value: T) => void) | null = null
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
if (!resolve) {
|
||||
throw new Error('deferred resolver was not initialized')
|
||||
}
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness {
|
||||
let current: ReturnType<typeof useTerminalLiveInputModePreference> | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(): null {
|
||||
current = useTerminalLiveInputModePreference({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'worktree-1'
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!current || !renderer) {
|
||||
throw new Error('terminal live input mode preference hook did not render')
|
||||
}
|
||||
|
||||
return {
|
||||
get current() {
|
||||
if (!current) {
|
||||
throw new Error('terminal live input mode preference hook is not mounted')
|
||||
}
|
||||
return current
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live input mode preference hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset()
|
||||
vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset()
|
||||
vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue()
|
||||
})
|
||||
|
||||
it('merges pre-hydration edits with loaded disabled handles', async () => {
|
||||
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
|
||||
const harness = createTerminalLiveInputModePreferenceHarness()
|
||||
|
||||
act(() => {
|
||||
harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2'])
|
||||
})
|
||||
act(() => {
|
||||
expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
load.resolve({ handles: new Set(['pty-2']), loaded: true })
|
||||
await load.promise
|
||||
})
|
||||
|
||||
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
|
||||
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1)
|
||||
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith(
|
||||
'host-1',
|
||||
'worktree-1',
|
||||
new Set(['pty-2'])
|
||||
)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('does not persist fallback-empty storage reads during clean hydration', async () => {
|
||||
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
|
||||
const harness = createTerminalLiveInputModePreferenceHarness()
|
||||
|
||||
act(() => {
|
||||
harness.current.defaultTerminalHandlesToLiveInput(['pty-1'])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
load.resolve({ handles: new Set(), loaded: false })
|
||||
await load.promise
|
||||
})
|
||||
|
||||
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
|
||||
expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled()
|
||||
harness.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles
|
||||
} from '../storage/preferences'
|
||||
import {
|
||||
applyDisabledTerminalLiveInputHandles,
|
||||
defaultTerminalLiveInputHandles,
|
||||
filterTerminalLiveInputDefaultCandidates,
|
||||
pruneTerminalLiveInputHandles
|
||||
} from '../terminal/terminal-live-input'
|
||||
|
||||
type UseTerminalLiveInputModePreferenceOptions = {
|
||||
readonly hostId: string
|
||||
readonly worktreeId: string
|
||||
}
|
||||
|
||||
export function useTerminalLiveInputModePreference({
|
||||
hostId,
|
||||
worktreeId
|
||||
}: UseTerminalLiveInputModePreferenceOptions) {
|
||||
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const disabledLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const disabledLiveInputHydratedRef = useRef(false)
|
||||
const pendingDisabledLiveInputHydrationEditsRef = useRef<Map<string, boolean>>(new Map())
|
||||
const pendingLiveInputDefaultHandlesRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
|
||||
// Why: terminal discovery (tab snapshots, list poll, create) can arrive
|
||||
// before the async persisted-disabled load on worktree re-entry.
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
for (const handle of handles) {
|
||||
pendingLiveInputDefaultHandlesRef.current.add(handle)
|
||||
}
|
||||
return
|
||||
}
|
||||
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
|
||||
handles,
|
||||
disabledLiveInputTerminalHandlesRef.current
|
||||
)
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
defaultableHandles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const persistDisabledLiveInputHandles = useCallback(() => {
|
||||
void saveDisabledTerminalLiveInputHandles(
|
||||
hostId,
|
||||
worktreeId,
|
||||
disabledLiveInputTerminalHandlesRef.current
|
||||
).catch(() => {})
|
||||
}, [hostId, worktreeId])
|
||||
|
||||
const pruneTerminalHandlesFromLiveInput = useCallback(
|
||||
(liveHandles: ReadonlySet<string>) => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
liveHandles
|
||||
)
|
||||
let prunedDisabledHandles = false
|
||||
for (const handle of disabledLiveInputTerminalHandlesRef.current) {
|
||||
if (liveHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
disabledLiveInputTerminalHandlesRef.current.delete(handle)
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
|
||||
}
|
||||
prunedDisabledHandles = true
|
||||
}
|
||||
if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
},
|
||||
[persistDisabledLiveInputHandles]
|
||||
)
|
||||
|
||||
const clearTerminalLiveInputDefault = useCallback(
|
||||
(handle: string) => {
|
||||
const liveHandles = new Set([
|
||||
...liveInputTerminalHandlesRef.current,
|
||||
...defaultedLiveInputTerminalHandlesRef.current
|
||||
])
|
||||
liveHandles.delete(handle)
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
|
||||
}
|
||||
if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) {
|
||||
if (disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
}
|
||||
pruneTerminalHandlesFromLiveInput(liveHandles)
|
||||
},
|
||||
[persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput]
|
||||
)
|
||||
|
||||
const toggleTerminalLiveInput = useCallback(
|
||||
(handle: string): boolean => {
|
||||
const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle)
|
||||
if (nextEnabled) {
|
||||
disabledLiveInputTerminalHandlesRef.current.delete(handle)
|
||||
} else {
|
||||
disabledLiveInputTerminalHandlesRef.current.add(handle)
|
||||
}
|
||||
// Why: pre-hydration edits must patch the loaded set per handle; replacing
|
||||
// the loaded set would erase other persisted opt-outs for this worktree.
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled)
|
||||
}
|
||||
// Why: only persist after hydration; an earlier write would use the
|
||||
// reset-empty ref and overwrite other handles' opt-outs for the worktree.
|
||||
if (disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
setLiveInputTerminalHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (nextEnabled) {
|
||||
next.add(handle)
|
||||
} else {
|
||||
next.delete(handle)
|
||||
}
|
||||
liveInputTerminalHandlesRef.current = next
|
||||
return next
|
||||
})
|
||||
return nextEnabled
|
||||
},
|
||||
[persistDisabledLiveInputHandles]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
liveInputTerminalHandlesRef.current = new Set()
|
||||
defaultedLiveInputTerminalHandlesRef.current = new Set()
|
||||
disabledLiveInputTerminalHandlesRef.current = new Set()
|
||||
disabledLiveInputHydratedRef.current = false
|
||||
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
|
||||
pendingLiveInputDefaultHandlesRef.current = new Set()
|
||||
setLiveInputTerminalHandles(new Set())
|
||||
|
||||
let disposed = false
|
||||
// Why: load the persisted opt-outs first so defaulting logic (which can
|
||||
// fire immediately from subscriptions) respects prior user choices.
|
||||
void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current
|
||||
const hydratedDisabledHandles = new Set(preference.handles)
|
||||
for (const [handle, disabled] of pendingEdits) {
|
||||
if (disabled) {
|
||||
hydratedDisabledHandles.add(handle)
|
||||
} else {
|
||||
hydratedDisabledHandles.delete(handle)
|
||||
}
|
||||
}
|
||||
disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles
|
||||
disabledLiveInputHydratedRef.current = true
|
||||
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
|
||||
if (preference.loaded && pendingEdits.size > 0) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
const result = applyDisabledTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
hydratedDisabledHandles
|
||||
)
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current]
|
||||
pendingLiveInputDefaultHandlesRef.current.clear()
|
||||
defaultTerminalHandlesToLiveInput(pendingDefaultHandles)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId])
|
||||
|
||||
return {
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
toggleTerminalLiveInput
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
HOST_SIDEBAR_MIN_WIDTH,
|
||||
clampHostDockWidth,
|
||||
clampHostSidebarWidth,
|
||||
loadDisabledTerminalLiveInputHandles,
|
||||
loadHostSidebarWidth,
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalLinkOpenMode,
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
saveHostSidebarWidth,
|
||||
saveTerminalAutocompleteEnabled,
|
||||
saveTerminalLinkOpenMode
|
||||
@@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal live input disabled handles preference', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
})
|
||||
|
||||
it('defaults to no disabled handles when unset', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
await expect(
|
||||
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
|
||||
).resolves.toEqual({ handles: new Set(), loaded: true })
|
||||
})
|
||||
|
||||
it('loads only string terminal handles from storage', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2']))
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set(['pty-1', 'pty-2'])
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to no disabled handles for invalid or unreadable storage', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
|
||||
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
await expect(
|
||||
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
|
||||
).resolves.toEqual({ handles: new Set(), loaded: false })
|
||||
})
|
||||
|
||||
it('persists disabled handles per host and worktree', async () => {
|
||||
await saveDisabledTerminalLiveInputHandles(
|
||||
'host/one',
|
||||
'folder:C:\\repo',
|
||||
new Set(['pty-2', 'pty-1'])
|
||||
)
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo',
|
||||
JSON.stringify(['pty-2', 'pty-1'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host sidebar width preference', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
|
||||
@@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
|
||||
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
|
||||
}
|
||||
|
||||
const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:'
|
||||
|
||||
export type DisabledTerminalLiveInputHandlesPreference = {
|
||||
readonly handles: Set<string>
|
||||
readonly loaded: boolean
|
||||
}
|
||||
|
||||
function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string {
|
||||
return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
|
||||
worktreeId
|
||||
)}`
|
||||
}
|
||||
|
||||
export async function readDisabledTerminalLiveInputHandlesPreference(
|
||||
hostId: string,
|
||||
worktreeId: string
|
||||
): Promise<DisabledTerminalLiveInputHandlesPreference> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId))
|
||||
if (!raw) {
|
||||
return { handles: new Set(), loaded: true }
|
||||
}
|
||||
return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
|
||||
} catch {
|
||||
return { handles: new Set(), loaded: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDisabledTerminalLiveInputHandles(
|
||||
hostId: string,
|
||||
worktreeId: string
|
||||
): Promise<Set<string>> {
|
||||
const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId)
|
||||
return preference.handles
|
||||
}
|
||||
|
||||
export async function saveDisabledTerminalLiveInputHandles(
|
||||
hostId: string,
|
||||
worktreeId: string,
|
||||
handles: ReadonlySet<string>
|
||||
): Promise<void> {
|
||||
await AsyncStorage.setItem(
|
||||
terminalLiveInputDisabledKey(hostId, worktreeId),
|
||||
JSON.stringify([...handles])
|
||||
)
|
||||
}
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth'
|
||||
|
||||
// Bounds for the draggable host worktree-list sidebar on tablet/foldable
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { TappedFilePath } from './terminal-path-tap'
|
||||
import { parsePathWithOptionalLineColumn } from './terminal-path-tap'
|
||||
|
||||
export function resolveTerminalFileUrlTap(uri: string): TappedFilePath | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(uri)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const filePath = terminalFileUriToPathText(parsed)
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
const hashTarget = parseFileUrlLineHash(parsed.hash)
|
||||
if (hashTarget) {
|
||||
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }
|
||||
}
|
||||
if (/%3a/i.test(parsed.pathname)) {
|
||||
return { pathText: filePath, line: null, column: null }
|
||||
}
|
||||
return (
|
||||
parseFilePathTrailingLineTarget(filePath) ?? { pathText: filePath, line: null, column: null }
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveTerminalOscFileTap(uri: string): TappedFilePath | null {
|
||||
return resolveTerminalFileUrlTap(uri) ?? parseOscPathLikeTarget(uri)
|
||||
}
|
||||
|
||||
function terminalFileUriToPathText(url: URL): string | null {
|
||||
if (url.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
let decodedPath: string
|
||||
try {
|
||||
decodedPath = decodeURIComponent(url.pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.hostname && !isLocalFileUriHostname(url.hostname)) {
|
||||
return `//${url.hostname}${decodedPath}`
|
||||
}
|
||||
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
|
||||
return decodedPath.slice(1)
|
||||
}
|
||||
return decodedPath || null
|
||||
}
|
||||
|
||||
function isLocalFileUriHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase()
|
||||
return (
|
||||
normalized === 'localhost' ||
|
||||
normalized === '127.0.0.1' ||
|
||||
normalized === '::1' ||
|
||||
normalized === '[::1]'
|
||||
)
|
||||
}
|
||||
|
||||
function parseOscPathLikeTarget(value: string): TappedFilePath | null {
|
||||
if (
|
||||
!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(
|
||||
value
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsePathWithOptionalLineColumn(value)
|
||||
}
|
||||
|
||||
function parseFileUrlLineHash(hash: string): { line: number; column: number | null } | null {
|
||||
const match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const line = Number.parseInt(match[1]!, 10)
|
||||
const column = match[2] ? Number.parseInt(match[2], 10) : null
|
||||
if (line < 1 || (column !== null && column < 1)) {
|
||||
return null
|
||||
}
|
||||
return { line, column }
|
||||
}
|
||||
|
||||
function parseFilePathTrailingLineTarget(filePath: string): TappedFilePath | null {
|
||||
const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath)
|
||||
if (!match || !match[1] || match[1].endsWith('/') || match[1].endsWith('\\')) {
|
||||
return null
|
||||
}
|
||||
const line = Number.parseInt(match[2]!, 10)
|
||||
const column = match[3] ? Number.parseInt(match[3], 10) : null
|
||||
if (line < 1 || (column !== null && column < 1)) {
|
||||
return null
|
||||
}
|
||||
return { pathText: match[1], line, column }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit'
|
||||
|
||||
export type TerminalLiveAccessoryInput = {
|
||||
readonly bytes: string
|
||||
readonly localEdit?: TerminalLiveAccessoryLocalEdit
|
||||
}
|
||||
|
||||
type TerminalLiveAccessoryKey = {
|
||||
readonly bytes: string
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
export function createTerminalLiveAccessoryInput(
|
||||
key: TerminalLiveAccessoryKey
|
||||
): TerminalLiveAccessoryInput {
|
||||
if (key.id === 'backspace' || key.id === 'delete') {
|
||||
return { bytes: key.bytes, localEdit: key.id }
|
||||
}
|
||||
|
||||
return { bytes: key.bytes }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
|
||||
|
||||
describe('terminal live accessory raw send target', () => {
|
||||
it('Given the original terminal is still active When raw fallback resumes Then returns that handle', () => {
|
||||
// Given
|
||||
const targetHandle = 'terminal-a'
|
||||
|
||||
// When
|
||||
const sendTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle,
|
||||
activeHandle: targetHandle,
|
||||
activeSessionTabType: 'terminal'
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(sendTarget).toBe(targetHandle)
|
||||
})
|
||||
|
||||
it('Given the active terminal changed while waiting When raw fallback resumes Then suppresses the send', () => {
|
||||
// Given
|
||||
const targetHandle = 'terminal-a'
|
||||
|
||||
// When
|
||||
const sendTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle,
|
||||
activeHandle: 'terminal-b',
|
||||
activeSessionTabType: 'terminal'
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(sendTarget).toBeNull()
|
||||
})
|
||||
|
||||
it('Given the target is not an active terminal tab When raw fallback resumes Then suppresses the send', () => {
|
||||
// Given
|
||||
const targetHandle = 'terminal-a'
|
||||
|
||||
// When
|
||||
const inactiveTabTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle,
|
||||
activeHandle: targetHandle,
|
||||
activeSessionTabType: 'browser'
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(inactiveTabTarget).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
type TerminalLiveAccessoryRawSendTargetInput<TTabType extends string> = {
|
||||
readonly targetHandle: string
|
||||
readonly activeHandle: string | null
|
||||
readonly activeSessionTabType: TTabType | null
|
||||
}
|
||||
|
||||
export function getTerminalLiveAccessoryRawSendTarget<TTabType extends string>({
|
||||
targetHandle,
|
||||
activeHandle,
|
||||
activeSessionTabType
|
||||
}: TerminalLiveAccessoryRawSendTargetInput<TTabType>): string | null {
|
||||
if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') {
|
||||
return null
|
||||
}
|
||||
|
||||
return targetHandle
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
|
||||
describe('terminal live control send order', () => {
|
||||
it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
const flushPendingText = vi.fn(async () => {
|
||||
events.push('flush')
|
||||
return false
|
||||
})
|
||||
const sendControlBytes = vi.fn(async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await sendTerminalLiveControlAfterPendingFlush(
|
||||
flushPendingText,
|
||||
sendControlBytes
|
||||
)
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false)
|
||||
expect(sendControlBytes).not.toHaveBeenCalled()
|
||||
expect(events).toEqual(['flush'])
|
||||
})
|
||||
|
||||
it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
const flushPendingText = vi.fn(async () => {
|
||||
events.push('flush')
|
||||
return true
|
||||
})
|
||||
const sendControlBytes = vi.fn(async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await sendTerminalLiveControlAfterPendingFlush(
|
||||
flushPendingText,
|
||||
sendControlBytes
|
||||
)
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true)
|
||||
expect(events).toEqual(['flush', 'control'])
|
||||
})
|
||||
|
||||
it('Given a failed control byte send When pending text flushed Then reports failure', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
const flushPendingText = vi.fn(async () => {
|
||||
events.push('flush')
|
||||
return true
|
||||
})
|
||||
const sendControlBytes = vi.fn(async () => {
|
||||
events.push('control')
|
||||
return false
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await sendTerminalLiveControlAfterPendingFlush(
|
||||
flushPendingText,
|
||||
sendControlBytes
|
||||
)
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false)
|
||||
expect(events).toEqual(['flush', 'control'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
export type TerminalLiveAsyncSendStep = () => Promise<boolean>
|
||||
|
||||
export async function sendTerminalLiveControlAfterPendingFlush(
|
||||
flushPendingText: TerminalLiveAsyncSendStep,
|
||||
sendControlBytes: TerminalLiveAsyncSendStep
|
||||
): Promise<boolean> {
|
||||
const flushed = await flushPendingText()
|
||||
if (!flushed) {
|
||||
return false
|
||||
}
|
||||
return sendControlBytes()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise<boolean>
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIVE_INPUT_MAX_BYTES,
|
||||
applyDisabledTerminalLiveInputHandles,
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
defaultTerminalLiveInputHandles,
|
||||
filterTerminalLiveInputDefaultCandidates,
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
pruneTerminalLiveInputHandles,
|
||||
@@ -101,6 +103,35 @@ describe('terminal live input', () => {
|
||||
expect(result.defaultedHandles).toBe(defaulted)
|
||||
})
|
||||
|
||||
it('does not default persisted buffered-mode handles back to live input on reentry', () => {
|
||||
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
|
||||
['pty-1', 'pty-2'],
|
||||
new Set(['pty-1'])
|
||||
)
|
||||
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
new Set(),
|
||||
new Set(['pty-1']),
|
||||
defaultableHandles
|
||||
)
|
||||
|
||||
expect(defaultableHandles).toEqual(['pty-2'])
|
||||
expect([...result.enabledHandles]).toEqual(['pty-2'])
|
||||
expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2'])
|
||||
})
|
||||
|
||||
it('reconciles persisted buffered-mode handles with currently enabled live input', () => {
|
||||
const result = applyDisabledTerminalLiveInputHandles(
|
||||
new Set(['pty-1', 'pty-2']),
|
||||
new Set(['pty-2']),
|
||||
new Set(['pty-1'])
|
||||
)
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect([...result.enabledHandles]).toEqual(['pty-2'])
|
||||
expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1'])
|
||||
})
|
||||
|
||||
it('prunes terminal handles that disappear from session snapshots', () => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
new Set(['pty-1', 'pty-stale']),
|
||||
|
||||
@@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles(
|
||||
}
|
||||
}
|
||||
|
||||
export function filterTerminalLiveInputDefaultCandidates(
|
||||
terminalHandles: readonly string[],
|
||||
disabledHandles: ReadonlySet<string>
|
||||
): string[] {
|
||||
return terminalHandles.filter((handle) => !disabledHandles.has(handle))
|
||||
}
|
||||
|
||||
export function applyDisabledTerminalLiveInputHandles(
|
||||
enabledHandles: ReadonlySet<string>,
|
||||
defaultedHandles: ReadonlySet<string>,
|
||||
disabledHandles: ReadonlySet<string>
|
||||
): TerminalLiveInputDefaultResult {
|
||||
let nextEnabledHandles: Set<string> | null = null
|
||||
let nextDefaultedHandles: Set<string> | null = null
|
||||
|
||||
for (const handle of enabledHandles) {
|
||||
if (!disabledHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
nextEnabledHandles ??= new Set(enabledHandles)
|
||||
nextEnabledHandles.delete(handle)
|
||||
}
|
||||
|
||||
for (const handle of disabledHandles) {
|
||||
if (defaultedHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
nextDefaultedHandles ??= new Set(defaultedHandles)
|
||||
nextDefaultedHandles.add(handle)
|
||||
}
|
||||
|
||||
if (!nextEnabledHandles && !nextDefaultedHandles) {
|
||||
return { enabledHandles, defaultedHandles, changed: false }
|
||||
}
|
||||
|
||||
return {
|
||||
enabledHandles: nextEnabledHandles ?? enabledHandles,
|
||||
defaultedHandles: nextDefaultedHandles ?? defaultedHandles,
|
||||
changed: true
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneTerminalLiveInputHandles(
|
||||
enabledHandles: ReadonlySet<string>,
|
||||
defaultedHandles: ReadonlySet<string>,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import {
|
||||
queueTerminalLivePendingFlush,
|
||||
waitForTerminalLivePendingFlush,
|
||||
type TerminalLivePendingFlushState
|
||||
} from './terminal-live-pending-flush-state'
|
||||
|
||||
describe('terminal live pending flush state', () => {
|
||||
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
|
||||
// When / Then
|
||||
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFlush: (value: boolean) => void = () => {}
|
||||
const flushPromise = new Promise<boolean>((resolve) => {
|
||||
resolveFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: flushPromise }
|
||||
|
||||
// When
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(events).toEqual([])
|
||||
resolveFlush(true)
|
||||
await expect(controlSend).resolves.toBe(true)
|
||||
expect(events).toEqual(['control'])
|
||||
})
|
||||
|
||||
it('Given an in-flight flush fails When control input waits Then control is skipped', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFlush: (value: boolean) => void = () => {}
|
||||
const flushPromise = new Promise<boolean>((resolve) => {
|
||||
resolveFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: flushPromise }
|
||||
|
||||
// When
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
resolveFlush(false)
|
||||
|
||||
// Then
|
||||
await expect(controlSend).resolves.toBe(false)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
|
||||
// Given
|
||||
const resolvedState: TerminalLivePendingFlushState = { current: null }
|
||||
const rejectedState: TerminalLivePendingFlushState = { current: null }
|
||||
|
||||
// When
|
||||
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
|
||||
await expect(
|
||||
queueTerminalLivePendingFlush(rejectedState, async () => {
|
||||
throw new Error('send failed')
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(resolvedState.current).toBeNull()
|
||||
expect(rejectedState.current).toBeNull()
|
||||
})
|
||||
|
||||
it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFirstFlush: (value: boolean) => void = () => {}
|
||||
const firstFlush = new Promise<boolean>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: firstFlush }
|
||||
|
||||
// When
|
||||
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
|
||||
events.push('second-flush')
|
||||
return true
|
||||
})
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(events).toEqual([])
|
||||
resolveFirstFlush(true)
|
||||
await expect(secondFlush).resolves.toBe(true)
|
||||
await expect(controlSend).resolves.toBe(true)
|
||||
expect(events).toEqual(['second-flush', 'control'])
|
||||
})
|
||||
|
||||
it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFirstFlush: (value: boolean) => void = () => {}
|
||||
const firstFlush = new Promise<boolean>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: firstFlush }
|
||||
|
||||
// When
|
||||
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
|
||||
events.push('second-flush')
|
||||
return true
|
||||
})
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
resolveFirstFlush(false)
|
||||
|
||||
// Then
|
||||
await expect(secondFlush).resolves.toBe(false)
|
||||
await expect(controlSend).resolves.toBe(false)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
export type TerminalLivePendingFlushState = {
|
||||
current: Promise<boolean> | null
|
||||
}
|
||||
|
||||
export function waitForTerminalLivePendingFlush(
|
||||
state: TerminalLivePendingFlushState
|
||||
): Promise<boolean> {
|
||||
return state.current ?? Promise.resolve(true)
|
||||
}
|
||||
|
||||
export function queueTerminalLivePendingFlush(
|
||||
state: TerminalLivePendingFlushState,
|
||||
sendPendingText: () => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const previousFlush = state.current
|
||||
const flushPromise = (async () => {
|
||||
if (previousFlush && !(await previousFlush)) {
|
||||
return false
|
||||
}
|
||||
return sendPendingText()
|
||||
})().catch(() => false)
|
||||
state.current = flushPromise
|
||||
void flushPromise.then(() => {
|
||||
if (state.current === flushPromise) {
|
||||
state.current = null
|
||||
}
|
||||
})
|
||||
return flushPromise
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
|
||||
getTerminalLiveAccessoryBytesDecision,
|
||||
getTerminalLiveAccessoryLocalEditText,
|
||||
getTerminalLiveDeferredTextDelayMs,
|
||||
getTerminalLiveSpecialKeyDecision,
|
||||
getTerminalLiveSubmitSequence,
|
||||
getTerminalLiveTextChangeDecision,
|
||||
isTerminalLiveTextHangulCandidate,
|
||||
isTerminalLiveTextImeCandidate
|
||||
} from './terminal-live-text-commit'
|
||||
|
||||
describe('terminal live text commit', () => {
|
||||
it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => {
|
||||
// Given
|
||||
const koreanCompositionSteps = ['ㅎ', '하', '한'] as const
|
||||
|
||||
// When
|
||||
const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision)
|
||||
const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now')
|
||||
const submitSequence = getTerminalLiveSubmitSequence('한')
|
||||
const composedWordDecision = getTerminalLiveTextChangeDecision('한글')
|
||||
const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글')
|
||||
|
||||
// Then
|
||||
expect(decisions).toEqual([
|
||||
{ kind: 'defer', text: 'ㅎ', delayMs: null },
|
||||
{ kind: 'defer', text: '하', delayMs: null },
|
||||
{ kind: 'defer', text: '한', delayMs: null }
|
||||
])
|
||||
expect(sentImmediately).toEqual([])
|
||||
expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true)
|
||||
expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull()
|
||||
expect(submitSequence).toEqual(['한', '\r'])
|
||||
expect(composedWordDecision).toEqual({
|
||||
kind: 'defer',
|
||||
text: '한글',
|
||||
delayMs: null
|
||||
})
|
||||
expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
|
||||
})
|
||||
|
||||
it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => {
|
||||
// Given
|
||||
const text = 'あ'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
|
||||
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
|
||||
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS })
|
||||
})
|
||||
|
||||
it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => {
|
||||
// Given
|
||||
const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const
|
||||
|
||||
for (const text of nonHangulImeTexts) {
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
|
||||
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
|
||||
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
expect(decision).toEqual({
|
||||
kind: 'defer',
|
||||
text,
|
||||
delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('Given ASCII text When live text changes Then sends immediately', () => {
|
||||
// Given
|
||||
const text = 'abc123'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
|
||||
expect(decision).toEqual({ kind: 'send-now', text })
|
||||
})
|
||||
|
||||
it('Given empty text When live text changes Then ignores the change', () => {
|
||||
// Given
|
||||
const text = ''
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
|
||||
expect(decision).toEqual({ kind: 'ignore' })
|
||||
})
|
||||
|
||||
it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => {
|
||||
// Given
|
||||
const pendingText = '한'
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
|
||||
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'local-edit' })
|
||||
expect(deleteDecision).toEqual({ kind: 'local-edit' })
|
||||
})
|
||||
|
||||
it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
|
||||
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' })
|
||||
expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' })
|
||||
})
|
||||
|
||||
it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => {
|
||||
// Given
|
||||
const pendingText = '한'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText })
|
||||
|
||||
// Then
|
||||
expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
|
||||
})
|
||||
|
||||
it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => {
|
||||
// Given
|
||||
const pendingText = '한글'
|
||||
|
||||
// When
|
||||
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
|
||||
const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText })
|
||||
const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText })
|
||||
|
||||
// Then
|
||||
expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
|
||||
expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' })
|
||||
expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' })
|
||||
})
|
||||
|
||||
it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => {
|
||||
// Given
|
||||
const pendingText = '한글'
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
})
|
||||
const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\b',
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
})
|
||||
const deleteDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x1b[3~',
|
||||
localEdit: 'delete',
|
||||
pendingText
|
||||
})
|
||||
const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
pendingText
|
||||
})
|
||||
const backspaceText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
})
|
||||
const deleteText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: 'delete',
|
||||
pendingText
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' })
|
||||
expect(customDeleteByteDecision).toEqual({
|
||||
kind: 'flush-then-send',
|
||||
pendingText,
|
||||
bytes: '\x7f'
|
||||
})
|
||||
expect(backspaceText).toBe('한')
|
||||
expect(deleteText).toBe('한글')
|
||||
expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull()
|
||||
expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull()
|
||||
})
|
||||
|
||||
it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
|
||||
// When
|
||||
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
|
||||
|
||||
// Then
|
||||
expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
|
||||
})
|
||||
|
||||
it('Given a non-special key When key decision is requested Then ignores it', () => {
|
||||
// Given
|
||||
const key = 'a'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' })
|
||||
|
||||
// Then
|
||||
expect(decision).toEqual({ kind: 'ignore' })
|
||||
})
|
||||
|
||||
it('Given no pending text When submit is requested Then sends only carriage return', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
|
||||
// When
|
||||
const sequence = getTerminalLiveSubmitSequence(pendingText)
|
||||
|
||||
// Then
|
||||
expect(sequence).toEqual(['\r'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
|
||||
|
||||
// Why: React Native does not expose portable composition events here, so
|
||||
// non-Hangul IME text gets a short settle window before being sent to the PTY.
|
||||
export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150
|
||||
|
||||
export type TerminalLiveTextChangeDecision =
|
||||
| { readonly kind: 'ignore' }
|
||||
| { readonly kind: 'send-now'; readonly text: string }
|
||||
| { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null }
|
||||
|
||||
export type TerminalLiveSpecialKeyDecision =
|
||||
| { readonly kind: 'ignore' }
|
||||
| { readonly kind: 'local-edit' }
|
||||
| { readonly kind: 'send-now'; readonly bytes: string }
|
||||
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
|
||||
|
||||
export type TerminalLiveSpecialKeyDecisionInput = {
|
||||
readonly key: string
|
||||
readonly pendingText: string
|
||||
}
|
||||
|
||||
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
|
||||
|
||||
export type TerminalLiveAccessoryBytesDecision =
|
||||
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
|
||||
| { readonly kind: 'send-now'; readonly bytes: string }
|
||||
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
|
||||
|
||||
export type TerminalLiveAccessoryBytesDecisionInput = {
|
||||
readonly bytes: string
|
||||
readonly localEdit?: TerminalLiveAccessoryLocalEdit
|
||||
readonly pendingText: string
|
||||
}
|
||||
|
||||
export function isTerminalLiveTextImeCandidate(text: string): boolean {
|
||||
for (const character of text) {
|
||||
const codePoint = character.codePointAt(0)
|
||||
if (codePoint !== undefined && codePoint > 0x7f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isHangulCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
|
||||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
|
||||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
|
||||
)
|
||||
}
|
||||
|
||||
export function isTerminalLiveTextHangulCandidate(text: string): boolean {
|
||||
for (const character of text) {
|
||||
const codePoint = character.codePointAt(0)
|
||||
if (codePoint !== undefined && isHangulCodePoint(codePoint)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision {
|
||||
if (text.length === 0) {
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
|
||||
if (isTerminalLiveTextHangulCandidate(text)) {
|
||||
return { kind: 'defer', text, delayMs: null }
|
||||
}
|
||||
|
||||
if (isTerminalLiveTextImeCandidate(text)) {
|
||||
return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', text }
|
||||
}
|
||||
|
||||
export function getTerminalLiveDeferredTextDelayMs(text: string): number | null {
|
||||
return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
|
||||
}
|
||||
|
||||
export function getTerminalLiveSpecialKeyDecision({
|
||||
key,
|
||||
pendingText
|
||||
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
|
||||
const bytes = getTerminalLiveSpecialKeyBytes(key)
|
||||
if (bytes === null) {
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) {
|
||||
return { kind: 'local-edit' }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0) {
|
||||
return { kind: 'flush-then-send', pendingText, bytes }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', bytes }
|
||||
}
|
||||
|
||||
export function getTerminalLiveAccessoryBytesDecision({
|
||||
bytes,
|
||||
localEdit,
|
||||
pendingText
|
||||
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
|
||||
if (pendingText.length > 0 && localEdit) {
|
||||
return { kind: 'local-edit', localEdit }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0) {
|
||||
return { kind: 'flush-then-send', pendingText, bytes }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', bytes }
|
||||
}
|
||||
|
||||
export function getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit,
|
||||
pendingText
|
||||
}: {
|
||||
readonly localEdit: TerminalLiveAccessoryLocalEdit
|
||||
readonly pendingText: string
|
||||
}): string {
|
||||
if (localEdit === 'delete') {
|
||||
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
|
||||
// it stays local but does not remove the pending IME text.
|
||||
return pendingText
|
||||
}
|
||||
|
||||
return Array.from(pendingText).slice(0, -1).join('')
|
||||
}
|
||||
|
||||
export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r']
|
||||
|
||||
export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence {
|
||||
if (pendingText.length === 0) {
|
||||
return ['\r']
|
||||
}
|
||||
|
||||
return [pendingText, '\r']
|
||||
}
|
||||
@@ -14,11 +14,12 @@
|
||||
// often print a bare filename (the markdown link target is consumed, leaving
|
||||
// only the label text), so requiring a slash would miss the common case.
|
||||
export const TERMINAL_PATH_TAP_JS = String.raw`
|
||||
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
|
||||
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
|
||||
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
|
||||
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
|
||||
var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g;
|
||||
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
|
||||
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
|
||||
|
||||
function parsePathLineCol(value) {
|
||||
function parsePathLineCol(value) {
|
||||
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
|
||||
if (!m) return null;
|
||||
var pathText = m[1];
|
||||
@@ -27,24 +28,91 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
|
||||
var line = m[2] ? parseInt(m[2], 10) : null;
|
||||
var column = m[3] ? parseInt(m[3], 10) : null;
|
||||
if ((line !== null && line < 1) || (column !== null && column < 1)) return null;
|
||||
return { pathText: pathText, line: line, column: column };
|
||||
}
|
||||
return { pathText: pathText, line: line, column: column };
|
||||
}
|
||||
|
||||
function matchFilePathAtColumn(lineText, col) {
|
||||
FILE_PATH_RE.lastIndex = 0;
|
||||
function trimPathBoundaryPunctuation(raw, rawStart) {
|
||||
var start = 0, end = raw.length;
|
||||
while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
|
||||
while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
|
||||
if (start >= end) return null;
|
||||
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
|
||||
}
|
||||
|
||||
function hasSeparatorAfterWhitespace(text) {
|
||||
var sawWhitespace = false;
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
var ch = text.charAt(i);
|
||||
if (/\s/.test(ch)) { sawWhitespace = true; continue; }
|
||||
if (sawWhitespace && (ch === '/' || ch === '\\')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trimSpacedPathTrailingProse(range, col) {
|
||||
// A line-end extension token only extends the span when the added segment
|
||||
// is path-like (contains a separator) — prose must not be swallowed.
|
||||
var selected = null;
|
||||
var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g;
|
||||
var match;
|
||||
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
|
||||
var end = match.index + match[0].length;
|
||||
var text = range.text.slice(0, end);
|
||||
if (countPathStarts(text) > 1) continue;
|
||||
if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) {
|
||||
selected = text;
|
||||
}
|
||||
}
|
||||
if (selected) {
|
||||
if (col !== undefined && col >= range.startIndex + selected.length) return null;
|
||||
return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length };
|
||||
}
|
||||
var text = range.text.replace(/\s+$/, '');
|
||||
return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length };
|
||||
}
|
||||
|
||||
function countPathStarts(text) {
|
||||
var count = 0;
|
||||
var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g;
|
||||
while (pathStartPattern.exec(text) !== null) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
function hasSpacedPathExtension(text) {
|
||||
var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length });
|
||||
if (!range) return false;
|
||||
var trimmed = range.text.replace(/\s+$/, '');
|
||||
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed);
|
||||
}
|
||||
|
||||
function matchSpacedFilePathAtColumn(lineText, col) {
|
||||
SPACED_PATH_RE.lastIndex = 0;
|
||||
var match;
|
||||
while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
|
||||
var trimmed = trimPathBoundaryPunctuation(match[0], match.index);
|
||||
if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
|
||||
var candidate = trimSpacedPathTrailingProse(trimmed, col);
|
||||
if (!candidate) continue;
|
||||
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
|
||||
var parsed = parsePathLineCol(candidate.text);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchFilePathAtColumn(lineText, col) {
|
||||
var spaced = matchSpacedFilePathAtColumn(lineText, col);
|
||||
if (spaced) return spaced;
|
||||
FILE_PATH_RE.lastIndex = 0;
|
||||
var match;
|
||||
while ((match = FILE_PATH_RE.exec(lineText)) !== null) {
|
||||
var raw = match[0];
|
||||
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
|
||||
var start = 0, end = raw.length;
|
||||
while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
|
||||
while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
|
||||
if (start >= end) continue;
|
||||
var spanStart = match.index + start;
|
||||
var spanEnd = match.index + end;
|
||||
if (col < spanStart || col > spanEnd) continue;
|
||||
var parsed = parsePathLineCol(raw.slice(start, end));
|
||||
if (parsed) return parsed;
|
||||
var trimmed = trimPathBoundaryPunctuation(raw, match.index);
|
||||
if (!trimmed) continue;
|
||||
if (col < trimmed.startIndex || col >= trimmed.endIndex) continue;
|
||||
var parsed = parsePathLineCol(trimmed.text);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Script, createContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES,
|
||||
columnForTerminalFileLinkTap
|
||||
} from '../../../src/shared/terminal-file-link-conformance'
|
||||
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap'
|
||||
|
||||
type InjectedPathMatcher = typeof matchFilePathAtColumn
|
||||
|
||||
// Returns the column of the first occurrence of `needle` in `line` (+offset).
|
||||
function colOf(line: string, needle: string, offset = 0): number {
|
||||
return line.indexOf(needle) + offset
|
||||
@@ -32,6 +40,15 @@ describe('parsePathWithOptionalLineColumn', () => {
|
||||
})
|
||||
|
||||
describe('matchFilePathAtColumn', () => {
|
||||
it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)(
|
||||
'matches shared terminal file-link tap case: $name',
|
||||
(testCase) => {
|
||||
expect(
|
||||
matchFilePathAtColumn(testCase.lineText, columnForTerminalFileLinkTap(testCase))
|
||||
).toEqual(testCase.expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('matches an absolute path under the tap', () => {
|
||||
const line = 'created /tmp/out/report.html for you'
|
||||
const result = matchFilePathAtColumn(line, colOf(line, 'report'))
|
||||
@@ -50,12 +67,68 @@ describe('matchFilePathAtColumn', () => {
|
||||
expect(result?.pathText).toBe('~/Documents/notes.md')
|
||||
})
|
||||
|
||||
it('yields the tight whitespace-bounded segment under the tap', () => {
|
||||
// On a path whose dir name has a space, tapping the file segment yields the
|
||||
// openable sub-path after the space (still resolves against the worktree).
|
||||
it('matches a path whose directory name contains a space', () => {
|
||||
const line = '/Users/me/My Project/readme.md done'
|
||||
const result = matchFilePathAtColumn(line, colOf(line, 'readme'))
|
||||
expect(result?.pathText).toBe('Project/readme.md')
|
||||
expect(result?.pathText).toBe('/Users/me/My Project/readme.md')
|
||||
})
|
||||
|
||||
it('matches a spaced path with line and column suffixes', () => {
|
||||
const line = 'wrote /tmp/orca report/result.json:12:3 for you'
|
||||
const result = matchFilePathAtColumn(line, colOf(line, 'result'))
|
||||
expect(result).toEqual({
|
||||
pathText: '/tmp/orca report/result.json',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a spaced path when an earlier directory segment contains a dot', () => {
|
||||
const line = 'wrote /tmp/v1.2 reports/result.json for you'
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'v1.2'))).toEqual({
|
||||
pathText: '/tmp/v1.2 reports/result.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'reports'))).toEqual({
|
||||
pathText: '/tmp/v1.2 reports/result.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'result'))).toEqual({
|
||||
pathText: '/tmp/v1.2 reports/result.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a path whose final filename contains a space', () => {
|
||||
const line = 'wrote /tmp/final report.json for you'
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'final'))).toEqual({
|
||||
pathText: '/tmp/final report.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'report'))).toEqual({
|
||||
pathText: '/tmp/final report.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('does not merge two spaced path candidates through prose', () => {
|
||||
const line = 'see /tmp/a.txt and /tmp/b.txt done'
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'a.txt'))).toEqual({
|
||||
pathText: '/tmp/a.txt',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'b.txt'))).toEqual({
|
||||
pathText: '/tmp/b.txt',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('trims surrounding punctuation', () => {
|
||||
@@ -74,6 +147,11 @@ describe('matchFilePathAtColumn', () => {
|
||||
expect(matchFilePathAtColumn(line, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the tap is immediately after the path span', () => {
|
||||
const line = 'prefix /tmp/x.ts next'
|
||||
expect(matchFilePathAtColumn(line, line.indexOf(' next'))).toBeNull()
|
||||
})
|
||||
|
||||
it('matches a bare filename with an extension (no slash)', () => {
|
||||
// Why: agents commonly print a bare filename (e.g. a markdown link whose
|
||||
// target was consumed). The host existence-check rejects non-files.
|
||||
@@ -87,3 +165,24 @@ describe('matchFilePathAtColumn', () => {
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('injected matchFilePathAtColumn', () => {
|
||||
const matcher = createInjectedPathMatcher()
|
||||
|
||||
it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)(
|
||||
'matches shared terminal file-link tap case: $name',
|
||||
(testCase) => {
|
||||
expect(matcher(testCase.lineText, columnForTerminalFileLinkTap(testCase))).toEqual(
|
||||
testCase.expected
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function createInjectedPathMatcher(): InjectedPathMatcher {
|
||||
const context = createContext({})
|
||||
new Script(
|
||||
`${TERMINAL_PATH_TAP_JS}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;`
|
||||
).runInContext(context)
|
||||
return (context as { __matchFilePathAtColumn: InjectedPathMatcher }).__matchFilePathAtColumn
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ export type TappedFilePath = {
|
||||
// a bare filename with an extension (README.md, index.ts), optionally suffixed
|
||||
// with :line or :line:col. Like desktop, we propose candidates and let the host
|
||||
// existence-check reject non-files — agents often print a bare filename, so
|
||||
// requiring a slash would miss the common case. The desktop's spaced-path
|
||||
// variants are intentionally not ported: a tap always lands inside one
|
||||
// whitespace-bounded segment, so this already covers the real cases.
|
||||
// requiring a slash would miss the common case.
|
||||
const LOCAL_PATH_REGEX =
|
||||
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g
|
||||
const SPACED_PATH_REGEX =
|
||||
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
|
||||
|
||||
const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
|
||||
const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
|
||||
@@ -64,9 +64,116 @@ export function parsePathWithOptionalLineColumn(value: string): TappedFilePath |
|
||||
return { pathText, line, column }
|
||||
}
|
||||
|
||||
function hasSeparatorAfterWhitespace(text: string): boolean {
|
||||
let sawWhitespace = false
|
||||
for (const char of text) {
|
||||
if (/\s/.test(char)) {
|
||||
sawWhitespace = true
|
||||
continue
|
||||
}
|
||||
if (sawWhitespace && (char === '/' || char === '\\')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function trimSpacedPathTrailingProse(
|
||||
range: Span & { text: string },
|
||||
col?: number
|
||||
): (Span & { text: string }) | null {
|
||||
// Why: a line-end extension token only extends the span when the added
|
||||
// segment is path-like (contains a separator) — "v1.2 reports/result.json"
|
||||
// extends, prose like "failed to start app.py" must not be swallowed.
|
||||
let selected: string | null = null
|
||||
const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
|
||||
const end = match.index + match[0].length
|
||||
const text = range.text.slice(0, end)
|
||||
if (countPathStarts(text) > 1) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
end < range.text.length ||
|
||||
selected === null ||
|
||||
/[\\/]/.test(range.text.slice(selected.length, end))
|
||||
) {
|
||||
selected = text
|
||||
}
|
||||
}
|
||||
if (!selected) {
|
||||
const text = range.text.trimEnd()
|
||||
return {
|
||||
...range,
|
||||
text,
|
||||
endIndex: range.startIndex + text.length
|
||||
}
|
||||
}
|
||||
if (col !== undefined && col >= range.startIndex + selected.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text: selected,
|
||||
startIndex: range.startIndex,
|
||||
endIndex: range.startIndex + selected.length
|
||||
}
|
||||
}
|
||||
|
||||
function countPathStarts(text: string): number {
|
||||
let count = 0
|
||||
for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
|
||||
void match
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function hasSpacedPathExtension(text: string): boolean {
|
||||
const trimmed = trimSpacedPathTrailingProse({
|
||||
text,
|
||||
startIndex: 0,
|
||||
endIndex: text.length
|
||||
})?.text.trimEnd()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed)
|
||||
}
|
||||
|
||||
function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
|
||||
SPACED_PATH_REGEX.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = SPACED_PATH_REGEX.exec(lineText)) !== null) {
|
||||
const trimmed = trimBoundaryPunctuation(match[0], match.index)
|
||||
if (
|
||||
!trimmed ||
|
||||
(!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const candidate = trimSpacedPathTrailingProse(trimmed, col)
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
if (col < candidate.startIndex || col >= candidate.endIndex) {
|
||||
continue
|
||||
}
|
||||
const parsed = parsePathWithOptionalLineColumn(candidate.text)
|
||||
if (parsed) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Returns the file-path span (after punctuation trim) that contains `col`, or
|
||||
// null when the tap isn't on a path.
|
||||
export function matchFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
|
||||
const spaced = matchSpacedFilePathAtColumn(lineText, col)
|
||||
if (spaced) {
|
||||
return spaced
|
||||
}
|
||||
LOCAL_PATH_REGEX.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = LOCAL_PATH_REGEX.exec(lineText)) !== null) {
|
||||
@@ -78,8 +185,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
// Inclusive of the trailing edge so a tap on the last glyph still counts.
|
||||
if (col < trimmed.startIndex || col > trimmed.endIndex) {
|
||||
if (col < trimmed.startIndex || col >= trimmed.endIndex) {
|
||||
continue
|
||||
}
|
||||
const parsed = parsePathWithOptionalLineColumn(trimmed.text)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
|
||||
|
||||
const runtimeMeta = { runtimeId: 'test-runtime' } as const
|
||||
|
||||
describe('terminal send RPC response', () => {
|
||||
it('Given accepted terminal send response When checked Then reports success', () => {
|
||||
// Given
|
||||
const response: RpcResponse = {
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } },
|
||||
_meta: runtimeMeta
|
||||
}
|
||||
|
||||
// When / Then
|
||||
expect(isTerminalSendRpcAccepted(response)).toBe(true)
|
||||
})
|
||||
|
||||
it('Given rejected terminal send response When checked Then reports failure', () => {
|
||||
// Given
|
||||
const response: RpcResponse = {
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } },
|
||||
_meta: runtimeMeta
|
||||
}
|
||||
|
||||
// When / Then
|
||||
expect(isTerminalSendRpcAccepted(response)).toBe(false)
|
||||
})
|
||||
|
||||
it('Given RPC failure or malformed terminal send response When checked Then reports failure', () => {
|
||||
// Given
|
||||
const rpcFailure: RpcResponse = {
|
||||
id: '1',
|
||||
ok: false,
|
||||
error: { code: 'terminal_error', message: 'failed' },
|
||||
_meta: runtimeMeta
|
||||
}
|
||||
const malformedSuccess: RpcResponse = {
|
||||
id: '2',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: runtimeMeta
|
||||
}
|
||||
|
||||
// When / Then
|
||||
expect(isTerminalSendRpcAccepted(rpcFailure)).toBe(false)
|
||||
expect(isTerminalSendRpcAccepted(malformedSuccess)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export function isTerminalSendRpcAccepted(response: RpcResponse): boolean {
|
||||
if (!response.ok) {
|
||||
return false
|
||||
}
|
||||
if (!isRecord(response.result) || !isRecord(response.result.send)) {
|
||||
return false
|
||||
}
|
||||
return response.result.send.accepted === true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment happy-dom
|
||||
// Exercises the in-WebView touch dispatcher end-to-end: a surface tap on a
|
||||
// printed http(s) URL must post an `open-url` message (which RN routes to the
|
||||
// in-app/phone browser). Regression guard for taps that jitter a few pixels —
|
||||
@@ -174,6 +174,39 @@ describe('terminal WebView tap routing', () => {
|
||||
expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/issue/1234')
|
||||
})
|
||||
|
||||
it('opens first-load file OSC links through terminal file taps', async () => {
|
||||
const oscLinks = [{ row: 0, startCol: 5, endCol: 15, uri: 'file:///tmp/result.json#L12C3' }]
|
||||
const { posted } = boot('open artifact here', oscLinks)
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
|
||||
expect(posted.find((m) => m.type === 'terminal-file-tap')).toMatchObject({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: '/tmp/result.json',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('opens plain file URLs through terminal file taps', async () => {
|
||||
const line = 'open file:///tmp/result.json#L12C3 now'
|
||||
const { posted } = boot(line)
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('result')), y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(posted.find((m) => m.type === 'terminal-file-tap')).toMatchObject({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: '/tmp/result.json',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('does not open snapshot OSC links from adjacent terminal cells', async () => {
|
||||
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
|
||||
const { posted } = boot('issue #1234 done', oscLinks)
|
||||
@@ -185,6 +218,36 @@ describe('terminal WebView tap routing', () => {
|
||||
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not parse off-cell snapshot file OSC links while routing a tap', async () => {
|
||||
const oscLinks = Array.from({ length: 50 }, (_, index) => ({
|
||||
row: index + 1,
|
||||
startCol: 0,
|
||||
endCol: 10,
|
||||
uri: `file:///tmp/result-${index}.json#L1`
|
||||
}))
|
||||
const { posted } = boot('plain text', oscLinks)
|
||||
await settle()
|
||||
|
||||
const OriginalURL = window.URL
|
||||
let parseCount = 0
|
||||
const CountingURL = class extends OriginalURL {
|
||||
constructor(url: string | URL, base?: string | URL) {
|
||||
parseCount += 1
|
||||
super(url, base)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'URL', { value: CountingURL, configurable: true })
|
||||
try {
|
||||
fireTouch('touchstart', [{ x: screenXForCol(5), y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
} finally {
|
||||
Object.defineProperty(window, 'URL', { value: OriginalURL, configurable: true })
|
||||
}
|
||||
|
||||
expect(parseCount).toBe(0)
|
||||
expect(posted.find((m) => m.type === 'terminal-file-tap')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not open stale snapshot OSC links after the row text changes', async () => {
|
||||
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
|
||||
const { posted, setLine } = boot('issue #1234 done', oscLinks)
|
||||
|
||||
@@ -1,11 +1,140 @@
|
||||
import { createContext, Script } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TappedFilePath } from './terminal-path-tap'
|
||||
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
import {
|
||||
TERMINAL_HTTP_URL_MAX_LENGTH,
|
||||
TERMINAL_HTTP_URL_REGEX_SOURCE,
|
||||
findUrlAtColumn
|
||||
URL_TAP_WEBVIEW_JS,
|
||||
findFileUrlAtColumn,
|
||||
findUrlAtColumn,
|
||||
resolveTerminalOscFileTap,
|
||||
resolveTerminalFileUrlTap
|
||||
} from './terminal-webview-url-tap'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
type FileTapResolverCase = {
|
||||
name: string
|
||||
uri: string
|
||||
expected: TappedFilePath | null
|
||||
}
|
||||
|
||||
const FILE_URL_TAP_CASES: FileTapResolverCase[] = [
|
||||
{
|
||||
name: 'plain file URL',
|
||||
uri: 'file:///tmp/result.json',
|
||||
expected: { pathText: '/tmp/result.json', line: null, column: null }
|
||||
},
|
||||
{
|
||||
name: 'hash line and column',
|
||||
uri: 'file:///tmp/result.json#L12C3',
|
||||
expected: { pathText: '/tmp/result.json', line: 12, column: 3 }
|
||||
},
|
||||
{
|
||||
name: 'trailing line and column suffix',
|
||||
uri: 'file:///tmp/result.json:8:2',
|
||||
expected: { pathText: '/tmp/result.json', line: 8, column: 2 }
|
||||
},
|
||||
{
|
||||
name: 'percent-encoded colon suffix stays in the path',
|
||||
uri: 'file:///tmp/report%3A8%3A2',
|
||||
expected: { pathText: '/tmp/report:8:2', line: null, column: null }
|
||||
},
|
||||
{
|
||||
name: 'Windows drive path with hash line',
|
||||
uri: 'file:///C:/repo/src/app.ts#L4',
|
||||
expected: { pathText: 'C:/repo/src/app.ts', line: 4, column: null }
|
||||
},
|
||||
{
|
||||
name: 'host-qualified POSIX authority is preserved',
|
||||
uri: 'file://remote-host/tmp/result.json#L12',
|
||||
expected: { pathText: '//remote-host/tmp/result.json', line: 12, column: null }
|
||||
},
|
||||
{
|
||||
name: 'bracketed IPv6 loopback is local',
|
||||
uri: 'file://[::1]/tmp/result.json#L12',
|
||||
expected: { pathText: '/tmp/result.json', line: 12, column: null }
|
||||
},
|
||||
{
|
||||
name: 'IPv4 loopback is local',
|
||||
uri: 'file://127.0.0.1/tmp/result.json#L12',
|
||||
expected: { pathText: '/tmp/result.json', line: 12, column: null }
|
||||
},
|
||||
{
|
||||
name: 'UNC authority is preserved',
|
||||
uri: 'file://server/share/repo/app.ts#L12',
|
||||
expected: { pathText: '//server/share/repo/app.ts', line: 12, column: null }
|
||||
},
|
||||
{
|
||||
name: 'non-file scheme is rejected',
|
||||
uri: 'https://example.com/result.json',
|
||||
expected: null
|
||||
}
|
||||
]
|
||||
|
||||
const OSC_FILE_TAP_CASES: FileTapResolverCase[] = [
|
||||
{
|
||||
name: 'relative path',
|
||||
uri: 'docs/README.md',
|
||||
expected: { pathText: 'docs/README.md', line: null, column: null }
|
||||
},
|
||||
{
|
||||
name: 'tilde path with line and column',
|
||||
uri: '~/notes.md:4:2',
|
||||
expected: { pathText: '~/notes.md', line: 4, column: 2 }
|
||||
},
|
||||
{
|
||||
name: 'mailto target is rejected',
|
||||
uri: 'mailto:team@example.com',
|
||||
expected: null
|
||||
}
|
||||
]
|
||||
|
||||
type InjectedFileTapResolver = (uri: string) => TappedFilePath | null
|
||||
|
||||
// Why: the WebView blob hand-translates terminal-file-url-tap.ts into plain JS
|
||||
// with re-escaped regexes; executing it against the same cases as the TS module
|
||||
// keeps the two copies from drifting (mirrors createInjectedPathMatcher).
|
||||
function createInjectedFileTapResolvers(): {
|
||||
resolveTerminalFileUrlTap: InjectedFileTapResolver
|
||||
resolveTerminalOscFileTap: InjectedFileTapResolver
|
||||
} {
|
||||
const context = createContext({ URL })
|
||||
new Script(
|
||||
`${TERMINAL_PATH_TAP_JS}\n${URL_TAP_WEBVIEW_JS}\n` +
|
||||
'this.__resolveTerminalFileUrlTap = resolveTerminalFileUrlTap;\n' +
|
||||
'this.__resolveTerminalOscFileTap = resolveTerminalOscFileTap;'
|
||||
).runInContext(context)
|
||||
const injected = context as {
|
||||
__resolveTerminalFileUrlTap: InjectedFileTapResolver
|
||||
__resolveTerminalOscFileTap: InjectedFileTapResolver
|
||||
}
|
||||
return {
|
||||
resolveTerminalFileUrlTap: injected.__resolveTerminalFileUrlTap,
|
||||
resolveTerminalOscFileTap: injected.__resolveTerminalOscFileTap
|
||||
}
|
||||
}
|
||||
|
||||
describe.each([
|
||||
['module', { resolveTerminalFileUrlTap, resolveTerminalOscFileTap }],
|
||||
['injected', createInjectedFileTapResolvers()]
|
||||
] as const)('terminal file tap resolvers (%s)', (_variant, resolvers) => {
|
||||
it.each(FILE_URL_TAP_CASES)('resolves file URL: $name', ({ uri, expected }) => {
|
||||
expect(resolvers.resolveTerminalFileUrlTap(uri)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(OSC_FILE_TAP_CASES)('resolves OSC target: $name', ({ uri, expected }) => {
|
||||
expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(FILE_URL_TAP_CASES)('OSC resolver accepts file URL: $name', ({ uri, expected }) => {
|
||||
if (expected === null) {
|
||||
return
|
||||
}
|
||||
expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findUrlAtColumn', () => {
|
||||
it('returns the URL when the tapped column falls inside it', () => {
|
||||
const line = 'see https://example.com/path for details'
|
||||
@@ -46,6 +175,13 @@ describe('findUrlAtColumn', () => {
|
||||
expect(findUrlAtColumn(line, line.indexOf('etc'))).toBeNull()
|
||||
})
|
||||
|
||||
it('finds file URLs separately so taps route to file opens', () => {
|
||||
const line = 'open file:///tmp/result.json#L12C3 please'
|
||||
|
||||
expect(findFileUrlAtColumn(line, line.indexOf('result'))).toBe('file:///tmp/result.json#L12C3')
|
||||
expect(findFileUrlAtColumn(line, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches desktop URL boundary and length guards', () => {
|
||||
expect(findUrlAtColumn('prefixhttps://example.com/path', 'prefix'.length)).toBeNull()
|
||||
expect(findUrlAtColumn('prefix https://example.com/path', 'prefix '.length)).toBe(
|
||||
@@ -58,9 +194,15 @@ describe('findUrlAtColumn', () => {
|
||||
|
||||
it('injects URL and OSC tap handling into the WebView document', () => {
|
||||
expect(XTERM_HTML).toContain('function findUrlAtColumn(')
|
||||
expect(XTERM_HTML).toContain('function findFileUrlAtColumn(')
|
||||
expect(XTERM_HTML).toContain('function fileUrlAtViewportPoint(')
|
||||
expect(XTERM_HTML).toContain('function urlAtViewportPoint(')
|
||||
expect(XTERM_HTML).toContain(JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE))
|
||||
expect(XTERM_HTML).toContain('function oscLinkAtViewportPoint(')
|
||||
expect(XTERM_HTML).toContain('function resolveTerminalOscFileTap(')
|
||||
expect(XTERM_HTML).toContain('function resolveTerminalFileUrlTap(')
|
||||
expect(XTERM_HTML).toContain('function isLocalFileUriHostname(')
|
||||
expect(XTERM_HTML).toContain('return parsePathLineCol(value);')
|
||||
expect(XTERM_HTML).toContain('function notifyTerminalSurfaceTap(')
|
||||
expect(XTERM_HTML).toContain("notify({ type: 'open-url', url: tappedUrl });")
|
||||
})
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
export { resolveTerminalFileUrlTap, resolveTerminalOscFileTap } from './terminal-file-url-tap'
|
||||
|
||||
export const TERMINAL_HTTP_URL_REGEX_SOURCE =
|
||||
String.raw`\bhttps?:\/\/[^\s"'!*(){}|\\^<>` +
|
||||
'`' +
|
||||
String.raw`]*[^\s"':,.!?{}|\\^~[\]` +
|
||||
'`' +
|
||||
String.raw`()<>]`
|
||||
export const TERMINAL_FILE_URL_REGEX_SOURCE =
|
||||
String.raw`\bfile:\/\/[^\s"'!*(){}|\\^<>` +
|
||||
'`' +
|
||||
String.raw`]*[^\s"',!?{}|\\^~[\]` +
|
||||
'`' +
|
||||
String.raw`()<>]`
|
||||
export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048
|
||||
|
||||
export function findUrlAtColumn(lineText: string, col: number): string | null {
|
||||
return findTerminalUrlAtColumn(lineText, col, TERMINAL_HTTP_URL_REGEX_SOURCE)
|
||||
}
|
||||
|
||||
export function findFileUrlAtColumn(lineText: string, col: number): string | null {
|
||||
return findTerminalUrlAtColumn(lineText, col, TERMINAL_FILE_URL_REGEX_SOURCE)
|
||||
}
|
||||
|
||||
function findTerminalUrlAtColumn(lineText: string, col: number, source: string): string | null {
|
||||
if (typeof lineText !== 'string' || lineText.length === 0) {
|
||||
return null
|
||||
}
|
||||
const re = new RegExp(TERMINAL_HTTP_URL_REGEX_SOURCE, 'gi')
|
||||
const re = new RegExp(source, 'gi')
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = re.exec(lineText)) !== null) {
|
||||
const start = match.index
|
||||
@@ -30,10 +46,17 @@ export function findUrlAtColumn(lineText: string, col: number): string | null {
|
||||
|
||||
export const URL_TAP_WEBVIEW_JS = `
|
||||
var URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE)};
|
||||
var FILE_URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_FILE_URL_REGEX_SOURCE)};
|
||||
var URL_TAP_MAX_LENGTH = ${TERMINAL_HTTP_URL_MAX_LENGTH};
|
||||
function findUrlAtColumn(lineText, col) {
|
||||
return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE);
|
||||
}
|
||||
function findFileUrlAtColumn(lineText, col) {
|
||||
return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE);
|
||||
}
|
||||
function findTerminalUrlAtColumn(lineText, col, source) {
|
||||
if (typeof lineText !== 'string' || lineText.length === 0) return null;
|
||||
var re = new RegExp(URL_TAP_RE_SOURCE, 'gi');
|
||||
var re = new RegExp(source, 'gi');
|
||||
var match;
|
||||
while ((match = re.exec(lineText)) !== null) {
|
||||
var end = match.index + match[0].length;
|
||||
@@ -42,6 +65,11 @@ export const URL_TAP_WEBVIEW_JS = `
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function fileUrlAtViewportPoint(clientX, clientY) {
|
||||
var cell = viewportToCell(clientX, clientY);
|
||||
if (!cell) return null;
|
||||
return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
|
||||
}
|
||||
function urlAtViewportPoint(clientX, clientY) {
|
||||
var cell = viewportToCell(clientX, clientY);
|
||||
if (!cell) return null;
|
||||
@@ -73,19 +101,81 @@ export const URL_TAP_WEBVIEW_JS = `
|
||||
if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col);
|
||||
var data = svc.getLinkData(urlId);
|
||||
var uri = data && data.uri;
|
||||
return uri && /^https?:/i.test(uri) ? uri : null;
|
||||
return terminalOscLinkTarget(uri);
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function initialOscLinkAtCell(row, col) {
|
||||
for (var i = 0; i < initialOscLinks.length; i++) {
|
||||
var link = initialOscLinks[i];
|
||||
if (!link || typeof link.uri !== 'string' || !/^https?:/i.test(link.uri)) continue;
|
||||
if (!link || typeof link.uri !== 'string') continue;
|
||||
if (link.row < initialOscLinkRowOffset) continue;
|
||||
var shiftedRow = link.row - initialOscLinkRowOffset;
|
||||
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return link.uri;
|
||||
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function terminalOscLinkTarget(uri) {
|
||||
if (typeof uri !== 'string') return null;
|
||||
if (/^https?:/i.test(uri)) return { kind: 'url', url: uri };
|
||||
var fileTap = resolveTerminalOscFileTap(uri);
|
||||
return fileTap ? { kind: 'file', fileTap: fileTap } : null;
|
||||
}
|
||||
function resolveTerminalOscFileTap(uri) {
|
||||
return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri);
|
||||
}
|
||||
function resolveTerminalFileUrlTap(uri) {
|
||||
var parsed;
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== 'file:') return null;
|
||||
var filePath;
|
||||
try {
|
||||
filePath = decodeURIComponent(parsed.pathname || '');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) {
|
||||
filePath = '//' + parsed.hostname + filePath;
|
||||
} else if (/^\\/[A-Za-z]:\\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
if (!filePath) return null;
|
||||
var hashTarget = parseFileUrlLineHash(parsed.hash || '');
|
||||
if (hashTarget) {
|
||||
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column };
|
||||
}
|
||||
if (/%3a/i.test(parsed.pathname || '')) {
|
||||
return { pathText: filePath, line: null, column: null };
|
||||
}
|
||||
return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null };
|
||||
}
|
||||
function isLocalFileUriHostname(hostname) {
|
||||
var normalized = String(hostname).toLowerCase();
|
||||
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
|
||||
}
|
||||
function parseOscPathLikeTarget(value) {
|
||||
if (!/^(?:~[\\\\/]|[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|[A-Za-z0-9._-]+[\\\\/]|(?=[A-Za-z0-9._-]*\\.[A-Za-z0-9]))/.test(value)) return null;
|
||||
return parsePathLineCol(value);
|
||||
}
|
||||
function parseFileUrlLineHash(hash) {
|
||||
var match = /^#?L(\\d+)(?:C(\\d+))?$/i.exec(hash);
|
||||
if (!match) return null;
|
||||
var line = parseInt(match[1], 10);
|
||||
var column = match[2] ? parseInt(match[2], 10) : null;
|
||||
if (line < 1 || (column !== null && column < 1)) return null;
|
||||
return { line: line, column: column };
|
||||
}
|
||||
function parseFilePathTrailingLineTarget(filePath) {
|
||||
var match = /^(.*?)(?::(\\d+))(?::(\\d+))?$/.exec(filePath);
|
||||
if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\\\') return null;
|
||||
var line = parseInt(match[2], 10);
|
||||
var column = match[3] ? parseInt(match[3], 10) : null;
|
||||
if (line < 1 || (column !== null && column < 1)) return null;
|
||||
return { pathText: match[1], line: line, column: column };
|
||||
}
|
||||
function captureInitialOscLinkTexts() {
|
||||
if (!Array.isArray(initialOscLinks)) return;
|
||||
for (var i = 0; i < initialOscLinks.length; i++) {
|
||||
@@ -116,7 +206,28 @@ export const URL_TAP_WEBVIEW_JS = `
|
||||
}
|
||||
|
||||
function notifyTerminalSurfaceTap(originX, originY) {
|
||||
var tappedUrl = oscLinkAtViewportPoint(originX, originY) || urlAtViewportPoint(originX, originY);
|
||||
var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
|
||||
if (tappedOscLink && tappedOscLink.kind === 'file') {
|
||||
notify({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: tappedOscLink.fileTap.pathText,
|
||||
line: tappedOscLink.fileTap.line,
|
||||
column: tappedOscLink.fileTap.column
|
||||
});
|
||||
return;
|
||||
}
|
||||
var tappedFileUrl = fileUrlAtViewportPoint(originX, originY);
|
||||
var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null;
|
||||
if (tappedFileUrlPath) {
|
||||
notify({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: tappedFileUrlPath.pathText,
|
||||
line: tappedFileUrlPath.line,
|
||||
column: tappedFileUrlPath.column
|
||||
});
|
||||
return;
|
||||
}
|
||||
var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY);
|
||||
if (tappedUrl) {
|
||||
notify({ type: 'open-url', url: tappedUrl });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
|
||||
|
||||
type DeferredBoolean = {
|
||||
readonly promise: Promise<boolean>
|
||||
readonly resolve: (value: boolean) => void
|
||||
}
|
||||
|
||||
function createDeferredBoolean(): DeferredBoolean {
|
||||
let resolvePromise: (value: boolean) => void = () => {
|
||||
throw new Error('deferred promise was resolved before initialization')
|
||||
}
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
describe('terminal live accessory input commit', () => {
|
||||
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
|
||||
// Given
|
||||
const deferredFlush = createDeferredBoolean()
|
||||
let settled = false
|
||||
|
||||
// When
|
||||
const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
() => deferredFlush.promise
|
||||
)
|
||||
void resultPromise.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(settled).toBe(false)
|
||||
deferredFlush.resolve(true)
|
||||
await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' })
|
||||
})
|
||||
|
||||
it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => {
|
||||
// Given
|
||||
const waitForPendingLiveInputFlush = async (): Promise<boolean> => false
|
||||
|
||||
// When
|
||||
const result = await getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
waitForPendingLiveInputFlush
|
||||
)
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import {
|
||||
getTerminalLiveAccessoryBytesDecision,
|
||||
getTerminalLiveAccessoryLocalEditText,
|
||||
getTerminalLiveDeferredTextDelayMs
|
||||
} from './terminal-live-text-commit'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
|
||||
export type TerminalLiveAccessoryInputCommitResult =
|
||||
| { readonly kind: 'allow-raw' }
|
||||
| { readonly kind: 'handled' }
|
||||
| { readonly kind: 'suppress-raw' }
|
||||
|
||||
type TerminalLiveInputCommitScheduler = (
|
||||
handle: string,
|
||||
text: string,
|
||||
delayMs: number | null
|
||||
) => void
|
||||
|
||||
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
): Promise<TerminalLiveAccessoryInputCommitResult> {
|
||||
return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
|
||||
}
|
||||
|
||||
type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly pendingLiveInputTextRef: RefObject<string>
|
||||
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
}: TerminalLiveAccessoryInputCommitOptions): (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult> {
|
||||
return useCallback(
|
||||
async (input: TerminalLiveAccessoryInput): Promise<TerminalLiveAccessoryInputCommitResult> => {
|
||||
if (!activeHandle) {
|
||||
return { kind: 'allow-raw' }
|
||||
}
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
|
||||
switch (decision.kind) {
|
||||
case 'send-now':
|
||||
// Why: raw accessory bytes must wait behind any in-flight IME text
|
||||
// flush so composed Hangul reaches the PTY before follow-up controls.
|
||||
return (await waitForPendingLiveInputFlush())
|
||||
? { kind: 'allow-raw' }
|
||||
: { kind: 'suppress-raw' }
|
||||
case 'local-edit': {
|
||||
const editedText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: decision.localEdit,
|
||||
pendingText
|
||||
})
|
||||
if (editedText.length === 0) {
|
||||
clearPendingLiveInputCommit()
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
// Why: accessory buttons do not emit native TextInput edits, so the
|
||||
// pending IME buffer must be edited and rescheduled here.
|
||||
setLiveInputCapture(editedText)
|
||||
liveInputRef.current?.setNativeProps({ text: editedText })
|
||||
schedulePendingLiveInputCommit(
|
||||
activeHandle,
|
||||
editedText,
|
||||
getTerminalLiveDeferredTextDelayMs(editedText)
|
||||
)
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
case 'flush-then-send':
|
||||
await sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return { kind: 'handled' }
|
||||
default:
|
||||
decision satisfies never
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createElement, type RefObject } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
|
||||
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
|
||||
|
||||
type TerminalLiveInputCommitHarness = {
|
||||
readonly captures: readonly string[]
|
||||
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
|
||||
readonly sent: readonly string[]
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitHarnessOptions = {
|
||||
readonly sendResult?: boolean
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function createTerminalLiveInputCommitHarness({
|
||||
sendResult = true
|
||||
}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
|
||||
const activeHandle = 'terminal-a'
|
||||
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
|
||||
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
|
||||
const captures: string[] = []
|
||||
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
||||
const liveInputTerminalHandles = new Set([activeHandle])
|
||||
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
|
||||
current: new Set([activeHandle])
|
||||
}
|
||||
const sent: string[] = []
|
||||
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
|
||||
current: async (_handle, bytes) => {
|
||||
sent.push(bytes)
|
||||
return sendResult
|
||||
}
|
||||
}
|
||||
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(): null {
|
||||
handlers = useTerminalLiveInputCommit({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType: 'terminal',
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture: (text) => captures.push(text)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!handlers || !renderer) {
|
||||
throw new Error('terminal live input hook did not render')
|
||||
}
|
||||
|
||||
return {
|
||||
captures,
|
||||
handlers,
|
||||
sent,
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live input commit hook', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('ㅎ')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
// Then
|
||||
expect(captures).toEqual(['ㅎ'])
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
|
||||
|
||||
// Then
|
||||
expect(flushed).toBe(true)
|
||||
expect(sent).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
|
||||
|
||||
// Then
|
||||
expect(flushed).toBe(false)
|
||||
expect(sent).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('你好')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
|
||||
|
||||
// Then
|
||||
expect(captures).toEqual(['你好'])
|
||||
expect(sent).toEqual([])
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好']))
|
||||
handlers.handleLiveInputChange('tiếng Việt')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
|
||||
})
|
||||
|
||||
it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('é')
|
||||
|
||||
// When
|
||||
unmount()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
// Then
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import {
|
||||
getTerminalLiveSpecialKeyDecision,
|
||||
getTerminalLiveSubmitSequence,
|
||||
getTerminalLiveTextChangeDecision
|
||||
} from './terminal-live-text-commit'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
|
||||
import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
|
||||
import {
|
||||
useTerminalLiveAccessoryInputCommit,
|
||||
type TerminalLiveAccessoryInputCommitResult
|
||||
} from './use-terminal-live-accessory-input-commit'
|
||||
|
||||
type TerminalLiveInputKeyPressEvent = {
|
||||
readonly nativeEvent: {
|
||||
readonly key: string
|
||||
}
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitOptions<TTabType extends string> = {
|
||||
readonly activeHandle: string | null
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
readonly activeSessionTabType: TTabType | null | undefined
|
||||
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitHandlers = {
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
|
||||
readonly handleLiveInputAccessoryBytes: (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult>
|
||||
readonly handleLiveInputChange: (text: string) => void
|
||||
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
|
||||
readonly handleLiveInputSubmit: () => void
|
||||
}
|
||||
|
||||
export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
|
||||
const {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
waitForPendingLiveInputFlush
|
||||
} = useTerminalLivePendingInputFlush({
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const pendingHandle = pendingLiveInputHandleRef.current
|
||||
if (!pendingHandle) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!activeHandle ||
|
||||
pendingHandle !== activeHandle ||
|
||||
activeSessionTabType !== 'terminal' ||
|
||||
!liveInputTerminalHandles.has(activeHandle)
|
||||
) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
}, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles])
|
||||
|
||||
const flushPendingLiveInputBeforeExternalSend = useCallback(
|
||||
async (handle: string): Promise<boolean> => {
|
||||
const pendingHandle = pendingLiveInputHandleRef.current
|
||||
if (pendingHandle && pendingHandle !== handle) {
|
||||
clearPendingLiveInputCommit()
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
|
||||
return flushPendingLiveInputText(handle)
|
||||
}
|
||||
return waitForPendingLiveInputFlush()
|
||||
},
|
||||
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
|
||||
)
|
||||
|
||||
const handleLiveInputChange = useCallback(
|
||||
(text: string) => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
}
|
||||
const normalizedText = normalizeTerminalTextInput(text)
|
||||
const decision = getTerminalLiveTextChangeDecision(normalizedText)
|
||||
switch (decision.kind) {
|
||||
case 'ignore':
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
case 'send-now':
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, decision.text)
|
||||
)
|
||||
return
|
||||
case 'defer':
|
||||
// Why: React Native does not expose composition events here, so keep
|
||||
// probable IME text in the native field until the commit timer settles.
|
||||
setLiveInputCapture(decision.text)
|
||||
schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
|
||||
return
|
||||
default:
|
||||
decision satisfies never
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
liveInputTerminalHandles,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
|
||||
const handleLiveInputKeyPress = useCallback(
|
||||
(event: TerminalLiveInputKeyPressEvent) => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: event.nativeEvent.key,
|
||||
pendingText
|
||||
})
|
||||
switch (decision.kind) {
|
||||
case 'ignore':
|
||||
case 'local-edit':
|
||||
return
|
||||
case 'send-now':
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return
|
||||
case 'flush-then-send':
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return
|
||||
default:
|
||||
decision satisfies never
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputTerminalHandles,
|
||||
sendLiveTerminalInputRef,
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
|
||||
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
})
|
||||
|
||||
const handleLiveInputSubmit = useCallback(() => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
const sequence = getTerminalLiveSubmitSequence(pendingText)
|
||||
if (sequence.length === 2) {
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
|
||||
)
|
||||
return
|
||||
}
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
|
||||
)
|
||||
}, [
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputTerminalHandles,
|
||||
sendLiveTerminalInputRef,
|
||||
waitForPendingLiveInputFlush
|
||||
])
|
||||
|
||||
return {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
handleLiveInputAccessoryBytes,
|
||||
handleLiveInputChange,
|
||||
handleLiveInputKeyPress,
|
||||
handleLiveInputSubmit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import {
|
||||
queueTerminalLivePendingFlush,
|
||||
waitForTerminalLivePendingFlush
|
||||
} from './terminal-live-pending-flush-state'
|
||||
|
||||
type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
}
|
||||
|
||||
type TerminalLivePendingInputFlush = {
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly pendingLiveInputTextRef: RefObject<string>
|
||||
readonly schedulePendingLiveInputCommit: (
|
||||
handle: string,
|
||||
text: string,
|
||||
delayMs: number | null
|
||||
) => void
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
|
||||
const liveInputCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
|
||||
const pendingLiveInputTextRef = useRef('')
|
||||
const pendingLiveInputHandleRef = useRef<string | null>(null)
|
||||
|
||||
const clearPendingLiveInputCommit = useCallback(() => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
pendingLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
}, [liveInputRef, setLiveInputCapture])
|
||||
|
||||
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
|
||||
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
|
||||
}, [])
|
||||
|
||||
const flushPendingLiveInputText = useCallback(
|
||||
async (expectedHandle: string | null): Promise<boolean> => {
|
||||
const existingFlush = pendingLiveInputFlushRef.current
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
|
||||
const handle = pendingLiveInputHandleRef.current
|
||||
const text = pendingLiveInputTextRef.current
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputTextRef.current = ''
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
|
||||
if (!handle || text.length === 0) {
|
||||
return existingFlush ?? false
|
||||
}
|
||||
if (
|
||||
(expectedHandle !== null && handle !== expectedHandle) ||
|
||||
handle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal' ||
|
||||
!liveInputTerminalHandlesRef.current.has(handle)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
|
||||
sendLiveTerminalInputRef.current(handle, text)
|
||||
)
|
||||
},
|
||||
[
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
]
|
||||
)
|
||||
|
||||
const schedulePendingLiveInputCommit = useCallback(
|
||||
(handle: string, text: string, delayMs: number | null) => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
}
|
||||
pendingLiveInputHandleRef.current = handle
|
||||
pendingLiveInputTextRef.current = text
|
||||
if (delayMs === null) {
|
||||
liveInputCommitTimerRef.current = null
|
||||
return
|
||||
}
|
||||
liveInputCommitTimerRef.current = setTimeout(() => {
|
||||
liveInputCommitTimerRef.current = null
|
||||
void flushPendingLiveInputText(handle)
|
||||
}, delayMs)
|
||||
},
|
||||
[flushPendingLiveInputText]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputTextRef.current = ''
|
||||
pendingLiveInputFlushRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
waitForPendingLiveInputFlush
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { handleTerminalBinaryFrame } from './rpc-client-terminal-binary-frame'
|
||||
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
|
||||
|
||||
function encodeFrame(opcode: TerminalStreamOpcode, streamId: number, payload: unknown): Uint8Array {
|
||||
const body =
|
||||
typeof payload === 'string'
|
||||
? new TextEncoder().encode(payload)
|
||||
: new TextEncoder().encode(JSON.stringify(payload))
|
||||
return encodeTerminalStreamFrame({
|
||||
opcode,
|
||||
streamId,
|
||||
seq: 1,
|
||||
payload: body
|
||||
})
|
||||
}
|
||||
|
||||
describe('handleTerminalBinaryFrame', () => {
|
||||
it('routes terminal metadata frames to the stream listener', () => {
|
||||
const listener = vi.fn()
|
||||
const recordValidatedInboundTraffic = vi.fn()
|
||||
|
||||
handleTerminalBinaryFrame(
|
||||
encodeFrame(TerminalStreamOpcode.Metadata, 42, { cwd: '/repo/src' }),
|
||||
{
|
||||
terminalSnapshots: new Map(),
|
||||
getListener: (streamId) => (streamId === 42 ? listener : undefined),
|
||||
recordValidatedInboundTraffic
|
||||
}
|
||||
)
|
||||
|
||||
expect(recordValidatedInboundTraffic).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith({ type: 'metadata', streamId: 42, cwd: '/repo/src' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
decodeTerminalStreamText
|
||||
} from './terminal-stream-protocol'
|
||||
|
||||
export type TerminalSnapshotState = {
|
||||
streamId: number
|
||||
meta: Record<string, unknown>
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
type StreamingListener = (result: unknown) => void
|
||||
|
||||
type TerminalBinaryFrameOptions = {
|
||||
terminalSnapshots: Map<number, TerminalSnapshotState>
|
||||
getListener: (streamId: number) => StreamingListener | undefined
|
||||
recordValidatedInboundTraffic: () => void
|
||||
}
|
||||
|
||||
export function handleTerminalBinaryFrame(
|
||||
bytes: Uint8Array,
|
||||
options: TerminalBinaryFrameOptions
|
||||
): void {
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
if (!frame) {
|
||||
return
|
||||
}
|
||||
const listener = options.getListener(frame.streamId)
|
||||
if (!listener) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Output) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
listener({
|
||||
type: 'data',
|
||||
streamId: frame.streamId,
|
||||
chunk: decodeTerminalStreamText(frame.payload)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
options.recordValidatedInboundTraffic()
|
||||
options.terminalSnapshots.set(frame.streamId, {
|
||||
streamId: frame.streamId,
|
||||
meta,
|
||||
chunks: []
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
const snapshot = options.terminalSnapshots.get(frame.streamId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
snapshot.chunks.push(decodeTerminalStreamText(frame.payload))
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
const snapshot = options.terminalSnapshots.get(frame.streamId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
options.terminalSnapshots.delete(frame.streamId)
|
||||
const kind = snapshot.meta.kind === 'resized' ? 'resized' : 'scrollback'
|
||||
listener({
|
||||
...snapshot.meta,
|
||||
type: kind,
|
||||
streamId: frame.streamId,
|
||||
serialized: snapshot.chunks.join('')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Resized) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
options.recordValidatedInboundTraffic()
|
||||
listener({
|
||||
...meta,
|
||||
type: 'resized',
|
||||
streamId: frame.streamId
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Metadata) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
options.recordValidatedInboundTraffic()
|
||||
listener({
|
||||
...meta,
|
||||
type: 'metadata',
|
||||
streamId: frame.streamId
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Error) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
listener({
|
||||
type: 'error',
|
||||
streamId: frame.streamId,
|
||||
message: decodeTerminalStreamText(frame.payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connect } from './rpc-client'
|
||||
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
|
||||
|
||||
vi.mock('./e2ee', () => ({
|
||||
generateKeyPair: () => ({
|
||||
publicKey: new Uint8Array(32),
|
||||
secretKey: new Uint8Array(32)
|
||||
}),
|
||||
deriveSharedKey: () => new Uint8Array(32),
|
||||
publicKeyFromBase64: () => new Uint8Array(32),
|
||||
publicKeyToBase64: () => 'client-public-key',
|
||||
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
|
||||
decrypt: (raw: string) => (raw === 'undecryptable' ? null : raw.replace(/^encrypted:/, '')),
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSED = 3
|
||||
|
||||
readonly CONNECTING = MockWebSocket.CONNECTING
|
||||
readonly OPEN = MockWebSocket.OPEN
|
||||
readonly CLOSED = MockWebSocket.CLOSED
|
||||
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
sent: string[] = []
|
||||
close = vi.fn(() => {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
})
|
||||
|
||||
constructor(readonly endpoint: string) {
|
||||
mockSockets.push(this)
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.sent.push(payload)
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
receive(payload: unknown): void {
|
||||
this.onmessage?.({ data: payload })
|
||||
}
|
||||
}
|
||||
|
||||
const mockSockets: MockWebSocket[] = []
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
function authenticate(socket: MockWebSocket): void {
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
|
||||
}
|
||||
|
||||
function sentRequest(socket: MockWebSocket, method: string): { id: string } {
|
||||
for (const payload of socket.sent) {
|
||||
const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
|
||||
id: string
|
||||
method: string
|
||||
}
|
||||
if (decoded.method === method) {
|
||||
return { id: decoded.id }
|
||||
}
|
||||
}
|
||||
throw new Error(`Request not sent: ${method}`)
|
||||
}
|
||||
|
||||
function sentRequests(socket: MockWebSocket, method: string): Array<{ id: string }> {
|
||||
return socket.sent
|
||||
.map(
|
||||
(payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string }
|
||||
)
|
||||
.filter((request) => request.method === method)
|
||||
}
|
||||
|
||||
function encryptedStreamingReady(id: string, streamId: number): string {
|
||||
return `encrypted:${JSON.stringify({
|
||||
id,
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'subscribed', streamId }
|
||||
})}`
|
||||
}
|
||||
|
||||
function unauthorizedResponsePayload(id: string): string {
|
||||
return `encrypted:${JSON.stringify({
|
||||
id,
|
||||
ok: false,
|
||||
error: { code: 'unauthorized', message: 'Unauthorized' }
|
||||
})}`
|
||||
}
|
||||
|
||||
function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array {
|
||||
return encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId,
|
||||
seq: 1,
|
||||
payload: new TextEncoder().encode(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
describe('rpc-client terminal reconnect streams', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockSockets.length = 0
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('re-subscribes active streams after auth retry without routing stale stream ids', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const first = mockSockets[0]!
|
||||
const terminalEvents: unknown[] = []
|
||||
authenticate(first)
|
||||
|
||||
client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => {
|
||||
terminalEvents.push(event)
|
||||
})
|
||||
const initialSubscribe = sentRequest(first, 'terminal.subscribe')
|
||||
first.receive(encryptedStreamingReady(initialSubscribe.id, 76))
|
||||
|
||||
const request = client.sendRequest('status.get').catch(() => undefined)
|
||||
await Promise.resolve()
|
||||
first.receive(unauthorizedResponsePayload(sentRequest(first, 'status.get').id))
|
||||
await request
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const second = mockSockets.at(-1)!
|
||||
authenticate(second)
|
||||
expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
|
||||
const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
|
||||
second.receive(encryptedStreamingReady(resumedSubscribe.id, 77))
|
||||
second.receive(encodeTerminalOutput(77, 'after-reconnect'))
|
||||
second.receive(encodeTerminalOutput(76, 'stale-before-reconnect'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(terminalEvents).toContainEqual({
|
||||
type: 'data',
|
||||
streamId: 77,
|
||||
chunk: 'after-reconnect'
|
||||
})
|
||||
expect(terminalEvents).not.toContainEqual({
|
||||
type: 'data',
|
||||
streamId: 76,
|
||||
chunk: 'stale-before-reconnect'
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
@@ -906,57 +906,6 @@ describe('mobile rpc-client connection timeout', () => {
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('re-subscribes active streams after an auth-retry reconnect', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const first = mockSockets[0]!
|
||||
const terminalEvents: unknown[] = []
|
||||
authenticate(first)
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
// An active terminal subscription on the live connection.
|
||||
client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => {
|
||||
terminalEvents.push(event)
|
||||
})
|
||||
expect(sentRequests(first, 'terminal.subscribe')).toHaveLength(1)
|
||||
|
||||
// Mid-session transient unauthorized — handleAuthRejection retries
|
||||
// (budget not exhausted) and reconnects rather than latching.
|
||||
const request = client.sendRequest('status.get').catch(() => undefined)
|
||||
await Promise.resolve()
|
||||
const id = sentRequest(first, 'status.get').id
|
||||
first.receive(unauthorizedResponsePayload(id))
|
||||
await request
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
|
||||
// Fresh socket authenticates; the replay loop must re-send the still
|
||||
// active terminal subscription (issue #5200 frozen-terminal regression).
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const second = mockSockets[mockSockets.length - 1]!
|
||||
expect(second).not.toBe(first)
|
||||
authenticate(second)
|
||||
expect(client.getState()).toBe('connected')
|
||||
expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
|
||||
const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
|
||||
second.receive(
|
||||
`encrypted:${JSON.stringify({
|
||||
id: resumedSubscribe.id,
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'subscribed', streamId: 77 }
|
||||
})}`
|
||||
)
|
||||
second.receive(encodeTerminalOutput(77, 'after-reconnect'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(terminalEvents).toContainEqual({
|
||||
type: 'data',
|
||||
streamId: 77,
|
||||
chunk: 'after-reconnect'
|
||||
})
|
||||
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects requests waiting for reconnect after the retry cap', async () => {
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
decryptBytes
|
||||
} from './e2ee'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
decodeTerminalStreamText
|
||||
} from './terminal-stream-protocol'
|
||||
handleTerminalBinaryFrame,
|
||||
type TerminalSnapshotState
|
||||
} from './rpc-client-terminal-binary-frame'
|
||||
import {
|
||||
decodeBrowserScreencastFrame,
|
||||
type BrowserScreencastFrame
|
||||
@@ -63,12 +61,6 @@ type StreamRequest = {
|
||||
sent?: boolean
|
||||
}
|
||||
|
||||
type TerminalSnapshotState = {
|
||||
streamId: number
|
||||
meta: Record<string, unknown>
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
export type RpcClient = {
|
||||
sendRequest: (
|
||||
method: string,
|
||||
@@ -490,6 +482,7 @@ export function connect(
|
||||
pendingBrowserScreencastRequestId = id
|
||||
activeBrowserScreencastRequestId = null
|
||||
}
|
||||
resetTerminalStreamRoutingForRequest(id)
|
||||
if (
|
||||
sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
|
||||
) {
|
||||
@@ -708,7 +701,7 @@ export function connect(
|
||||
sharedKey = null
|
||||
activeBrowserScreencastRequestId = null
|
||||
pendingBrowserScreencastRequestId = null
|
||||
streamListeners.forEach((stream) => (stream.sent = false))
|
||||
markStreamsForReplay()
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
@@ -759,7 +752,7 @@ export function connect(
|
||||
ws = null
|
||||
sharedKey = null
|
||||
// Why: close cleanup stale-bails here, so mark active streams for replay.
|
||||
streamListeners.forEach((stream) => (stream.sent = false))
|
||||
markStreamsForReplay()
|
||||
rejectAllPending(reason)
|
||||
if (closing) {
|
||||
closing.close()
|
||||
@@ -900,6 +893,25 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
function markStreamsForReplay(): void {
|
||||
for (const [id, stream] of streamListeners) {
|
||||
stream.sent = false
|
||||
resetTerminalStreamRoutingForRequest(id)
|
||||
}
|
||||
}
|
||||
|
||||
function resetTerminalStreamRoutingForRequest(id: string): void {
|
||||
const terminalStreamIds = terminalStreamIdsByRequest.get(id)
|
||||
if (!terminalStreamIds) {
|
||||
return
|
||||
}
|
||||
for (const streamId of terminalStreamIds) {
|
||||
terminalStreamListeners.delete(streamId)
|
||||
terminalSnapshots.delete(streamId)
|
||||
}
|
||||
terminalStreamIdsByRequest.delete(id)
|
||||
}
|
||||
|
||||
function emitStreamError(stream: StreamRequest, message: string, error?: unknown): void {
|
||||
if (stream.cancelled) {
|
||||
return
|
||||
@@ -942,7 +954,11 @@ export function connect(
|
||||
handleBrowserBinaryFrame(browserFrame)
|
||||
return
|
||||
}
|
||||
handleTerminalBinaryFrame(bytes)
|
||||
handleTerminalBinaryFrame(bytes, {
|
||||
terminalSnapshots,
|
||||
getListener: (streamId) => terminalStreamListeners.get(streamId),
|
||||
recordValidatedInboundTraffic
|
||||
})
|
||||
}
|
||||
|
||||
function handleBrowserBinaryFrame(frame: BrowserScreencastFrame) {
|
||||
@@ -956,82 +972,6 @@ export function connect(
|
||||
stream.onBinaryFrame?.(frame)
|
||||
}
|
||||
|
||||
function handleTerminalBinaryFrame(bytes: Uint8Array): void {
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
if (!frame) {
|
||||
return
|
||||
}
|
||||
const listener = terminalStreamListeners.get(frame.streamId)
|
||||
if (!listener) {
|
||||
recordValidatedInboundTraffic()
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Output) {
|
||||
recordValidatedInboundTraffic()
|
||||
listener({
|
||||
type: 'data',
|
||||
streamId: frame.streamId,
|
||||
chunk: decodeTerminalStreamText(frame.payload)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
recordValidatedInboundTraffic()
|
||||
terminalSnapshots.set(frame.streamId, { streamId: frame.streamId, meta, chunks: [] })
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
|
||||
recordValidatedInboundTraffic()
|
||||
const snapshot = terminalSnapshots.get(frame.streamId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
snapshot.chunks.push(decodeTerminalStreamText(frame.payload))
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
|
||||
recordValidatedInboundTraffic()
|
||||
const snapshot = terminalSnapshots.get(frame.streamId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
terminalSnapshots.delete(frame.streamId)
|
||||
const kind = snapshot.meta.kind === 'resized' ? 'resized' : 'scrollback'
|
||||
listener({
|
||||
...snapshot.meta,
|
||||
type: kind,
|
||||
streamId: frame.streamId,
|
||||
serialized: snapshot.chunks.join('')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Resized) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
recordValidatedInboundTraffic()
|
||||
listener({
|
||||
...meta,
|
||||
type: 'resized',
|
||||
streamId: frame.streamId
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Error) {
|
||||
recordValidatedInboundTraffic()
|
||||
listener({
|
||||
type: 'error',
|
||||
streamId: frame.streamId,
|
||||
message: decodeTerminalStreamText(frame.payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function sendEncrypted(request: unknown): boolean {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
|
||||
ws.send(encrypt(JSON.stringify(request), sharedKey))
|
||||
|
||||
@@ -8,7 +8,8 @@ export enum TerminalStreamOpcode {
|
||||
SnapshotChunk = 3,
|
||||
SnapshotEnd = 4,
|
||||
Resized = 5,
|
||||
Error = 6
|
||||
Error = 6,
|
||||
Metadata = 12
|
||||
}
|
||||
|
||||
export type TerminalStreamFrame = {
|
||||
@@ -74,6 +75,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||
value === TerminalStreamOpcode.SnapshotChunk ||
|
||||
value === TerminalStreamOpcode.SnapshotEnd ||
|
||||
value === TerminalStreamOpcode.Resized ||
|
||||
value === TerminalStreamOpcode.Error
|
||||
value === TerminalStreamOpcode.Error ||
|
||||
value === TerminalStreamOpcode.Metadata
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,64 @@ Goal: (1) significantly improve Windows startup time (~1 min cold start reported
|
||||
(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance.
|
||||
All changes must be proven with before/after benchmark numbers.
|
||||
|
||||
## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency
|
||||
|
||||
Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes.
|
||||
Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase
|
||||
timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture).
|
||||
Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines
|
||||
(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn).
|
||||
|
||||
Findings (baseline, this machine):
|
||||
- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it —
|
||||
~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts
|
||||
admitted this). Premise (16-context budget) stale since #7064 raised budget to 128.
|
||||
- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon
|
||||
provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new
|
||||
pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first
|
||||
ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal.
|
||||
- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs
|
||||
scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear +
|
||||
refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts
|
||||
recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers
|
||||
risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) —
|
||||
left as follow-up.
|
||||
- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system
|
||||
ConPTY corruption + perf differences on degraded-mode/fresh-local spawns).
|
||||
|
||||
Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis):
|
||||
- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping
|
||||
were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the
|
||||
previous dispose-on-hide behavior.
|
||||
- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon.
|
||||
- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the
|
||||
first user terminal doesn't pay the ~2.7s first-ConPTY cost.
|
||||
|
||||
Follow-ups (documented, not in this branch):
|
||||
- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual
|
||||
tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback
|
||||
when column count changed while hidden.
|
||||
- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons
|
||||
+ synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF,
|
||||
pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes.
|
||||
- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is
|
||||
Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty
|
||||
→ rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at
|
||||
that renderer connect layer, not in reconnectPersistedTerminals.
|
||||
- First terminal opened immediately after launch also waits on the one-time daemon-init
|
||||
barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured
|
||||
preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays
|
||||
it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain
|
||||
existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it
|
||||
does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize
|
||||
on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing
|
||||
behind the in-flight warm-up, not unwarmed cost.
|
||||
- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn
|
||||
storms serializing the daemon loop.
|
||||
|
||||
Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim
|
||||
PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts).
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.117-rc.3",
|
||||
"version": "1.4.121-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -73,6 +73,24 @@ describe('orca emulator CLI handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a wider client timeout for emulator attach recovery', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_attach', {
|
||||
attached: true,
|
||||
info: { deviceUdid: 'device-1', streamUrl: 'http://127.0.0.1:3102/stream.mjpeg' }
|
||||
})
|
||||
)
|
||||
|
||||
await main(['emulator', 'attach', 'device-1', '--worktree', 'all'], '/repo/project')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'emulator.attach',
|
||||
{ device: 'device-1', worktree: undefined, focus: false },
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects relative APK paths for remote runtimes', async () => {
|
||||
remoteMock.mockReturnValue(true)
|
||||
|
||||
|
||||
@@ -128,7 +128,13 @@ export const EMULATOR_HANDLERS: Record<string, CommandHandler> = {
|
||||
const target = await getEmulatorCommandTarget(flags, cwd, client)
|
||||
const device = getOptionalStringFlag(flags, 'device')
|
||||
const focus = flags.get('focus') === true
|
||||
const res = await client.call('emulator.attach', { device, worktree: target.worktree, focus })
|
||||
// Why: attach may cold-boot or recycle a wedged simulator (shutdown + boot +
|
||||
// helper restart), which can legitimately exceed the 60s default budget.
|
||||
const res = await client.call(
|
||||
'emulator.attach',
|
||||
{ device, worktree: target.worktree, focus },
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
printResult(res, json, (r: unknown) => {
|
||||
const result = r as EmulatorAttachResult
|
||||
const info = result.info ?? result
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
hookDefinitionHasManagedCommand,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeManagedScript,
|
||||
writeHooksJson,
|
||||
@@ -339,19 +341,19 @@ describe('wrapPosixHookCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
const qualifiedWindowsPowerShellCommand =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
|
||||
function decodeWindowsHookCommand(command: string): string {
|
||||
const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
|
||||
expect(encodedCommand).toBeTruthy()
|
||||
return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
describe('wrapWindowsHookCommand', () => {
|
||||
const qualifiedPowerShellCommand =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
|
||||
function decodeWindowsHookCommand(command: string): string {
|
||||
const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
|
||||
expect(encodedCommand).toBeTruthy()
|
||||
return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
it('invokes the .cmd through an encoded PowerShell command', () => {
|
||||
const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
expect(command).toMatch(qualifiedPowerShellCommand)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(command).not.toMatch(/^powershell\b/i)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(
|
||||
"& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
|
||||
@@ -363,7 +365,7 @@ describe('wrapWindowsHookCommand', () => {
|
||||
// the whole path inside the encoded command so shells do not split it.
|
||||
it('preserves spaces in the script path (user profile with space case)', () => {
|
||||
const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
expect(cmd).toMatch(qualifiedPowerShellCommand)
|
||||
expect(cmd).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(decodeWindowsHookCommand(cmd)).toBe(
|
||||
"& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
|
||||
)
|
||||
@@ -395,6 +397,43 @@ describe('wrapWindowsHookCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('wrapWindowsCmdHookCommand', () => {
|
||||
it('returns a bare .cmd path when cmd.exe can invoke it safely', () => {
|
||||
const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'
|
||||
expect(wrapWindowsCmdHookCommand(scriptPath)).toBe(scriptPath)
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when cmd.exe would split or expand the path', () => {
|
||||
const scriptPath = 'C:\\Users\\Jane Doe\\%ORCA_TEST%\\codex-hook.cmd'
|
||||
const command = wrapWindowsCmdHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapWindowsGitBashHookCommand', () => {
|
||||
it('returns a forward-slash .cmd path when Git Bash can execute it safely', () => {
|
||||
expect(
|
||||
wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd')
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when bash would split the path', () => {
|
||||
const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const command = wrapWindowsGitBashHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when bash metacharacters are present', () => {
|
||||
const scriptPath = 'C:\\Users\\alice & bob\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const command = wrapWindowsGitBashHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(command).not.toContain('& bob')
|
||||
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWindowsAgentHookPostCommand', () => {
|
||||
it('posts hook stdin through bounded curl without spawning PowerShell', () => {
|
||||
const command = buildWindowsAgentHookPostCommand('codex')
|
||||
|
||||
@@ -163,6 +163,24 @@ export function wrapWindowsHookCommand(scriptPath: string): string {
|
||||
return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
|
||||
}
|
||||
|
||||
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
|
||||
|
||||
export function wrapWindowsCmdHookCommand(scriptPath: string): string {
|
||||
// Why: skip the ~360ms PowerShell interpreter startup when the path is safe
|
||||
// for cmd.exe to invoke directly. The fallback keeps the encoded PowerShell
|
||||
// launcher for paths with spaces or metacharacters.
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
|
||||
|
||||
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
|
||||
const bashPath = scriptPath.replaceAll('\\', '/')
|
||||
// Why: Claude Code's Windows hook runner is Git Bash/MSYS. It can execute a
|
||||
// bare .cmd via forward slashes, but shell metacharacters must stay encoded.
|
||||
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) ? bashPath : wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
|
||||
// Why: Codex runs these hooks inline on every turn. PowerShell startup alone
|
||||
// makes trusted Windows hooks visibly slow, so mirror the POSIX curl path.
|
||||
|
||||
@@ -21,7 +21,9 @@ function createMockWebContents() {
|
||||
detach: vi.fn(() => {
|
||||
debuggerAttached = false
|
||||
}),
|
||||
sendCommand: vi.fn(async () => ({})),
|
||||
sendCommand: vi.fn(
|
||||
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
|
||||
),
|
||||
on: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
arr.push(handler)
|
||||
@@ -41,6 +43,8 @@ function createMockWebContents() {
|
||||
debugger: debuggerObj,
|
||||
isDestroyed: () => destroyed,
|
||||
focus: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
reloadIgnoringCache: vi.fn(),
|
||||
getTitle: vi.fn(() => 'Example'),
|
||||
getURL: vi.fn(() => 'https://example.com')
|
||||
},
|
||||
@@ -89,6 +93,22 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
}
|
||||
|
||||
type SendCommandCall = [string, Record<string, unknown>?, string?]
|
||||
|
||||
function getSendCommandCalls(): SendCommandCall[] {
|
||||
const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>?,
|
||||
string?
|
||||
][]
|
||||
return calls
|
||||
}
|
||||
|
||||
function getSendCommandMethods(): string[] {
|
||||
const calls = getSendCommandCalls()
|
||||
return calls.map((call) => call[0])
|
||||
}
|
||||
|
||||
it('starts on a random port and returns ws:// URL', () => {
|
||||
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
||||
expect(proxy.getPort()).toBeGreaterThan(0)
|
||||
@@ -311,6 +331,168 @@ describe('CdpWsProxy', () => {
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('primes lifecycle events for Page.navigate', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 11,
|
||||
method: 'Page.navigate',
|
||||
params: { url: 'https://example.com/next' }
|
||||
})
|
||||
|
||||
expect(response.id).toBe(11)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
'Page.enable',
|
||||
'Page.setLifecycleEventsEnabled',
|
||||
'Page.navigate'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('primes lifecycle events for Page.reload and preserves response id', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 12,
|
||||
method: 'Page.reload'
|
||||
})
|
||||
|
||||
expect(response.id).toBe(12)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
'Page.enable',
|
||||
'Page.setLifecycleEventsEnabled'
|
||||
])
|
||||
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandMethods()).not.toContain('Page.reload')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('preserves explicit Page.navigate session during lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 14,
|
||||
method: 'Page.navigate',
|
||||
params: { url: 'https://example.com/frame' },
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls().slice(2)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
['Page.navigate', { url: 'https://example.com/frame' }, 'iframe-session-123']
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('forwards explicit Page.reload session after lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 15,
|
||||
method: 'Page.reload',
|
||||
params: { ignoreCache: true },
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls().slice(2)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
['Page.reload', { ignoreCache: true }, 'iframe-session-123']
|
||||
])
|
||||
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
expect(mock.webContents.reload).not.toHaveBeenCalled()
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 16,
|
||||
method: 'Page.reload',
|
||||
params: { loaderId: 'stale-loader' }
|
||||
})
|
||||
|
||||
expect(response).toEqual({
|
||||
id: 16,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Page.reload parameter "loaderId" is not supported for Orca tab reloads'
|
||||
}
|
||||
})
|
||||
expect(mock.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
expect(getSendCommandMethods()).not.toContain('Network.enable')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('still reloads when lifecycle priming stalls', async () => {
|
||||
const client = await connect()
|
||||
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
|
||||
if (method === 'Network.enable') {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const responsePromise = sendAndReceive(client, {
|
||||
id: 17,
|
||||
method: 'Page.reload'
|
||||
})
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({ id: 17, result: {} })
|
||||
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not reload after the requesting client disconnects during priming', async () => {
|
||||
const client = await connect()
|
||||
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
|
||||
if (method === 'Network.enable') {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
client.send(JSON.stringify({ id: 18, method: 'Page.reload' }))
|
||||
client.close()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100))
|
||||
|
||||
expect(mock.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards Runtime.evaluate without lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 13,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression: 'document.readyState' }
|
||||
})
|
||||
|
||||
expect(response.id).toBe(13)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Runtime.evaluate'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
// ── Page.frameNavigated interception ──
|
||||
|
||||
// ── Cleanup ──
|
||||
|
||||
@@ -6,6 +6,8 @@ import { captureScreenshot } from './cdp-screenshot'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
|
||||
|
||||
const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
|
||||
|
||||
export class CdpWsProxy {
|
||||
private httpServer: Server | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
@@ -298,13 +300,39 @@ export class CdpWsProxy {
|
||||
}
|
||||
// Why: agent-browser waits for network idle to detect navigation completion.
|
||||
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
|
||||
// Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031).
|
||||
if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) {
|
||||
void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {})
|
||||
void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
|
||||
return
|
||||
}
|
||||
// Why: CDP Page.reload can destroy Electron webview targets during process swaps.
|
||||
// Use the same direct webContents reload path as Orca's own browser.reload.
|
||||
if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) {
|
||||
void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId)
|
||||
}
|
||||
|
||||
private resolveDebuggerSessionId(msgSessionId?: string): string | undefined {
|
||||
return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
|
||||
}
|
||||
|
||||
private isActiveClient(client: WebSocket): boolean {
|
||||
return this.client === client && client.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
private sendDebuggerCommand(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
sessionId?: string
|
||||
): Promise<unknown> {
|
||||
const command = sessionId
|
||||
? this.webContents.debugger.sendCommand(method, params, sessionId)
|
||||
: this.webContents.debugger.sendCommand(method, params)
|
||||
return Promise.resolve(command)
|
||||
}
|
||||
|
||||
private forwardCommand(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
@@ -316,10 +344,9 @@ export class CdpWsProxy {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return
|
||||
}
|
||||
const sessionId =
|
||||
msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
|
||||
const sessionId = this.resolveDebuggerSessionId(msgSessionId)
|
||||
try {
|
||||
Promise.resolve(this.webContents.debugger.sendCommand(method, params, sessionId))
|
||||
this.sendDebuggerCommand(method, params, sessionId)
|
||||
.then((result) => {
|
||||
this.sendResult(clientId, result, client)
|
||||
})
|
||||
@@ -331,21 +358,85 @@ export class CdpWsProxy {
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateWithLifecycleEnsured(
|
||||
private async navigateWithLifecycle(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
params: Record<string, unknown>,
|
||||
msgSessionId?: string
|
||||
): Promise<void> {
|
||||
await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId))
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId)
|
||||
}
|
||||
|
||||
private async reloadWithLifecycle(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>,
|
||||
msgSessionId?: string
|
||||
): Promise<void> {
|
||||
const sessionId = this.resolveDebuggerSessionId(msgSessionId)
|
||||
const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params)
|
||||
if (unsupportedParam) {
|
||||
this.sendError(
|
||||
clientId,
|
||||
`Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`,
|
||||
client
|
||||
)
|
||||
return
|
||||
}
|
||||
await this.primePageLifecycle(sessionId)
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
if (sessionId) {
|
||||
this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId)
|
||||
return
|
||||
}
|
||||
if (this.webContents.isDestroyed()) {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dbg = this.webContents.debugger
|
||||
if (params.ignoreCache === true) {
|
||||
this.webContents.reloadIgnoringCache()
|
||||
} else {
|
||||
this.webContents.reload()
|
||||
}
|
||||
this.sendResult(clientId, {}, client)
|
||||
} catch (err) {
|
||||
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
|
||||
}
|
||||
}
|
||||
|
||||
private getUnsupportedRootReloadParam(params: Record<string, unknown>): string | null {
|
||||
return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null
|
||||
}
|
||||
|
||||
private async primePageLifecycle(sessionId?: string): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const priming = (async (): Promise<void> => {
|
||||
// Why: without Network.enable, agent-browser never sees network idle → goto times out.
|
||||
await dbg.sendCommand('Network.enable', {})
|
||||
await dbg.sendCommand('Page.enable', {})
|
||||
await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true })
|
||||
} catch {
|
||||
/* best-effort */
|
||||
await this.sendDebuggerCommand('Network.enable', {}, sessionId)
|
||||
await this.sendDebuggerCommand('Page.enable', {}, sessionId)
|
||||
await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId)
|
||||
})().catch(() => {})
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
priming,
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS)
|
||||
timeout.unref?.()
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
this.forwardCommand(client, clientId, 'Page.navigate', params)
|
||||
}
|
||||
|
||||
private handleScreenshot(
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
|
||||
import { ClaudeHookService } from './hook-service'
|
||||
import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
|
||||
|
||||
@@ -23,6 +24,8 @@ const OPENCLAUDE_SCRIPT_FILE_NAME =
|
||||
process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh'
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
const isClaudeManagedCommand = createManagedCommandMatcher(CLAUDE_SCRIPT_FILE_NAME)
|
||||
const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT_FILE_NAME)
|
||||
|
||||
type FakeFs = {
|
||||
files: Map<string, string>
|
||||
@@ -162,23 +165,13 @@ describe('ClaudeHookService.install', () => {
|
||||
definition.hooks.map((hook) => hook.command)
|
||||
)
|
||||
expect(legacyCommands).toContain('/usr/local/bin/user-hook')
|
||||
expect(
|
||||
legacyCommands.some((command: string) =>
|
||||
process.platform === 'win32'
|
||||
? WINDOWS_POWERSHELL_LAUNCHER.test(command)
|
||||
: command.includes(CLAUDE_SCRIPT_FILE_NAME)
|
||||
)
|
||||
).toBe(true)
|
||||
expect(legacyCommands.some((command: string) => isClaudeManagedCommand(command))).toBe(true)
|
||||
expect(
|
||||
legacyCommands.some((command: string) =>
|
||||
command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(legacy.hooks.StopFailure[0].hooks[0].command).toMatch(
|
||||
process.platform === 'win32'
|
||||
? WINDOWS_POWERSHELL_LAUNCHER
|
||||
: new RegExp(CLAUDE_SCRIPT_FILE_NAME)
|
||||
)
|
||||
expect(isClaudeManagedCommand(legacy.hooks.StopFailure[0].hooks[0].command)).toBe(true)
|
||||
expect(
|
||||
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8')
|
||||
).toContain('DEVIN_PROJECT_DIR')
|
||||
@@ -351,10 +344,8 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8'))
|
||||
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const command = parsed.hooks[event][0].hooks[0].command as string
|
||||
if (process.platform === 'win32') {
|
||||
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
|
||||
} else {
|
||||
expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME)
|
||||
expect(isOpenClaudeManagedCommand(command)).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getSharedManagedScriptPath,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
@@ -75,14 +75,9 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
|
||||
}
|
||||
|
||||
export function getManagedCommand(scriptPath: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Why: Claude Code runs hooks through Git Bash on Windows. Forward slashes
|
||||
// alone don't survive a path with spaces — bash splits at the space and
|
||||
// tries to execute `C:/Users/Jorge` as a command. Wrapping in
|
||||
// `cmd.exe /d /c call "..."` keeps the path as one argument. #6078.
|
||||
return wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
return wrapPosixHookCommand(scriptPath)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsGitBashHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export function getRemoteManagedCommand(scriptPath: string): string {
|
||||
|
||||
@@ -277,6 +277,49 @@ describe('CodexAccountService config sync', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rewrites relative path config values when syncing into managed homes', async () => {
|
||||
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
writeFileSync(
|
||||
canonicalConfigPath,
|
||||
'model_instructions_file = "instructions.md"\nsandbox_mode = "danger-full-access"\n',
|
||||
'utf-8'
|
||||
)
|
||||
const managedHomePath = createManagedHome(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
'approval_policy = "on-request"\n',
|
||||
'{"account":"managed"}\n'
|
||||
)
|
||||
const settings = createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'user@example.com',
|
||||
managedHomePath,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const rateLimits = createRateLimits()
|
||||
const runtimeHome = createRuntimeHome()
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
new CodexAccountService(store as never, rateLimits as never, runtimeHome as never)
|
||||
|
||||
const managedConfig = readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')
|
||||
expect(managedConfig).toContain(
|
||||
`model_instructions_file = '${join(testState.fakeHomeDir, '.codex', 'instructions.md')}'`
|
||||
)
|
||||
expect(managedConfig).toContain('sandbox_mode = "danger-full-access"')
|
||||
})
|
||||
|
||||
it('does not rewrite managed configs that already match canonical config', async () => {
|
||||
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n'
|
||||
@@ -706,7 +749,11 @@ describe('CodexAccountService config sync', () => {
|
||||
const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home')
|
||||
const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml')
|
||||
const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home'
|
||||
writeFileSync(wslConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8')
|
||||
writeFileSync(
|
||||
wslConfigPath,
|
||||
'sandbox_mode = "danger-full-access"\nmodel_instructions_file = "instructions.md"\n',
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const execFileSyncMock = vi.fn((_command: string, args: string[]) => {
|
||||
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
|
||||
@@ -731,8 +778,11 @@ describe('CodexAccountService config sync', () => {
|
||||
'-ic',
|
||||
`export CODEX_HOME='${wslLinuxHomePath}'; exec codex login`
|
||||
])
|
||||
// Why: codex login runs inside WSL, so the rewritten path must be the
|
||||
// Linux-side ~/.codex, not a Windows UNC path.
|
||||
expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe(
|
||||
'sandbox_mode = "danger-full-access"\n'
|
||||
'sandbox_mode = "danger-full-access"\n' +
|
||||
"model_instructions_file = '/home/alice/.codex/instructions.md'\n"
|
||||
)
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import type { Store } from '../persistence'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
@@ -47,6 +48,13 @@ type ResolvedCodexIdentity = {
|
||||
workspaceAccountId: string | null
|
||||
}
|
||||
|
||||
type CanonicalCodexConfig = {
|
||||
contents: string
|
||||
/** Home the config was read from, in the path style Codex sees at runtime
|
||||
* (Linux-side for WSL); relative path-valued settings resolve against it. */
|
||||
sourceHomePath: string
|
||||
}
|
||||
|
||||
export type CodexAccountAddTarget = {
|
||||
runtime?: 'host' | 'wsl'
|
||||
wslDistro?: string | null
|
||||
@@ -452,25 +460,31 @@ export class CodexAccountService {
|
||||
// Why: Orca account switching is meant to swap Codex credentials and quota
|
||||
// identity, not silently fork the user's sandbox/config defaults. Syncing
|
||||
// one canonical config into every managed home keeps auth isolated per
|
||||
// account while preserving consistent Codex behavior.
|
||||
this.writeManagedConfig(trustedManagedHomePath, canonicalConfig)
|
||||
// account while preserving consistent Codex behavior. Managed homes are
|
||||
// real CODEX_HOMEs for `codex login`, so relative path-valued settings
|
||||
// must keep resolving against the home the config was read from.
|
||||
this.writeManagedConfig(
|
||||
trustedManagedHomePath,
|
||||
rewriteRelativePathConfigValues(canonicalConfig.contents, canonicalConfig.sourceHomePath)
|
||||
)
|
||||
}
|
||||
|
||||
private readCanonicalConfig(): string | null {
|
||||
const primaryConfigPath = join(homedir(), '.codex', 'config.toml')
|
||||
private readCanonicalConfig(): CanonicalCodexConfig | null {
|
||||
const sourceHomePath = join(homedir(), '.codex')
|
||||
const primaryConfigPath = join(sourceHomePath, 'config.toml')
|
||||
if (!existsSync(primaryConfigPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return readFileSync(primaryConfigPath, 'utf-8')
|
||||
return { contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath }
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to read canonical config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private readCanonicalConfigForManagedHome(managedHomePath: string): string | null {
|
||||
private readCanonicalConfigForManagedHome(managedHomePath: string): CanonicalCodexConfig | null {
|
||||
const wslInfo = parseWslUncPath(managedHomePath)
|
||||
if (!wslInfo) {
|
||||
return this.readCanonicalConfig()
|
||||
@@ -488,7 +502,9 @@ export class CodexAccountService {
|
||||
}
|
||||
|
||||
try {
|
||||
return readFileSync(configPath, 'utf-8')
|
||||
// Why: the config is read over UNC but consumed by Codex inside WSL, so
|
||||
// path rewrites must anchor to the Linux-side ~/.codex, not the UNC path.
|
||||
return { contents: readFileSync(configPath, 'utf-8'), sourceHomePath: `${wslHome}/.codex` }
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to read WSL canonical config:', error)
|
||||
return null
|
||||
|
||||
@@ -108,6 +108,141 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
expect(readFileSync(getSystemConfigPath(), 'utf-8')).toContain('codex_hooks = true')
|
||||
})
|
||||
|
||||
it('preserves system-home relative path references in the runtime config copy', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model_instructions_file = "instructions.md"',
|
||||
"model_catalog_json = 'catalogs/models.json'",
|
||||
'experimental_compact_prompt_file = "prompts/compact.md"',
|
||||
'experimental_instructions_file = "legacy-instructions.md"',
|
||||
'log_dir = "logs"',
|
||||
'sqlite_home = "state"',
|
||||
'',
|
||||
'[agents.reviewer]',
|
||||
'config_file = "agents/reviewer.toml"',
|
||||
'',
|
||||
'[model_providers.qwen.auth]',
|
||||
'cwd = "auth"',
|
||||
'',
|
||||
'[[skills.config]]',
|
||||
'path = "skills/local"',
|
||||
''
|
||||
].join('\r\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain(
|
||||
`model_instructions_file = '${join(getSystemCodexHomePath(), 'instructions.md')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(
|
||||
`model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'models.json')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(
|
||||
`experimental_compact_prompt_file = '${join(
|
||||
getSystemCodexHomePath(),
|
||||
'prompts',
|
||||
'compact.md'
|
||||
)}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(
|
||||
`experimental_instructions_file = '${join(
|
||||
getSystemCodexHomePath(),
|
||||
'legacy-instructions.md'
|
||||
)}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
|
||||
expect(runtimeConfig).toContain(`sqlite_home = '${join(getSystemCodexHomePath(), 'state')}'`)
|
||||
expect(runtimeConfig).toContain(
|
||||
`config_file = '${join(getSystemCodexHomePath(), 'agents', 'reviewer.toml')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(`cwd = '${join(getSystemCodexHomePath(), 'auth')}'`)
|
||||
expect(runtimeConfig).toContain(`path = '${join(getSystemCodexHomePath(), 'skills', 'local')}'`)
|
||||
})
|
||||
|
||||
it('rewrites profile and debug lockfile path references', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'[profiles.fast]',
|
||||
'model_catalog_json = "catalogs/fast.json"',
|
||||
'',
|
||||
'[debug.config_lockfile]',
|
||||
'load_path = "locks/config.lock.toml"',
|
||||
'export_dir = "locks"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain(
|
||||
`model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'fast.json')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(
|
||||
`load_path = '${join(getSystemCodexHomePath(), 'locks', 'config.lock.toml')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(`export_dir = '${join(getSystemCodexHomePath(), 'locks')}'`)
|
||||
})
|
||||
|
||||
it('does not treat lines inside multiline arrays as headers or path keys', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
['notify = [', ' ["custom", 1]', ']', 'log_dir = "logs"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain(' ["custom", 1]')
|
||||
expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
|
||||
})
|
||||
|
||||
it('escapes control characters instead of emitting them raw in rewritten paths', () => {
|
||||
writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\bdir"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('log_dir = "')
|
||||
expect(runtimeConfig).toContain('\\u0008')
|
||||
expect(runtimeConfig).not.toContain('\b')
|
||||
})
|
||||
|
||||
it('leaves values with lone-surrogate unicode escapes untouched', () => {
|
||||
writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\uD800dir"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('log_dir = "logs\\uD800dir"')
|
||||
})
|
||||
|
||||
it('leaves absolute, home-prefixed, env-shaped, and URL path references untouched', () => {
|
||||
const passthroughLines = [
|
||||
'model_instructions_file = "~/notes/instructions.md"',
|
||||
'model_catalog_json = "$CODEX_ASSETS/models.json"',
|
||||
'experimental_instructions_file = "%USERPROFILE%\\\\instructions.md"',
|
||||
'log_dir = "/var/log/codex"',
|
||||
"sqlite_home = 'C:\\Users\\example\\state'",
|
||||
'experimental_compact_prompt_file = "file://server/prompts/compact.md"'
|
||||
]
|
||||
writeFileSync(getSystemConfigPath(), `${passthroughLines.join('\n')}\n`, 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
for (const line of passthroughLines) {
|
||||
expect(runtimeConfig).toContain(line)
|
||||
}
|
||||
})
|
||||
|
||||
it('drops deprecated codex_hooks when the new hooks flag already exists', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
|
||||
import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
getTomlTableHeader,
|
||||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
|
||||
function getRuntimeCodexConfigTomlPath(): string {
|
||||
return join(getOrcaManagedCodexHomePath(), 'config.toml')
|
||||
@@ -28,8 +35,9 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
|
||||
return
|
||||
}
|
||||
|
||||
const systemConfig = normalizeDeprecatedCodexHookFeatureFlag(
|
||||
systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : ''
|
||||
const systemConfig = prepareSystemConfigForRuntimeMirror(
|
||||
systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '',
|
||||
dirname(systemConfigPath)
|
||||
)
|
||||
if (!runtimeConfigExists) {
|
||||
// Why: trust blocks reference a hooks.json path, so system-home hook trust
|
||||
@@ -45,6 +53,13 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string {
|
||||
return rewriteRelativePathConfigValues(
|
||||
normalizeDeprecatedCodexHookFeatureFlag(config),
|
||||
systemConfigDir
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
|
||||
if (!config.includes('codex_hooks')) {
|
||||
return config
|
||||
@@ -145,13 +160,6 @@ type TomlSection = {
|
||||
start: number
|
||||
}
|
||||
|
||||
type TomlMultilineState = {
|
||||
basic: boolean
|
||||
literal: boolean
|
||||
}
|
||||
|
||||
type TomlMultilineMode = 'basic' | 'literal' | null
|
||||
|
||||
function stripRuntimeOwnedTomlSections(
|
||||
config: string,
|
||||
runtimeProjectHeaders = new Set<string>()
|
||||
@@ -179,14 +187,12 @@ function getTomlSections(config: string): TomlSection[] {
|
||||
const sections: TomlSection[] = []
|
||||
let sectionStart = -1
|
||||
let sectionHeader: string | null = null
|
||||
let multilineState: TomlMultilineState = { basic: false, literal: false }
|
||||
let scanState = createTomlLineScanState()
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const header = isInsideTomlMultilineString(multilineState)
|
||||
? null
|
||||
: getTomlTableHeader(lines[index] ?? '')
|
||||
const header = isTomlStructuralLine(scanState) ? getTomlTableHeader(lines[index] ?? '') : null
|
||||
if (!header) {
|
||||
multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '')
|
||||
scanState = updateTomlLineScanState(scanState, lines[index] ?? '')
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -199,7 +205,7 @@ function getTomlSections(config: string): TomlSection[] {
|
||||
}
|
||||
sectionStart = index
|
||||
sectionHeader = header
|
||||
multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '')
|
||||
scanState = updateTomlLineScanState(scanState, lines[index] ?? '')
|
||||
}
|
||||
|
||||
if (sectionStart !== -1) {
|
||||
@@ -241,87 +247,3 @@ function joinTomlBlocks(blocks: string[]): string {
|
||||
const normalizedBlocks = blocks.map((block) => block.trim()).filter((block) => block.length > 0)
|
||||
return normalizedBlocks.length === 0 ? '' : `${normalizedBlocks.join('\n\n')}\n`
|
||||
}
|
||||
|
||||
function getTomlTableHeader(line: string): string | null {
|
||||
const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function isInsideTomlMultilineString(state: TomlMultilineState): boolean {
|
||||
return state.basic || state.literal
|
||||
}
|
||||
|
||||
function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState {
|
||||
let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
|
||||
let index = 0
|
||||
while (index < line.length) {
|
||||
if (mode === 'basic') {
|
||||
if (line[index] === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('"""', index)) {
|
||||
mode = null
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (mode === 'literal') {
|
||||
if (line.startsWith("'''", index)) {
|
||||
mode = null
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const char = line[index]
|
||||
if (char === '#') {
|
||||
break
|
||||
}
|
||||
if (line.startsWith('"""', index)) {
|
||||
mode = 'basic'
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("'''", index)) {
|
||||
mode = 'literal'
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
index = skipTomlBasicString(line, index + 1)
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
index = skipTomlLiteralString(line, index + 1)
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return { basic: mode === 'basic', literal: mode === 'literal' }
|
||||
}
|
||||
|
||||
function skipTomlBasicString(line: string, startIndex: number): number {
|
||||
let index = startIndex
|
||||
while (index < line.length) {
|
||||
const char = line[index]
|
||||
if (char === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
return index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function skipTomlLiteralString(line: string, startIndex: number): number {
|
||||
const endIndex = line.indexOf("'", startIndex)
|
||||
return endIndex === -1 ? line.length : endIndex + 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { posix as pathPosix, win32 as pathWin32 } from 'node:path'
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
getTomlTableHeader,
|
||||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
|
||||
// Why: codex-rs types these settings AbsolutePathBuf and resolves relative
|
||||
// values against the defining config.toml's directory (= CODEX_HOME for the
|
||||
// user config). experimental_instructions_file only exists in older Codex
|
||||
// releases; keeping it is harmless since Codex ignores unknown keys.
|
||||
const EXACT_PATH_CONFIG_KEYS = new Set([
|
||||
'debug.config_lockfile.export_dir',
|
||||
'debug.config_lockfile.load_path',
|
||||
'experimental_compact_prompt_file',
|
||||
'experimental_instructions_file',
|
||||
'log_dir',
|
||||
'model_catalog_json',
|
||||
'model_instructions_file',
|
||||
'skills.config.path',
|
||||
'sqlite_home'
|
||||
])
|
||||
|
||||
type ParsedTomlString = {
|
||||
value: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
// Why: Orca mirrors config.toml into a managed CODEX_HOME, but Codex resolves
|
||||
// path-valued config settings from the file it read. Keep user-owned assets in
|
||||
// ~/.codex reachable after the mirror moves the TOML. Best-effort by design:
|
||||
// values spelled as inline tables, quoted keys, or triple-quoted strings pass
|
||||
// through unchanged rather than risk corrupting them.
|
||||
export function rewriteRelativePathConfigValues(config: string, sourceConfigDir: string): string {
|
||||
const lines = config.split('\n')
|
||||
let tablePath = ''
|
||||
let scanState = createTomlLineScanState()
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(scanState)) {
|
||||
const header = getTomlTableHeader(line)
|
||||
if (header) {
|
||||
tablePath = getTomlHeaderPath(header)
|
||||
} else {
|
||||
lines[index] = rewriteRelativePathConfigLine(line, tablePath, sourceConfigDir)
|
||||
}
|
||||
}
|
||||
scanState = updateTomlLineScanState(scanState, line)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function rewriteRelativePathConfigLine(
|
||||
line: string,
|
||||
tablePath: string,
|
||||
sourceConfigDir: string
|
||||
): string {
|
||||
const equalsIndex = line.indexOf('=')
|
||||
if (equalsIndex === -1) {
|
||||
return line
|
||||
}
|
||||
|
||||
const key = line.slice(0, equalsIndex).trim()
|
||||
if (!isPathConfigKey(tablePath, key)) {
|
||||
return line
|
||||
}
|
||||
|
||||
const parsed = parseTomlSingleLineStringValue(line, equalsIndex + 1)
|
||||
if (!parsed || !shouldRewriteRelativePath(parsed.value)) {
|
||||
return line
|
||||
}
|
||||
|
||||
// Why: WSL configs are read over UNC paths but consumed inside Linux, so
|
||||
// join semantics must follow the source home's path style, not the host's.
|
||||
const path = sourceConfigDir.startsWith('/') ? pathPosix : pathWin32
|
||||
const absolutePath = path.join(sourceConfigDir, parsed.value)
|
||||
return `${line.slice(0, parsed.start)}${quoteTomlPath(absolutePath)}${line.slice(parsed.end)}`
|
||||
}
|
||||
|
||||
function isPathConfigKey(tablePath: string, key: string): boolean {
|
||||
const normalizedKey = normalizeTomlPathExpression(key)
|
||||
const fullPath = tablePath
|
||||
? `${normalizeTomlPathExpression(tablePath)}.${normalizedKey}`
|
||||
: normalizedKey
|
||||
if (EXACT_PATH_CONFIG_KEYS.has(fullPath)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
/^agents\..+\.config_file$/.test(fullPath) ||
|
||||
/^model_providers\..+\.auth\.cwd$/.test(fullPath) ||
|
||||
// Why: profiles mirror the top-level file settings that Codex reads (and
|
||||
// can abort on) during config load.
|
||||
/^profiles\..+\.(?:experimental_compact_prompt_file|model_catalog_json|model_instructions_file)$/.test(
|
||||
fullPath
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeTomlPathExpression(value: string): string {
|
||||
return value.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function shouldRewriteRelativePath(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || trimmed.startsWith('~') || trimmed.startsWith('$') || trimmed.startsWith('%')) {
|
||||
return false
|
||||
}
|
||||
if (pathWin32.isAbsolute(trimmed) || pathPosix.isAbsolute(trimmed)) {
|
||||
return false
|
||||
}
|
||||
return !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed)
|
||||
}
|
||||
|
||||
function quoteTomlPath(value: string): string {
|
||||
return canUseTomlLiteralString(value) ? `'${value}'` : quoteTomlBasicString(value)
|
||||
}
|
||||
|
||||
// Why: TOML literal strings allow tab but no other control chars and no
|
||||
// single quote; anything else must go through an escaped basic string.
|
||||
function canUseTomlLiteralString(value: string): boolean {
|
||||
for (const char of value) {
|
||||
if (char === "'") {
|
||||
return false
|
||||
}
|
||||
const codePoint = char.codePointAt(0) ?? 0
|
||||
if ((codePoint < 0x20 && codePoint !== 0x09) || codePoint === 0x7f) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function quoteTomlBasicString(value: string): string {
|
||||
let quoted = '"'
|
||||
for (const char of value) {
|
||||
if (char === '"' || char === '\\') {
|
||||
quoted += `\\${char}`
|
||||
continue
|
||||
}
|
||||
const codePoint = char.codePointAt(0) ?? 0
|
||||
if (codePoint < 0x20 || codePoint === 0x7f) {
|
||||
quoted += `\\u${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
|
||||
continue
|
||||
}
|
||||
quoted += char
|
||||
}
|
||||
return `${quoted}"`
|
||||
}
|
||||
|
||||
function parseTomlSingleLineStringValue(line: string, offset: number): ParsedTomlString | null {
|
||||
let index = offset
|
||||
while (line[index] === ' ' || line[index] === '\t') {
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (line.startsWith('"""', index) || line.startsWith("'''", index)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const quote = line[index]
|
||||
if (quote !== '"' && quote !== "'") {
|
||||
return null
|
||||
}
|
||||
|
||||
const start = index
|
||||
index += 1
|
||||
let value = ''
|
||||
while (index < line.length) {
|
||||
const char = line[index]
|
||||
if (char === quote) {
|
||||
return { value, start, end: index + 1 }
|
||||
}
|
||||
if (quote === '"' && char === '\\') {
|
||||
const escaped = parseTomlBasicStringEscape(line, index)
|
||||
if (!escaped) {
|
||||
return null
|
||||
}
|
||||
value += escaped.value
|
||||
index = escaped.nextIndex
|
||||
continue
|
||||
}
|
||||
value += char
|
||||
index += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseTomlBasicStringEscape(
|
||||
line: string,
|
||||
slashIndex: number
|
||||
): { value: string; nextIndex: number } | null {
|
||||
const escaped = line[slashIndex + 1]
|
||||
switch (escaped) {
|
||||
case 'b':
|
||||
return { value: '\b', nextIndex: slashIndex + 2 }
|
||||
case 't':
|
||||
return { value: '\t', nextIndex: slashIndex + 2 }
|
||||
case 'n':
|
||||
return { value: '\n', nextIndex: slashIndex + 2 }
|
||||
case 'f':
|
||||
return { value: '\f', nextIndex: slashIndex + 2 }
|
||||
case 'r':
|
||||
return { value: '\r', nextIndex: slashIndex + 2 }
|
||||
case '"':
|
||||
case '\\':
|
||||
return { value: escaped, nextIndex: slashIndex + 2 }
|
||||
case 'u':
|
||||
return parseTomlUnicodeEscape(line, slashIndex + 2, 4)
|
||||
case 'U':
|
||||
return parseTomlUnicodeEscape(line, slashIndex + 2, 8)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseTomlUnicodeEscape(
|
||||
line: string,
|
||||
start: number,
|
||||
length: number
|
||||
): { value: string; nextIndex: number } | null {
|
||||
const raw = line.slice(start, start + length)
|
||||
if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(raw)) {
|
||||
return null
|
||||
}
|
||||
const codePoint = Number.parseInt(raw, 16)
|
||||
// Why: TOML escapes must be Unicode scalar values; String.fromCodePoint
|
||||
// accepts lone surrogates, which would round-trip into invalid TOML.
|
||||
if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return { value: String.fromCodePoint(codePoint), nextIndex: start + length }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getTomlHeaderPath(header: string): string {
|
||||
const trimmed = header.trim()
|
||||
if (trimmed.startsWith('[[') && trimmed.endsWith(']]')) {
|
||||
return trimmed.slice(2, -2).trim()
|
||||
}
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
return trimmed.slice(1, -1).trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Why: Orca edits Codex config.toml byte-preservingly (no TOML dependency), so
|
||||
// every editor must agree on which lines sit inside multiline strings or
|
||||
// arrays vs real TOML structure. Keep the line-scanner in one place to avoid
|
||||
// drift.
|
||||
|
||||
export type TomlLineScanState = {
|
||||
basic: boolean
|
||||
literal: boolean
|
||||
arrayDepth: number
|
||||
}
|
||||
|
||||
type TomlMultilineMode = 'basic' | 'literal' | null
|
||||
|
||||
export function createTomlLineScanState(): TomlLineScanState {
|
||||
return { basic: false, literal: false, arrayDepth: 0 }
|
||||
}
|
||||
|
||||
// Why: lines inside multiline strings or unclosed arrays can look exactly like
|
||||
// `[section]` headers or `key = value` pairs but are data, not structure.
|
||||
export function isTomlStructuralLine(state: TomlLineScanState): boolean {
|
||||
return !state.basic && !state.literal && state.arrayDepth === 0
|
||||
}
|
||||
|
||||
export function updateTomlLineScanState(state: TomlLineScanState, line: string): TomlLineScanState {
|
||||
let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
|
||||
let arrayDepth = state.arrayDepth
|
||||
let index = 0
|
||||
while (index < line.length) {
|
||||
if (mode === 'basic') {
|
||||
if (line[index] === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('"""', index)) {
|
||||
mode = null
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (mode === 'literal') {
|
||||
if (line.startsWith("'''", index)) {
|
||||
mode = null
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const char = line[index]
|
||||
if (char === '#') {
|
||||
break
|
||||
}
|
||||
if (line.startsWith('"""', index)) {
|
||||
mode = 'basic'
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("'''", index)) {
|
||||
mode = 'literal'
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
index = skipTomlBasicString(line, index + 1)
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
index = skipTomlLiteralString(line, index + 1)
|
||||
continue
|
||||
}
|
||||
// Why: table-header brackets balance within their line, so a depth that
|
||||
// stays positive across lines means a multiline array is still open.
|
||||
if (char === '[') {
|
||||
arrayDepth += 1
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (char === ']') {
|
||||
arrayDepth = Math.max(0, arrayDepth - 1)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return { basic: mode === 'basic', literal: mode === 'literal', arrayDepth }
|
||||
}
|
||||
|
||||
export function getTomlTableHeader(line: string): string | null {
|
||||
const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function skipTomlBasicString(line: string, startIndex: number): number {
|
||||
let index = startIndex
|
||||
while (index < line.length) {
|
||||
const char = line[index]
|
||||
if (char === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
return index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function skipTomlLiteralString(line: string, startIndex: number): number {
|
||||
const endIndex = line.indexOf("'", startIndex)
|
||||
return endIndex === -1 ? line.length : endIndex + 1
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import { dirname, join } from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { escapeRegex } from '../../shared/string-utils'
|
||||
import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from '../codex-accounts/fs-utils'
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
|
||||
// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in
|
||||
// ~/.codex/config.toml under [hooks.state."<key>"]. Without it the hook is in
|
||||
@@ -476,16 +481,14 @@ export function normalizeHookTrustKeyForLookup(key: string): string {
|
||||
function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
|
||||
const ranges: TrustBlockRange[] = []
|
||||
let cursor = 0
|
||||
let multilineState: TomlMultilineState = { basic: false, literal: false }
|
||||
let scanState = createTomlLineScanState()
|
||||
while (cursor < content.length) {
|
||||
const newlineIdx = content.indexOf('\n', cursor)
|
||||
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx
|
||||
const rawLine = content.slice(cursor, lineEnd)
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
|
||||
const headerKey = isInsideTomlMultilineString(multilineState)
|
||||
? null
|
||||
: parseHookStateHeaderKey(line)
|
||||
const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
|
||||
if (
|
||||
headerKey !== null &&
|
||||
normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key)
|
||||
@@ -498,7 +501,7 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
|
||||
cursor = Math.max(blockEnd, nextCursor)
|
||||
continue
|
||||
}
|
||||
multilineState = updateTomlMultilineState(multilineState, line)
|
||||
scanState = updateTomlLineScanState(scanState, line)
|
||||
cursor = nextCursor
|
||||
}
|
||||
return ranges
|
||||
@@ -596,13 +599,13 @@ function skipTomlInlineWhitespace(line: string, startIndex: number): number {
|
||||
// a flat regex misclassifies both cases.
|
||||
function findNextTableHeader(text: string): number {
|
||||
let cursor = 0
|
||||
let multilineState: TomlMultilineState = { basic: false, literal: false }
|
||||
let scanState = createTomlLineScanState()
|
||||
while (cursor < text.length) {
|
||||
const newlineIdx = text.indexOf('\n', cursor)
|
||||
const lineEnd = newlineIdx === -1 ? text.length : newlineIdx
|
||||
const rawLine = text.slice(cursor, lineEnd)
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
if (!isInsideTomlMultilineString(multilineState)) {
|
||||
if (isTomlStructuralLine(scanState)) {
|
||||
const trimmed = line.trimStart()
|
||||
// Why: stop at both `[table]` and `[[array.of.tables]]` — both end our
|
||||
// block. Skipping `[[ ]]` here would let our slice consume past array
|
||||
@@ -611,7 +614,7 @@ function findNextTableHeader(text: string): number {
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
multilineState = updateTomlMultilineState(multilineState, line)
|
||||
scanState = updateTomlLineScanState(scanState, line)
|
||||
if (newlineIdx === -1) {
|
||||
return -1
|
||||
}
|
||||
@@ -678,92 +681,6 @@ function isCompleteTableHeader(line: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
type TomlMultilineState = {
|
||||
basic: boolean
|
||||
literal: boolean
|
||||
}
|
||||
|
||||
type TomlMultilineMode = 'basic' | 'literal' | null
|
||||
|
||||
function isInsideTomlMultilineString(state: TomlMultilineState): boolean {
|
||||
return state.basic || state.literal
|
||||
}
|
||||
|
||||
function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState {
|
||||
let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
|
||||
let index = 0
|
||||
while (index < line.length) {
|
||||
if (mode === 'basic') {
|
||||
if (line[index] === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('"""', index)) {
|
||||
mode = null
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (mode === 'literal') {
|
||||
if (line.startsWith("'''", index)) {
|
||||
mode = null
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
const char = line[index]
|
||||
if (char === '#') {
|
||||
break
|
||||
}
|
||||
if (line.startsWith('"""', index)) {
|
||||
mode = 'basic'
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("'''", index)) {
|
||||
mode = 'literal'
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
index = skipTomlBasicString(line, index + 1)
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
index = skipTomlLiteralString(line, index + 1)
|
||||
continue
|
||||
}
|
||||
index++
|
||||
}
|
||||
return { basic: mode === 'basic', literal: mode === 'literal' }
|
||||
}
|
||||
|
||||
function skipTomlBasicString(line: string, startIndex: number): number {
|
||||
let index = startIndex
|
||||
while (index < line.length) {
|
||||
const char = line[index]
|
||||
if (char === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
return index + 1
|
||||
}
|
||||
index++
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function skipTomlLiteralString(line: string, startIndex: number): number {
|
||||
const endIndex = line.indexOf("'", startIndex)
|
||||
return endIndex === -1 ? line.length : endIndex + 1
|
||||
}
|
||||
|
||||
// Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a
|
||||
// half-written config.toml can brick a user's Codex install, so write to
|
||||
// tmp and rename. Random-suffix tmp name avoids cross-process races on
|
||||
@@ -830,14 +747,14 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
|
||||
// Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or
|
||||
// `'''...'''` multi-line string isn't mistaken for a real header.
|
||||
let cursor = 0
|
||||
let multilineState: TomlMultilineState = { basic: false, literal: false }
|
||||
let scanState = createTomlLineScanState()
|
||||
while (cursor < content.length) {
|
||||
const newlineIdx = content.indexOf('\n', cursor)
|
||||
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx
|
||||
const rawLine = content.slice(cursor, lineEnd)
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
|
||||
const key = isInsideTomlMultilineString(multilineState) ? null : parseHookStateHeaderKey(line)
|
||||
const key = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
|
||||
if (key !== null) {
|
||||
// Why: block ends at the next *real* header (multi-line aware).
|
||||
const after = content.slice(nextCursor)
|
||||
@@ -863,7 +780,7 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
|
||||
cursor = nextCursor
|
||||
continue
|
||||
}
|
||||
multilineState = updateTomlMultilineState(multilineState, line)
|
||||
scanState = updateTomlLineScanState(scanState, line)
|
||||
cursor = nextCursor
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookCommandConfig,
|
||||
@@ -120,17 +120,10 @@ function getManagedScriptPath(): string {
|
||||
return getSharedManagedScriptPath(getManagedScriptFileName())
|
||||
}
|
||||
|
||||
// Why: a Windows script path is cmd-safe when it holds only characters cmd.exe
|
||||
// passes through untouched (drive letter, backslash, dot, dash, underscore).
|
||||
// Spaces or cmd metacharacters force the encoded launcher; `cmd.exe /C` splits
|
||||
// bare `.cmd` paths at spaces before the script can run.
|
||||
const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
|
||||
|
||||
function getManagedCommand(scriptPath: string): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsCmdHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getSystemConfigPath(): string {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
|
||||
import { warmPwshAvailabilityCache } from '../pwsh'
|
||||
|
||||
export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } {
|
||||
@@ -84,6 +85,8 @@ async function main(): Promise<void> {
|
||||
if (process.send) {
|
||||
process.send({ type: 'ready' })
|
||||
}
|
||||
|
||||
warmWindowsConptyOnce()
|
||||
}
|
||||
|
||||
// Only auto-run when executed directly (not imported for testing)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user