Merge remote-tracking branch 'origin/main' into brennanb2025/tcc-local-build-bundle-id

# Conflicts:
#	src/main/ipc/notifications.ts
This commit is contained in:
Brennan Benson
2026-07-23 18:47:29 -07:00
1678 changed files with 23065 additions and 93039 deletions
-4
View File
@@ -117,7 +117,6 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
@@ -159,7 +158,6 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
linux:
@@ -187,7 +185,6 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
windows:
@@ -207,5 +204,4 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
+2 -2
View File
@@ -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.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/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.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
+164 -13
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-20",
"updatedAt": "2026-07-22",
"policy": {
"maturityLevels": [
"experimental",
@@ -2552,11 +2552,107 @@
],
"demotionRule": "Cannot promote while Windows E2E is flaky, silently skipped, or screenshot-only."
},
{
"id": "terminal-performance.cold-restore-replay-budget",
"title": "Daemon cold restore keeps replay work and retained payloads bounded",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "main-daemon-unit",
"surfaces": [
"startup restore",
"daemon history replay",
"sleep and hibernation restore",
"main-process memory"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["daemon", "wsl"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["daemon"],
"coverageNotes": "Deterministic main-process tests cover byte-bounded cache eviction and ACK release, one-at-a-time replay admission, a fixed per-turn replay budget within one large output record, UTF-16 boundary preservation, and checkpoint-only restore bypass while another replay is paused. The same HistoryReader path carries WSL context, but live WSL and cross-platform startup-scale runs remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/9971",
"https://github.com/stablyai/orca/pull/9990",
"https://github.com/stablyai/orca/issues/9441"
],
"invariant": "Cold restore must reproduce persisted terminal output while admitting at most one scratch-emulator replay, yielding after at most 64 Ki UTF-16 code units or 1,024 replay operations, keeping sticky restore payloads within 16 MiB, and allowing header-only checkpoint restores to bypass the replay queue.",
"oracle": "Pause setImmediate during two single-batch restores larger than one replay slice and require exactly one admitted yield at a time, preserved text across a surrogate-pair slice boundary, and a concurrent header-only checkpoint restore to finish without consuming a replay slot. Cache tests require least-recently-used eviction, rejection of one oversized payload, and zero retained cache bytes after renderer ACK.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cold-restore-payload-cache.test.ts src/main/daemon/history-reader.test.ts src/main/daemon/terminal-history-incremental-restore.test.ts src/main/daemon/hibernation-cold-restore-repro.test.ts src/main/daemon/daemon-pty-adapter.test.ts"
],
"testFiles": [
"src/main/daemon/cold-restore-payload-cache.test.ts",
"src/main/daemon/history-reader.test.ts",
"src/main/daemon/terminal-history-incremental-restore.test.ts",
"src/main/daemon/hibernation-cold-restore-repro.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts"
],
"assertionRefs": [
{
"file": "src/main/daemon/terminal-history-incremental-restore.test.ts",
"assertions": [
"large single-batch replays yield within the record, preserve a surrogate pair at the slice boundary, and admit only one scratch replay at a time",
"a header-only checkpoint restore completes while an unrelated incremental replay is paused"
]
},
{
"file": "src/main/daemon/cold-restore-payload-cache.test.ts",
"assertions": [
"least-recently-used payloads are evicted to the aggregate byte budget",
"one payload larger than the entire budget is not retained"
]
},
{
"file": "src/main/daemon/daemon-pty-adapter.test.ts",
"assertions": [
"StrictMode remount receives sticky cold-restore data until renderer ACK clears its retained bytes"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cold-restore-payload-cache.test.ts src/main/daemon/history-reader.test.ts src/main/daemon/terminal-history-incremental-restore.test.ts src/main/daemon/hibernation-cold-restore-repro.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
"result": "passed",
"durationSeconds": 5.68,
"summary": "Five focused files passed 150 tests, including deterministic single-record replay slicing, one-at-a-time admission, UTF-16 boundary preservation, header-only queue bypass, byte-bounded LRU eviction, and ACK cleanup."
}
],
"runtimeBudget": {
"p95Seconds": 15,
"scope": "focused main-process cold-restore unit contract"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The focused deterministic slice is new and has no CI or soak history yet."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The prior implementation had no yield inside one large batch and queued header-only restores behind the shared semaphore by inspection; an intentional pre-fix test run was not recorded."
},
"performanceBudget": {
"required": true,
"evidence": "Production admits one emulator replay globally, yields after a deterministic 64 Ki character or 1,024-operation budget even within one record, bypasses the semaphore for the common header-only final-checkpoint path, and caps sticky payloads at 16 MiB. No polling, subprocess, session inventory, or renderer wake loop is added."
},
"promotionCriteria": [
"Record an intentional-break red run for both the within-record yield and header-only bypass assertions.",
"Collect startup event-loop-delay evidence with dozens of near-cap histories on representative macOS, Windows, and Linux hardware.",
"Add live WSL restore evidence before claiming WSL coverage."
],
"knownGaps": [
"The log decoder and final headless snapshot serialization remain synchronous inside the one-at-a-time replay slot; the gate bounds replay writes, not every CPU phase.",
"No live Electron startup-scale run currently proves first-pane paint order or end-to-end restore latency with dozens of histories.",
"SSH, remote-runtime, relay, and mobile do not use this local daemon history reader and are unaffected."
],
"demotionRule": "Keep experimental or demote to protection none if output differs across replay slices, header-only restores consume a replay slot, retained payload bytes exceed the cap, or the focused gate flakes."
},
{
"id": "terminal-performance.no-hot-list-sessions",
"title": "Hot terminal interactions do not call global PTY session listing",
"maturity": "experimental",
"protection": "none",
"protection": "partial",
"owner": "terminal-performance",
"layer": "ipc-count-contract",
"surfaces": [
@@ -2580,19 +2676,74 @@
"wsl",
"remote-runtime"
],
"coveredPlatforms": [],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The targeted-hasPty product hardening and no-hot count assertions exist only on the pending reliability stack. It registers here with its owning split PR.",
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, and installs no interval. Broader terminal interaction coverage remains on the pending reliability stack.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7002",
"https://github.com/stablyai/orca/pull/6858"
"https://github.com/stablyai/orca/pull/6858",
"https://github.com/stablyai/orca/issues/9386",
"https://github.com/stablyai/orca/pull/9387"
],
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, and per-pane liveness paths must not call global pty:listSessions; they must use targeted per-PTY APIs or cached provider-owned state.",
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager status badges avoid pty:listSessions; targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
"commands": [],
"testFiles": [],
"assertionRefs": [],
"evidenceRuns": [],
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs; known-session reattach signals and steady closed time perform zero reads. Targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
],
"testFiles": [
"src/main/ipc/pty.test.ts",
"src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
"src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
"assertions": [
"the false-to-true workspace readiness transition performs one daemon inventory seed",
"a failed readiness seed surfaces an error and a later inventory refresh recovers",
"known-session reattach signals perform zero additional inventory reads",
"multiple unknown background spawn signals coalesce to one inventory read",
"spawn signals during a slow inventory read never overlap provider-wide scans and cause at most one required follow-up",
"unknown sessions that exit before reconciliation cancel their queued inventory read",
"unmount during a slow inventory read cannot schedule follow-up work",
"exit and out-of-order refresh races cannot resurrect stale sessions"
]
},
{
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"global inventory starts local and SSH provider listings concurrently"
]
},
{
"file": "src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
"assertions": [
"daemon inventory construction copies its source and preserves count parity",
"single and batch removals preserve unrelated sessions and no-op references"
]
},
{
"file": "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
"assertions": [
"the closed inventory hook installs no interval",
"the badge count comes from cached daemon inventory rather than wake-hint bindings"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
"result": "passed",
"durationSeconds": 4.3,
"summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "unit or focused Electron count gate"
@@ -2603,7 +2754,7 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover rather than its closed badge, and panes close only on authoritative false. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
},
"performanceBudget": {
"required": true,
@@ -2615,8 +2766,8 @@
"Run with enough preserved sessions/providers to make a broad listing observable."
],
"knownGaps": [
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
"Current command covers targeted visibility/first-input liveness, resize re-assertion on visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager session-poll avoidance, but not every hot interaction listed in the invariant.",
"Current commands cover Resource Manager readiness/lifecycle inventory counts; the broader targeted-liveness slice still lives on the pending fix-terminal-reliability stack.",
"Current coverage includes the closed Resource Manager's no-interval and known-reattach budgets, but not every hot interaction listed in the invariant.",
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero global listSessions calls."
],
"demotionRule": "Cannot promote if the test allows broad listing in any hot interaction path."
@@ -0,0 +1,342 @@
import { spawn } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts'
const strategy = process.argv[2]
if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) {
throw new Error(
'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs <baseline|candidate|explicit-cmd>'
)
}
if (process.platform !== 'win32') {
throw new Error('This reproduction requires a physical Windows host.')
}
const expectedArgs = [
'',
'two words',
'amp&ersand',
'pipe|value',
'less<value',
'greater>value',
'caret^value',
'trailing\\',
'two-trailing\\\\',
'(parentheses)',
'100%',
'%ORCA_ARG_TRAP%',
'bang!value',
'한글-λ'
]
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-'))
const reportedDir = join(tempRoot, 'Profile with spaces 한글')
const reportedCapturePath = join(reportedDir, 'capture.json')
const reportedPidPath = join(reportedDir, 'pids.json')
const reportedShimPath = join(reportedDir, 'claude fixture.cmd')
const reportedFixturePath = join(reportedDir, 'capture-child.cjs')
const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글')
const capturePath = join(fixtureDir, 'capture.json')
const pidPath = join(fixtureDir, 'pids.json')
const shimPath = join(fixtureDir, 'claude fixture.cmd')
const fixturePath = join(fixtureDir, 'capture-child.cjs')
const fixtureEnv = {
...process.env,
CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'),
ORCA_ARG_TRAP: 'EXPANDED_ARG',
ORCA_PATH_TRAP: 'EXPANDED_PATH',
ORCA_FIXTURE_CAPTURE: capturePath,
ORCA_FIXTURE_PIDS: pidPath,
ORCA_FIXTURE_NODE: process.execPath
}
const reportedEnv = {
...fixtureEnv,
CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'),
ORCA_FIXTURE_CAPTURE: reportedCapturePath,
ORCA_FIXTURE_PIDS: reportedPidPath
}
function quoteForCandidate(value) {
return `"${value.replace(/"/g, '""')}"`
}
function launch(args, command = shimPath, env = fixtureEnv) {
if (strategy === 'baseline') {
return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true })
}
if (strategy === 'candidate') {
return spawn(quoteForCandidate(command), args, {
cwd: tempRoot,
env,
shell: true,
windowsHide: true
})
}
const invocation = buildWindowsCommandInvocation(command, args)
return spawn(invocation.command, invocation.args, {
cwd: tempRoot,
env,
shell: false,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
windowsHide: true
})
}
function collect(child) {
return new Promise((resolve) => {
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk) => (stdout += chunk.toString()))
child.stderr?.on('data', (chunk) => (stderr += chunk.toString()))
child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message }))
child.on('close', (code) => resolve({ code, stdout, stderr, error: null }))
})
}
async function waitForFile(path, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
return JSON.parse(await readFile(path, 'utf8'))
} catch {
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
throw new Error(`Timed out waiting for fixture output: ${path}`)
}
async function taskExists(pid) {
const result = await collect(
spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], {
windowsHide: true
})
)
if (result.error || result.code !== 0) {
throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`)
}
return result.stdout.includes(`"${pid}"`)
}
async function killTree(pid) {
const result = await collect(
spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true })
)
if (result.error || result.code !== 0) {
throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`)
}
}
async function waitForTreeExit(pids, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
let alive = {}
do {
alive = Object.fromEntries(
await Promise.all(
Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)])
)
)
if (!Object.values(alive).some(Boolean)) {
return alive
}
await new Promise((resolve) => setTimeout(resolve, 50))
} while (Date.now() < deadline)
return alive
}
const results = {
strategy,
reportedPath: null,
pathMatrix: {},
argvMatrix: {},
hostilePathAndArgv: null,
error: null,
cancellation: null
}
const fixtureSource =
`const { spawn } = require('node:child_process')\n` +
`const { writeFileSync } = require('node:fs')\n` +
`if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` +
`if (process.argv[2] === '--linger') {\n` +
` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` +
` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` +
` setInterval(() => {}, 1000)\n` +
`} else {\n` +
` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` +
`}\n`
const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n'
let lingeringShellPid = null
try {
await mkdir(fixtureDir, { recursive: true })
await mkdir(reportedDir, { recursive: true })
await writeFile(fixturePath, fixtureSource, 'utf8')
await writeFile(shimPath, shimSource, 'utf8')
await writeFile(reportedFixturePath, fixtureSource, 'utf8')
await writeFile(reportedShimPath, shimSource, 'utf8')
const reportedArgs = ['auth', 'status', '--json']
const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv))
let reportedCapture = null
try {
reportedCapture = await waitForFile(reportedCapturePath, 1_000)
} catch {}
results.reportedPath = {
...reportedRun,
actual: reportedCapture,
expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR },
pass:
reportedRun.code === 0 &&
JSON.stringify(reportedCapture) ===
JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR })
}
for (const [name, segment] of Object.entries({
spaces: 'profile space',
ampersand: 'profile&name',
caret: 'profile^name',
parentheses: 'profile(name)',
percent: 'profile%ORCA_PATH_TRAP%',
bang: 'profile!name',
unicode: 'profile-한글-λ'
})) {
const directory = join(tempRoot, segment)
const captureFile = join(directory, 'capture.json')
const command = join(directory, 'claude fixture.cmd')
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8')
await writeFile(command, shimSource, 'utf8')
const env = {
...fixtureEnv,
CLAUDE_CONFIG_DIR: join(directory, 'config'),
ORCA_FIXTURE_CAPTURE: captureFile
}
const run = await collect(launch(reportedArgs, command, env))
let actual = null
try {
actual = await waitForFile(captureFile, 500)
} catch {}
results.pathMatrix[name] = {
code: run.code,
stderr: run.stderr.trim(),
actual: actual?.argv ?? null,
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs)
}
}
for (const [name, value] of Object.entries({
empty: '',
spaces: 'two words',
ampersand: 'amp&ersand',
pipe: 'pipe|value',
lessThan: 'less<value',
greaterThan: 'greater>value',
caret: 'caret^value',
trailingBackslash: 'trailing\\',
twoTrailingBackslashes: 'two-trailing\\\\',
parentheses: '(parentheses)',
percent: '%ORCA_ARG_TRAP%',
bang: 'bang!value',
unicode: '한글-λ'
})) {
const args = ['prefix', value, 'suffix']
const captureFile = join(reportedDir, `capture-${name}.json`)
const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile }
const run = await collect(launch(args, reportedShimPath, env))
let actual = null
try {
actual = await waitForFile(captureFile, 500)
} catch {}
results.argvMatrix[name] = {
code: run.code,
stderr: run.stderr.trim(),
actual: actual?.argv ?? null,
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args)
}
}
const argvRun = await collect(launch(expectedArgs))
let capture = null
try {
capture = await waitForFile(capturePath, 1_000)
} catch {}
results.hostilePathAndArgv = {
...argvRun,
actual: capture,
expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR },
pass:
argvRun.code === 0 &&
JSON.stringify(capture) ===
JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR })
}
results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv))
results.error.pass =
results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !')
const lingering = launch(['--linger'], reportedShimPath, reportedEnv)
lingeringShellPid = lingering.pid
const lingeringResult = collect(lingering)
try {
const pids = await waitForFile(reportedPidPath)
await killTree(lingering.pid)
const alive = await waitForTreeExit({
shell: lingering.pid,
child: pids.child,
grandchild: pids.grandchild
})
results.cancellation = {
shell: lingering.pid,
...pids,
alive,
pass: !Object.values(alive).some(Boolean)
}
} catch (error) {
if (await taskExists(lingering.pid)) {
await killTree(lingering.pid)
}
const launchResult = await Promise.race([
lingeringResult,
new Promise((resolve) =>
setTimeout(
() => resolve({ code: null, error: 'fixture did not exit after cleanup' }),
5_000
)
)
])
results.cancellation = {
shell: lingering.pid,
launchResult,
pass: false,
error: error instanceof Error ? error.message : String(error)
}
}
} finally {
if (lingeringShellPid && (await taskExists(lingeringShellPid))) {
await killTree(lingeringShellPid)
}
try {
let pids
try {
pids = JSON.parse(await readFile(reportedPidPath, 'utf8'))
} catch {
pids = JSON.parse(await readFile(pidPath, 'utf8'))
}
for (const pid of [pids.child, pids.grandchild]) {
if (await taskExists(pid)) {
await killTree(pid)
}
}
} catch {}
await rm(tempRoot, { recursive: true, force: true })
}
console.log(JSON.stringify(results, null, 2))
process.exitCode =
results.reportedPath?.pass &&
Object.values(results.pathMatrix).every((result) => result.pass) &&
Object.values(results.argvMatrix).every((result) => result.pass) &&
results.hostilePathAndArgv?.pass &&
results.error?.pass &&
results.cancellation?.pass
? 0
: 1
@@ -177,7 +177,7 @@ describe('computer-use e2e workflow', () => {
)
})
it('builds Electron main output and verifies the CLI before every computer-use e2e run', () => {
it('builds Electron main output before every computer-use e2e run', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
@@ -187,7 +187,6 @@ describe('computer-use e2e workflow', () => {
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const cliVerifyIndex = runs.indexOf('pnpm verify:cli-runtime')
const e2eIndexes = runs
.map((run, index) => (run.includes('test:e2e:computer') ? index : -1))
.filter((index) => index >= 0)
@@ -196,17 +195,10 @@ describe('computer-use e2e workflow', () => {
buildIndex,
`${jobName} should build out/main before computer e2e`
).toBeGreaterThanOrEqual(0)
expect(
cliVerifyIndex,
`${jobName} should verify the CLI after the main build`
).toBeGreaterThan(buildIndex)
for (const e2eIndex of e2eIndexes) {
expect(buildIndex, `${jobName} should build out/main before computer e2e`).toBeLessThan(
e2eIndex
)
expect(cliVerifyIndex, `${jobName} should verify the CLI before computer e2e`).toBeLessThan(
e2eIndex
)
}
}
})
-8
View File
@@ -47,14 +47,6 @@ describe('verifyPackageCliBin', () => {
})
})
it('rejects a compiled CLI with a missing runtime dependency', () => {
const { projectDir } = makeProjectWithCli(
'#!/usr/bin/env node\nrequire("./missing-runtime-dependency")\n'
)
expect(() => verifyPackageCliBin({ projectDir, runHelp: true })).toThrow()
})
it('rejects an empty package bin target', () => {
const { projectDir } = makeProjectWithCli('')
+6 -8
View File
@@ -4,9 +4,6 @@
"../src/cli/**/*",
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/agent-hook-file-comparison.ts",
"../src/main/agent-hooks/agent-hook-file-limits.ts",
"../src/main/agent-hooks/agent-hook-sftp-text-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
@@ -23,12 +20,13 @@
"../src/main/codex/codex-app-server-client.ts",
"../src/main/codex/codex-app-server-grant-bridge.ts",
"../src/main/codex/codex-app-server-grant-envelope.ts",
"../src/main/codex/codex-app-server-grant-json.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-config-settings-preservation.ts",
"../src/main/codex/codex-config-settings-removal.ts",
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-host-retry-deadlines.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
@@ -40,8 +38,10 @@
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/codex-wsl-reconciliation-generations.ts",
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
@@ -49,14 +49,12 @@
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/codex-accounts/wsl-codex-command.ts",
"../src/main/codex-cli/command.ts",
"../src/main/codex-cli/nvm-version-directory-discovery.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
"../src/main/generated-node-bounded-file-reader.ts",
"../src/main/grok/hook-service.ts",
"../src/main/devin/hook-settings.ts",
"../src/main/devin/hook-service.ts",
+1
View File
@@ -20,6 +20,7 @@ export default defineConfig({
'src/**/*.test.tsx',
'config/scripts/**/*.test.ts',
'config/scripts/**/*.test.mjs',
'tools/**/*.test.mjs',
'tests/e2e/**/*.unit.test.ts'
],
// Why: the full suite runs heavy TS transforms plus real git/http fixtures;
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 8.0m">
<title>downloads: 8.0m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 8.1m">
<title>downloads: 8.1m</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">8.0m</text>
<text x="93.5" y="14">8.0m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">8.1m</text>
<text x="93.5" y="14">8.1m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+59 -62
View File
@@ -86,10 +86,6 @@ import {
import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
serializeLastVisitedWorktreeRecord
} from '../../../../src/worktree/last-visited-worktree-repo'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
triggerMediumImpact,
@@ -231,8 +227,6 @@ import {
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { MobileSessionFileDocLifecycle } from '../../../../src/session/mobile-session-file-doc-lifecycle'
import { MobileSessionMarkdownDocLifecycle } from '../../../../src/session/mobile-session-markdown-doc-lifecycle'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
@@ -903,9 +897,7 @@ export default function SessionScreen() {
const tabLayoutsRef = useRef<Map<string, { x: number; width: number }>>(new Map())
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle())
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(new Map())
const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle())
const [diffComments, setDiffComments] = useState<DiffComment[]>([])
const diffCommentsRef = useRef<DiffComment[]>([])
const [diffCommentBusy, setDiffCommentBusy] = useState(false)
@@ -1729,8 +1721,6 @@ export default function SessionScreen() {
if (orphanedDraftTabs.length > 0) {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
markdownDocLifecycleRef.current.reconcile(nextTabs, setMarkdownDocs)
fileDocLifecycleRef.current.reconcile(nextTabs, setFileDocs)
sessionTabsRef.current = nextTabs
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
@@ -1848,7 +1838,8 @@ export default function SessionScreen() {
if (!client) {
return
}
await markdownDocLifecycleRef.current.load(tab, setMarkdownDocs, async () => {
setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
const response = await client.sendRequest('markdown.readTab', {
worktree: `id:${worktreeId}`,
tabId: tab.id
@@ -1861,16 +1852,19 @@ export default function SessionScreen() {
editable?: boolean
readOnlyReason?: string
}
return {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason
}
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason
})
)
return
}
if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
throw new Error((response as RpcFailure).error.message)
@@ -1888,12 +1882,24 @@ export default function SessionScreen() {
truncated: boolean
byteLength: number
}
return buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
tabIsDirty: tab.isDirty
})
})
setMarkdownDocs((prev) =>
new Map(prev).set(
tab.id,
buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
tabIsDirty: tab.isDirty
})
)
)
} catch {
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: "Couldn't load markdown"
})
)
}
},
[client, worktreeId]
)
@@ -1903,13 +1909,31 @@ export default function SessionScreen() {
if (!client) {
return
}
await fileDocLifecycleRef.current.load(tab, setFileDocs, () =>
resolveMobileFileTabDoc(client, {
setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
const doc = await resolveMobileFileTabDoc(client, {
worktreeId,
relativePath: tab.relativePath,
diffSource: tab.diffSource
})
)
setFileDocs((prev) => new Map(prev).set(tab.id, doc))
} catch (err) {
const message = err instanceof Error ? err.message : ''
const previewMessage =
message === 'binary_file'
? 'Binary preview unavailable'
: message === 'file_too_large'
? 'File too large for mobile preview'
: tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
? "Couldn't load diff preview"
: "Couldn't load file preview"
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: previewMessage
})
)
}
},
[client, worktreeId]
)
@@ -2224,9 +2248,6 @@ export default function SessionScreen() {
})
} finally {
markdownSaveInFlightRef.current.delete(tab.id)
if (markdownSaveSeqRef.current.get(tab.id) === saveSeq) {
markdownSaveSeqRef.current.delete(tab.id)
}
}
},
[client, markdownDocs, showToast, worktreeId]
@@ -2499,10 +2520,10 @@ export default function SessionScreen() {
useEffect(() => {
if (hostId && worktreeId) {
const serialized = serializeLastVisitedWorktreeRecord({ hostId, worktreeId })
if (serialized) {
void AsyncStorage.setItem(LAST_VISITED_WORKTREE_STORAGE_KEY, serialized)
}
void AsyncStorage.setItem(
'orca:last-visited-worktree',
JSON.stringify({ hostId, worktreeId })
)
}
}, [hostId, worktreeId])
@@ -2536,10 +2557,6 @@ export default function SessionScreen() {
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
markdownDocLifecycleRef.current.reset()
fileDocLifecycleRef.current.reset()
markdownSaveSeqRef.current.clear()
markdownSaveInFlightRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
@@ -2559,10 +2576,6 @@ export default function SessionScreen() {
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
markdownDocLifecycleRef.current.reset()
fileDocLifecycleRef.current.reset()
markdownSaveSeqRef.current.clear()
markdownSaveInFlightRef.current.clear()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
@@ -4096,23 +4109,7 @@ export default function SessionScreen() {
initializedHandlesRef.current.delete(terminalHandle)
clearTerminalLiveInputDefault(terminalHandle)
}
if (tab.type === 'file') {
fileDocLifecycleRef.current.close(tab.id, setFileDocs)
}
if (tab.type === 'markdown') {
markdownDocLifecycleRef.current.close(tab.id, (update) => {
setMarkdownDocs((current) => {
const next = update(current)
markdownDocsRef.current = next
return next
})
})
markdownSaveSeqRef.current.delete(tab.id)
markdownSaveInFlightRef.current.delete(tab.id)
}
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
sessionTabsRef.current = remainingTabs
setSessionTabs(remainingTabs)
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
if (activeSessionTabId === tab.id) {
+89 -87
View File
@@ -137,15 +137,9 @@ import {
clearMobileTaskCopyFeedbackTimer,
scheduleMobileTaskCopyFeedbackReset
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
import {
createMobileItemPrFileContentScope,
createMobileProjectPrFileContentScope,
useMobilePrFileContentCache
} from '../../../src/tasks/use-mobile-pr-file-content-cache'
import type {
BaseRefSearchResult,
GitHubOwnerRepo,
GitHubPRFileContents,
PersistedTrustedOrcaHooks,
SparsePreset,
TuiAgent
@@ -364,6 +358,13 @@ type GitHubDetailCheck = {
url?: string | null
}
type GitHubPRFileContents = {
original: string
modified: string
originalIsBinary: boolean
modifiedIsBinary: boolean
}
type DetailPayload =
| {
provider: 'github'
@@ -2262,6 +2263,8 @@ export default function MobileTasksScreen() {
const [itemReviewersDraft, setItemReviewersDraft] = useState('')
const [itemReplyDrafts, setItemReplyDrafts] = useState<Record<string, string>>({})
const [expandedPrFilePath, setExpandedPrFilePath] = useState<string | null>(null)
const [prFileContents, setPrFileContents] = useState<Record<string, GitHubPRFileContents>>({})
const [prFileLoadingPath, setPrFileLoadingPath] = useState<string | null>(null)
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState<Record<string, string>>({})
const [copiedLinkKey, setCopiedLinkKey] = useState<string | null>(null)
const copiedLinkResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -2599,23 +2602,6 @@ export default function MobileTasksScreen() {
() => (projectRowItem ? findProjectRowRepo(projectRowItem) : null),
[findProjectRowRepo, projectRowItem]
)
const itemPrFileContentScope = createMobileItemPrFileContentScope(actionItem, detailPayload)
const projectPrFileContentScope = createMobileProjectPrFileContentScope(
projectRowItem,
projectRowHostedRepo,
projectRowDetail,
projectRowItem ? projectRowGitHubRepository(projectRowItem, activeGitHubProjectHost) : null
)
const activePrFileContentScope = projectRowItem
? projectPrFileContentScope
: itemPrFileContentScope
const {
clear: clearPrFileContents,
contents: prFileContents,
load: loadPrFileContent,
loadingPath: prFileLoadingPath
} = useMobilePrFileContentCache(activePrFileContentScope)
const itemReviewerCandidates = useMemo(() => {
if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') {
return []
@@ -4155,7 +4141,8 @@ export default function MobileTasksScreen() {
setItemReviewersDraft('')
setItemReplyDrafts({})
setExpandedPrFilePath(null)
clearPrFileContents()
setPrFileContents({})
setPrFileLoadingPath(null)
setPrFileCommentDrafts({})
setExpandedResolvedCommentGroups(new Set())
return
@@ -4170,10 +4157,11 @@ export default function MobileTasksScreen() {
setItemReviewersDraft('')
setItemReplyDrafts({})
setExpandedPrFilePath(null)
clearPrFileContents()
setPrFileContents({})
setPrFileLoadingPath(null)
setPrFileCommentDrafts({})
setExpandedResolvedCommentGroups(new Set())
}, [actionItem, clearPrFileContents])
}, [actionItem])
useEffect(() => {
if (!detailPayload) {
@@ -4470,7 +4458,8 @@ export default function MobileTasksScreen() {
setProjectEditingCommentDraft('')
setProjectReviewersDraft('')
setExpandedPrFilePath(null)
clearPrFileContents()
setPrFileContents({})
setPrFileLoadingPath(null)
setPrFileCommentDrafts({})
setProjectFieldDrafts({})
return
@@ -4485,7 +4474,8 @@ export default function MobileTasksScreen() {
setProjectEditingCommentDraft('')
setProjectReviewersDraft('')
setExpandedPrFilePath(null)
clearPrFileContents()
setPrFileContents({})
setPrFileLoadingPath(null)
setPrFileCommentDrafts({})
setProjectFieldDrafts(
Object.fromEntries(
@@ -4589,7 +4579,6 @@ export default function MobileTasksScreen() {
}
}, [
activeGitHubProjectHost,
clearPrFileContents,
client,
githubProjectTable,
projectRowDetailRefreshSeq,
@@ -6560,6 +6549,9 @@ export default function MobileTasksScreen() {
return
}
setExpandedPrFilePath(file.path)
if (prFileContents[file.path]) {
return
}
const repo = findProjectRowRepo(row)
if (
!client ||
@@ -6568,45 +6560,49 @@ export default function MobileTasksScreen() {
!row.content.number ||
projectRowDetail?.provider !== 'github' ||
!projectRowDetail.headSha ||
!projectRowDetail.baseSha ||
!projectPrFileContentScope
!projectRowDetail.baseSha
) {
setProjectRowDetailError('Unable to load file contents for this pull request.')
return
}
await loadPrFileContent(
projectPrFileContentScope,
file,
async () => {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: projectRowDetail.headSha,
baseSha: projectRowDetail.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
return response.result
},
setProjectRowDetailError
)
setPrFileLoadingPath(file.path)
setProjectRowDetailError('')
try {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: projectRowDetail.headSha,
baseSha: projectRowDetail.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
setPrFileContents((current) => ({
...current,
[file.path]: response.result as GitHubPRFileContents
}))
} catch (err) {
setProjectRowDetailError(
err instanceof Error ? err.message : 'Failed to load file contents'
)
} finally {
setPrFileLoadingPath(null)
}
},
[
activeGitHubProjectHost,
client,
expandedPrFilePath,
findProjectRowRepo,
loadPrFileContent,
projectPrFileContentScope,
prFileContents,
projectRowDetail
]
)
@@ -7530,43 +7526,49 @@ export default function MobileTasksScreen() {
return
}
setExpandedPrFilePath(file.path)
if (prFileContents[file.path]) {
return
}
if (
!client ||
item.source.type !== 'pr' ||
detailPayload?.provider !== 'github' ||
!detailPayload.headSha ||
!detailPayload.baseSha ||
!itemPrFileContentScope
!detailPayload.baseSha
) {
setError('Unable to load file contents for this pull request.')
return
}
await loadPrFileContent(
itemPrFileContentScope,
file,
async () => {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${item.source.repoId}`,
prNumber: item.source.number,
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: detailPayload.headSha,
baseSha: detailPayload.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
return response.result
},
setError
)
setPrFileLoadingPath(file.path)
setError('')
try {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${item.source.repoId}`,
prNumber: item.source.number,
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: detailPayload.headSha,
baseSha: detailPayload.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
setPrFileContents((current) => ({
...current,
[file.path]: response.result as GitHubPRFileContents
}))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load file contents')
} finally {
setPrFileLoadingPath(null)
}
},
[client, detailPayload, expandedPrFilePath, itemPrFileContentScope, loadPrFileContent]
[client, detailPayload, expandedPrFilePath, prFileContents]
)
const addGitHubFileReviewComment = useCallback(
@@ -12320,7 +12322,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]!}
contents={prFileContents[file.path]}
commentDrafts={prFileCommentDrafts}
disabled={projectMutating}
onCommentDraftChange={(draftKey, next) =>
@@ -13266,7 +13268,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]!}
contents={prFileContents[file.path]}
commentDrafts={prFileCommentDrafts}
disabled={mutatingStatus}
onCommentDraftChange={(draftKey, next) =>
+4 -9
View File
@@ -27,10 +27,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord
} from '../src/worktree/last-visited-worktree-repo'
import type { RpcClient } from '../src/transport/rpc-client'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import {
@@ -398,14 +394,13 @@ export default function HomeScreen() {
router.replace(mobileOnboardingDestination(onboardingSteps))
}
})
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
if (stale || !raw) {
return
}
const record = readLastVisitedWorktreeRecord(raw)
if (record) {
setLastVisited(record)
}
try {
setLastVisited(JSON.parse(raw))
} catch {}
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
@@ -1,103 +0,0 @@
import { Buffer } from 'node:buffer'
export const PROCESS_LINE_MAX_BYTES = 64 * 1024
export const PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS = 64 * 1024
const TRUNCATED_LINE_SUFFIX = '… [line truncated]'
export function appendProcessOutputTail(
current,
line,
maxCodeUnits = PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS
) {
if (!Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 0) {
throw new RangeError('Process output tail limit must be a non-negative safe integer')
}
if (maxCodeUnits === 0) {
return ''
}
const appended = `${current}${line}\n`
return appended.length <= maxCodeUnits ? appended : appended.slice(-maxCodeUnits)
}
export function attachBoundedProcessLineReader(
stream,
onLine,
maxLineBytes = PROCESS_LINE_MAX_BYTES
) {
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) {
throw new RangeError('Process line limit must be a non-negative safe integer')
}
const retained = Buffer.allocUnsafe(maxLineBytes)
let retainedBytes = 0
let truncated = false
let swallowLineFeed = false
let closed = false
const append = (bytes, start, end) => {
if (start >= end) {
return
}
const available = maxLineBytes - retainedBytes
const copied = Math.min(available, end - start)
if (copied > 0) {
bytes.copy(retained, retainedBytes, start, start + copied)
retainedBytes += copied
}
truncated ||= copied < end - start
}
const emit = () => {
const line = retained.subarray(0, retainedBytes).toString('utf8')
onLine(truncated ? `${line}${TRUNCATED_LINE_SUFFIX}` : line)
retainedBytes = 0
truncated = false
}
const onData = (chunk) => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
let start = 0
if (swallowLineFeed) {
swallowLineFeed = false
if (bytes[0] === 0x0a) {
start = 1
}
}
for (let index = start; index < bytes.length; index += 1) {
const value = bytes[index]
if (value !== 0x0a && value !== 0x0d) {
continue
}
append(bytes, start, index)
emit()
if (value === 0x0d && index + 1 < bytes.length && bytes[index + 1] === 0x0a) {
index += 1
} else if (value === 0x0d && index + 1 === bytes.length) {
swallowLineFeed = true
}
start = index + 1
}
append(bytes, start, bytes.length)
}
const detach = () => {
if (closed) {
return
}
closed = true
stream.off('data', onData)
stream.off('end', onEnd)
}
const onEnd = () => {
if (retainedBytes > 0 || truncated) {
emit()
}
detach()
}
stream.on('data', onData)
stream.on('end', onEnd)
return detach
}
-36
View File
@@ -1,36 +0,0 @@
export async function responseBodyIncludesWithinLimit(response, needle, maxBytes) {
const declaredLength = response.headers.get('content-length')
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) {
await response.body?.cancel().catch(() => undefined)
return false
}
if (!response.body) {
return false
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let observedBytes = 0
let suffix = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
return `${suffix}${decoder.decode()}`.includes(needle)
}
observedBytes += value.byteLength
if (observedBytes > maxBytes) {
await reader.cancel().catch(() => undefined)
return false
}
const candidate = suffix + decoder.decode(value, { stream: true })
if (candidate.includes(needle)) {
await reader.cancel().catch(() => undefined)
return true
}
suffix = candidate.slice(-Math.max(0, needle.length - 1))
}
} finally {
reader.releaseLock()
}
}
@@ -3,10 +3,7 @@ import { mkdirSync, mkdtempSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import {
appendProcessOutputTail,
attachBoundedProcessLineReader
} from './bounded-process-line-reader.mjs'
import readline from 'node:readline'
function primaryLanIp(lanIpCandidates) {
return lanIpCandidates()[0] || '127.0.0.1'
@@ -69,15 +66,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
let stderr = ''
let resolved = false
let exited = false
let closeStdout = () => {}
let closeStderr = () => {}
let rl = null
let rlErr = null
const stop = () => {
if (!exited) {
child.kill('SIGTERM')
}
closeStdout()
closeStderr()
rl?.close()
rlErr?.close()
child.stdout?.destroy()
child.stderr?.destroy()
}
@@ -122,17 +119,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
reject(error)
}
closeStdout = attachBoundedProcessLineReader(child.stdout, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
rl = readline.createInterface({ input: child.stdout })
rl.on('line', (line) => {
output += line + '\n'
handleRuntimeLine(line, finishResolve)
})
closeStderr = attachBoundedProcessLineReader(child.stderr, (line) => {
if (!resolved) {
stderr = appendProcessOutputTail(stderr, line)
}
rlErr = readline.createInterface({ input: child.stderr })
rlErr.on('line', (line) => {
stderr += line + '\n'
})
child.on('error', (error) => {
+14 -43
View File
@@ -22,21 +22,16 @@ import os from 'node:os'
import { promisify } from 'node:util'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline'
import {
registerWorktreeForPairingRuntime,
startHeadlessPairingRuntime
} from './start-emulator-pairing-runtime.mjs'
import {
appendProcessOutputTail,
attachBoundedProcessLineReader
} from './bounded-process-line-reader.mjs'
import { responseBodyIncludesWithinLimit } from './bounded-response-body.mjs'
import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs'
const execFileAsync = promisify(execFile)
const DEFAULT_METRO_PORT = 8081
const METRO_PORT_SEARCH_LIMIT = 100
const METRO_STATUS_MAX_BYTES = 64 * 1024
// Parse CLI arguments
const args = process.argv.slice(2)
@@ -118,22 +113,6 @@ function logInfo(message) {
log(`[info] ${message}`, 'yellow')
}
function createBackpressuredLineWriter(source, target, color) {
let waitingForDrain = false
return (line) => {
const accepted = target.write(color + line + colors.reset + '\n')
if (accepted || waitingForDrain) {
return
}
waitingForDrain = true
source.pause()
target.once('drain', () => {
waitingForDrain = false
source.resume()
})
}
}
function assertIosSimulatorPlatform() {
if (process.platform !== 'darwin') {
throw new Error('iOS Simulator automation requires macOS and Xcode.')
@@ -403,10 +382,8 @@ async function startMetro(worktree) {
let url = null
let resolved = false
let exited = false
let closeStdout = () => {}
let closeStderr = () => {}
const writeStdoutLine = createBackpressuredLineWriter(metro.stdout, process.stdout, colors.dim)
const writeStderrLine = createBackpressuredLineWriter(metro.stderr, process.stderr, colors.red)
let rl = null
let rlErr = null
const metroResult = () => ({
process: metro,
@@ -414,8 +391,8 @@ async function startMetro(worktree) {
output,
isExited: () => exited,
closeOutput: () => {
closeStdout()
closeStderr()
rl?.close()
rlErr?.close()
metro.stdin?.destroy()
metro.stdout?.destroy()
metro.stderr?.destroy()
@@ -423,11 +400,10 @@ async function startMetro(worktree) {
})
// Parse Metro output for the development URL
closeStdout = attachBoundedProcessLineReader(metro.stdout, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
writeStdoutLine(line)
rl = readline.createInterface({ input: metro.stdout })
rl.on('line', (line) => {
output += line + '\n'
process.stdout.write(colors.dim + line + colors.reset + '\n')
// Look for "Waiting on" message from Metro
// When Metro says "Waiting on http://localhost:8081", we need to construct the dev-client URL
@@ -470,11 +446,10 @@ async function startMetro(worktree) {
})
// Also check stderr
closeStderr = attachBoundedProcessLineReader(metro.stderr, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
writeStderrLine(line)
rlErr = readline.createInterface({ input: metro.stderr })
rlErr.on('line', (line) => {
output += line + '\n'
process.stderr.write(colors.red + line + colors.reset + '\n')
})
metro.on('error', (error) => {
@@ -572,11 +547,7 @@ async function verifyMetro(url) {
try {
const response = await fetch(statusUrl, { signal: controller.signal })
return await responseBodyIncludesWithinLimit(
response,
'packager-status:running',
METRO_STATUS_MAX_BYTES
)
return (await response.text()).includes('packager-status:running')
} catch {
return false
} finally {
+61 -16
View File
@@ -1,4 +1,7 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
import { Buffer } from 'buffer'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
@@ -39,10 +42,6 @@ import {
getInitialMobileBrowserViewMode,
saveMobileBrowserViewMode
} from './mobile-browser-view-mode-state'
import {
createMobileBrowserFrameDataUri,
MobileBrowserFrameCache
} from './mobile-browser-frame-cache'
import {
clampBrowserZoomState,
computeBrowserFrameGeometry,
@@ -109,7 +108,14 @@ const TOUCH_CLICK_RADIUS_DIP = 14
const MIN_ZOOM = 1
const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
const browserFrameCache = new MobileBrowserFrameCache()
const BROWSER_FRAME_CACHE_LIMIT = 4
type BrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
type BrowserPageParams = {
worktree: string
@@ -137,7 +143,7 @@ export function MobileBrowserPane({
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId)
)
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode)
const cachedInitialFrame = browserFrameCache.peek(cacheKey)
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url))
const [addressFocused, setAddressFocused] = useState(false)
const [addressSyncState, setAddressSyncState] = useState({
@@ -223,7 +229,7 @@ export function MobileBrowserPane({
const subscription = AppState.addEventListener('change', (nextState) => {
const active = nextState === 'active'
if (!active) {
browserFrameCache.clearWorktree(worktreeId)
clearCachedBrowserFramesForWorktree(worktreeId)
}
setAppActive(active)
})
@@ -274,14 +280,8 @@ export function MobileBrowserPane({
frameMetadataRef.current = frame.metadata
setFrameMetadata(frame.metadata)
}
const nextFrameUri = createMobileBrowserFrameDataUri(frame)
if (!nextFrameUri) {
busyRef.current = false
setBusy(false)
setError('Browser frame is too large to display safely.')
return
}
browserFrameCache.set(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
const nextFrameUri = createBrowserFrameDataUri(frame)
cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
if (!frameMountedRef.current) {
frameUriRef.current = nextFrameUri
frameMountedRef.current = true
@@ -389,7 +389,7 @@ export function MobileBrowserPane({
const sameStream = Boolean(cacheKey) && lastStreamCacheKeyRef.current === cacheKey
lastStreamCacheKeyRef.current = cacheKey
if (!sameStream || !frameUriRef.current) {
const cachedFrame = browserFrameCache.get(cacheKey)
const cachedFrame = getCachedBrowserFrame(cacheKey)
if (cachedFrame) {
frameUriRef.current = cachedFrame.uri
frameMountedRef.current = true
@@ -1303,6 +1303,10 @@ function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
function makeBrowserFrameCacheKey(
worktreeId: string,
browserPageId: string | null,
@@ -1311,6 +1315,47 @@ function makeBrowserFrameCacheKey(
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
}
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of browserFrameCache.keys()) {
if (key.startsWith(prefix)) {
browserFrameCache.delete(key)
}
}
}
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
if (!cacheKey) {
return null
}
const cached = browserFrameCache.get(cacheKey)
if (!cached) {
return null
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, cached)
return cached
}
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
}
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
if (!cacheKey) {
return
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, entry)
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
const oldestKey = browserFrameCache.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
browserFrameCache.delete(oldestKey)
}
}
function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
@@ -1,68 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
BrowserScreencastOpcode,
type BrowserScreencastFrame
} from '../transport/browser-screencast-protocol'
import {
createMobileBrowserFrameDataUri,
MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES,
MobileBrowserFrameCache
} from './mobile-browser-frame-cache'
function entry(uri: string) {
return { uri, metadata: {} }
}
function frame(imageBytes: number): BrowserScreencastFrame {
return {
opcode: BrowserScreencastOpcode.Frame,
seq: 1,
format: 'jpeg',
metadata: {},
image: new Uint8Array(imageBytes)
}
}
describe('MobileBrowserFrameCache', () => {
it('preserves LRU behavior below the count and retained-character limits', () => {
const cache = new MobileBrowserFrameCache(2, 100)
cache.set('first', entry('frame-1'))
cache.set('second', entry('frame-2'))
expect(cache.get('first')).toEqual(entry('frame-1'))
cache.set('third', entry('frame-3'))
expect(cache.peek('second')).toBeNull()
expect(cache.evidence().keysOldestFirst).toEqual(['first', 'third'])
})
it('accepts the exact aggregate budget and evicts oldest at one character over', () => {
const cache = new MobileBrowserFrameCache(4, 20)
expect(cache.set('a', entry('x'.repeat(9)))).toBe(true)
expect(cache.set('b', entry('x'.repeat(9)))).toBe(true)
expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 20 })
expect(cache.set('c', entry(''))).toBe(true)
expect(cache.peek('a')).toBeNull()
expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 11 })
})
it('does not retain one entry larger than the full budget', () => {
const cache = new MobileBrowserFrameCache(4, 10)
expect(cache.set('key', entry('x'.repeat(8)))).toBe(false)
expect(cache.evidence()).toEqual({
entryCount: 0,
retainedCharacters: 0,
keysOldestFirst: []
})
})
})
describe('createMobileBrowserFrameDataUri', () => {
it('accepts the exact image-byte limit and rejects one byte over before base64 expansion', () => {
expect(MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES).toBe(8 * 1024 * 1024)
expect(createMobileBrowserFrameDataUri(frame(4), 4)).toBe('data:image/jpeg;base64,AAAAAA==')
expect(createMobileBrowserFrameDataUri(frame(5), 4)).toBeNull()
})
})
@@ -1,134 +0,0 @@
import { Buffer } from 'buffer'
import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
export const MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024
export const MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES = 4
export const MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS = 16 * 1024 * 1024
export type MobileBrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
export type MobileBrowserFrameCacheEvidence = {
entryCount: number
retainedCharacters: number
keysOldestFirst: string[]
}
type RetainedFrame = {
entry: MobileBrowserFrameCacheEntry
retainedCharacters: number
}
export class MobileBrowserFrameCache {
private readonly entries = new Map<string, RetainedFrame>()
private retainedCharacters = 0
constructor(
private readonly maxEntries = MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES,
private readonly maxRetainedCharacters = MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS
) {
if (
!Number.isInteger(maxEntries) ||
maxEntries < 1 ||
!Number.isSafeInteger(maxRetainedCharacters) ||
maxRetainedCharacters < 1
) {
throw new Error('Mobile browser frame cache limits must be positive integers')
}
}
get(key: string | null): MobileBrowserFrameCacheEntry | null {
if (!key) {
return null
}
const retained = this.entries.get(key)
if (!retained) {
return null
}
this.entries.delete(key)
this.entries.set(key, retained)
return retained.entry
}
peek(key: string | null): MobileBrowserFrameCacheEntry | null {
return key ? (this.entries.get(key)?.entry ?? null) : null
}
set(key: string | null, entry: MobileBrowserFrameCacheEntry): boolean {
if (!key) {
return false
}
const retainedCharacters = key.length + entry.uri.length
const previous = this.entries.get(key)
if (previous) {
this.retainedCharacters -= previous.retainedCharacters
this.entries.delete(key)
}
if (retainedCharacters > this.maxRetainedCharacters) {
return false
}
this.entries.set(key, { entry, retainedCharacters })
this.retainedCharacters += retainedCharacters
this.evictOverflow()
return this.entries.has(key)
}
clearWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
this.delete(key)
}
}
}
clear(): void {
this.entries.clear()
this.retainedCharacters = 0
}
evidence(): MobileBrowserFrameCacheEvidence {
return {
entryCount: this.entries.size,
retainedCharacters: this.retainedCharacters,
keysOldestFirst: [...this.entries.keys()]
}
}
private delete(key: string): void {
const retained = this.entries.get(key)
if (!retained) {
return
}
this.retainedCharacters -= retained.retainedCharacters
this.entries.delete(key)
}
private evictOverflow(): void {
while (
this.entries.size > this.maxEntries ||
this.retainedCharacters > this.maxRetainedCharacters
) {
const oldestKey = this.entries.keys().next().value
if (typeof oldestKey !== 'string') {
return
}
this.delete(oldestKey)
}
}
}
export function createMobileBrowserFrameDataUri(
frame: BrowserScreencastFrame,
maxImageBytes = MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES
): string | null {
if (frame.image.byteLength > maxImageBytes) {
return null
}
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
@@ -1,7 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS,
BROWSER_VIEW_MODE_STATE_LIMIT,
clearMobileBrowserViewModeState,
getInitialMobileBrowserViewMode,
saveMobileBrowserViewMode
@@ -24,28 +22,4 @@ describe('mobile browser view mode state', () => {
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2')).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree-2', 'page-1')).toBe('web')
})
it('retains the exact LRU count and evicts the oldest page at one over', () => {
for (let index = 0; index < BROWSER_VIEW_MODE_STATE_LIMIT; index += 1) {
saveMobileBrowserViewMode('worktree', `page-${index}`, 'mobile')
}
expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('mobile')
saveMobileBrowserViewMode('worktree', 'one-over', 'mobile')
expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree', 'one-over')).toBe('mobile')
})
it('accepts the exact page-key character limit and rejects one over', () => {
const worktreeId = 'w'
const exactPageId = 'p'.repeat(BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS - 2)
const oversizedPageId = `${exactPageId}p`
saveMobileBrowserViewMode(worktreeId, exactPageId, 'mobile')
saveMobileBrowserViewMode(worktreeId, oversizedPageId, 'mobile')
expect(getInitialMobileBrowserViewMode(worktreeId, exactPageId)).toBe('mobile')
expect(getInitialMobileBrowserViewMode(worktreeId, oversizedPageId)).toBe('web')
})
})
@@ -1,7 +1,6 @@
import type { MobileBrowserViewMode } from './browser-screencast-request'
export const BROWSER_VIEW_MODE_STATE_LIMIT = 40
export const BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS = 4_096
const BROWSER_VIEW_MODE_STATE_LIMIT = 40
const browserViewModeByPageKey = new Map<string, MobileBrowserViewMode>()
export function getInitialMobileBrowserViewMode(
@@ -43,9 +42,5 @@ function makeBrowserViewModePageKey(
worktreeId: string,
browserPageId: string | null
): string | null {
if (!browserPageId) {
return null
}
const key = `${worktreeId}:${browserPageId}`
return key.length <= BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS ? key : null
return browserPageId ? `${worktreeId}:${browserPageId}` : null
}
-80
View File
@@ -1,80 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
HOME_SNAPSHOT_MAX_SERIALIZED_BYTES,
loadHomeSnapshot,
resetHomeSnapshotCacheForTests,
saveHomeSnapshot,
type HomeSnapshot
} from './home-snapshot-cache'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn()
}
}))
function snapshotWithSerializedBytes(serializedBytes: number): HomeSnapshot {
const base = {
worktreeInfo: {},
accountsByHost: {},
savedAt: 1,
padding: ''
}
const baseBytes = JSON.stringify(base).length
return {
...base,
padding: 'x'.repeat(serializedBytes - baseBytes)
} as unknown as HomeSnapshot
}
describe('home snapshot cache', () => {
beforeEach(() => {
vi.useFakeTimers()
resetHomeSnapshotCacheForTests()
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
resetHomeSnapshotCacheForTests()
vi.useRealTimers()
})
it('persists and reloads normal snapshots unchanged', async () => {
const snapshot = snapshotWithSerializedBytes(128)
saveHomeSnapshot(snapshot)
await vi.advanceTimersByTimeAsync(250)
const raw = vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]
expect(raw).toBe(JSON.stringify(snapshot))
resetHomeSnapshotCacheForTests()
vi.mocked(AsyncStorage.getItem).mockResolvedValue(raw ?? null)
await expect(loadHomeSnapshot()).resolves.toEqual(snapshot)
})
it('accepts the exact serialized-byte cap and rejects one byte over', async () => {
const exact = snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
saveHomeSnapshot(exact)
await vi.advanceTimersByTimeAsync(250)
expect(AsyncStorage.setItem).toHaveBeenCalledOnce()
expect(vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]).toHaveLength(
HOME_SNAPSHOT_MAX_SERIALIZED_BYTES
)
vi.mocked(AsyncStorage.setItem).mockClear()
saveHomeSnapshot(snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES + 1))
await vi.advanceTimersByTimeAsync(250)
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
})
it('rejects an oversized durable payload before JSON parsing', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
`"${'x'.repeat(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)}"`
)
await expect(loadHomeSnapshot()).resolves.toBeNull()
})
})
+1 -50
View File
@@ -5,10 +5,8 @@
// WebSocket reconnects and the first responses come back.
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AccountsSnapshot } from '../components/AccountUsage'
import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
const STORAGE_KEY = 'orca:home-snapshot:v1'
export const HOME_SNAPSHOT_MAX_SERIALIZED_BYTES = 2 * 1024 * 1024
type WorktreeSummary = {
worktreeId: string
@@ -44,12 +42,6 @@ export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
if (!raw) {
return null
}
if (
raw.length > HOME_SNAPSHOT_MAX_SERIALIZED_BYTES ||
utf8ByteLengthExceeds(raw, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
) {
return null
}
const parsed = JSON.parse(raw) as HomeSnapshot
if (
typeof parsed !== 'object' ||
@@ -69,53 +61,12 @@ export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
// Why: throttle writes so a flurry of streamed account-snapshot updates
// (one per provider fetch finishing) doesn't hammer AsyncStorage.
export function saveHomeSnapshot(snapshot: HomeSnapshot): void {
let serialized: string
try {
serialized = stringifyMobileOutboundJson(snapshot, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
} catch {
return
}
memoryCache = snapshot
if (writeTimer) {
clearTimeout(writeTimer)
}
writeTimer = setTimeout(() => {
writeTimer = null
void AsyncStorage.setItem(STORAGE_KEY, serialized).catch(() => {})
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {})
}, 250)
}
function utf8ByteLengthExceeds(value: string, limit: number): boolean {
let bytes = 0
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x7f) {
bytes += 1
} else if (code <= 0x7ff) {
bytes += 2
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index += 1
} else {
bytes += 3
}
} else {
bytes += 3
}
if (bytes > limit) {
return true
}
}
return false
}
/** Test-only: clear the process-lifetime snapshot and delayed write. */
export function resetHomeSnapshotCacheForTests(): void {
memoryCache = null
if (writeTimer) {
clearTimeout(writeTimer)
writeTimer = null
}
}
-50
View File
@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest'
import { MobileRpcListCache } from './mobile-rpc-list-cache'
describe('MobileRpcListCache', () => {
it('preserves values below the limits and expires them at the existing age boundary', () => {
const cache = new MobileRpcListCache(100, 2, 3, 100)
const values = [{ id: 'repo' }]
expect(cache.set('host', values, 1_000)).toBe(true)
expect(cache.get('host', 1_100)).toBe(values)
expect(cache.get('host', 1_101)).toBeNull()
})
it('accepts the exact item cap and rejects one over', () => {
const cache = new MobileRpcListCache(100, 2, 3, 1_000)
const exact = [1, 2, 3]
expect(cache.set('host', exact)).toBe(true)
expect(cache.get('host')).toBe(exact)
expect(cache.set('host', [...exact, 4])).toBe(false)
expect(cache.get('host')).toBeNull()
})
it('accepts the exact aggregate byte budget and evicts oldest at one over', () => {
const probe = new MobileRpcListCache(100, 3, 3, 1_000)
probe.set('first', ['a'])
const firstBytes = probe.evidence().retainedBytes
probe.clear()
const cache = new MobileRpcListCache(100, 3, 3, firstBytes * 2)
expect(cache.set('first', ['a'])).toBe(true)
expect(cache.set('other', ['a'])).toBe(true)
expect(cache.evidence().retainedBytes).toBe(firstBytes * 2)
expect(cache.set('x', [])).toBe(true)
expect(cache.get('first')).toBeNull()
expect(cache.evidence().retainedBytes).toBeLessThanOrEqual(firstBytes * 2)
})
it('rejects a single payload larger than the full byte budget', () => {
const cache = new MobileRpcListCache(100, 2, 3, 20)
expect(cache.set('host', ['a'.repeat(100)])).toBe(false)
expect(cache.evidence()).toEqual({
entryCount: 0,
retainedBytes: 0,
keysOldestFirst: []
})
})
})
-126
View File
@@ -1,126 +0,0 @@
import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
export type MobileRpcListCacheEvidence = {
entryCount: number
retainedBytes: number
keysOldestFirst: string[]
}
type CacheEntry = {
values: unknown[]
at: number
retainedBytes: number
}
export class MobileRpcListCache {
private readonly entries = new Map<string, CacheEntry>()
private retainedBytes = 0
constructor(
private readonly maxAgeMs: number,
private readonly maxEntries: number,
private readonly maxItemsPerEntry: number,
private readonly maxRetainedBytes: number
) {
if (
!Number.isFinite(maxAgeMs) ||
maxAgeMs < 0 ||
!Number.isInteger(maxEntries) ||
maxEntries < 1 ||
!Number.isInteger(maxItemsPerEntry) ||
maxItemsPerEntry < 1 ||
!Number.isSafeInteger(maxRetainedBytes) ||
maxRetainedBytes < 1
) {
throw new Error('Mobile RPC list cache limits must be positive')
}
}
set(key: string, values: unknown[], now = Date.now()): boolean {
this.delete(key)
if (values.length > this.maxItemsPerEntry) {
return false
}
let serialized: string
try {
serialized = stringifyMobileOutboundJson({ key, values }, this.maxRetainedBytes)
} catch {
return false
}
const retainedBytes = utf8ByteLength(serialized)
if (retainedBytes > this.maxRetainedBytes) {
return false
}
this.entries.set(key, { values, at: now, retainedBytes })
this.retainedBytes += retainedBytes
this.evictOverflow()
return this.entries.has(key)
}
get(key: string, now = Date.now()): unknown[] | null {
const entry = this.entries.get(key)
if (!entry) {
return null
}
if (now - entry.at > this.maxAgeMs) {
this.delete(key)
return null
}
return entry.values
}
clear(): void {
this.entries.clear()
this.retainedBytes = 0
}
evidence(): MobileRpcListCacheEvidence {
return {
entryCount: this.entries.size,
retainedBytes: this.retainedBytes,
keysOldestFirst: [...this.entries.keys()]
}
}
private delete(key: string): void {
const entry = this.entries.get(key)
if (!entry) {
return
}
this.retainedBytes -= entry.retainedBytes
this.entries.delete(key)
}
private evictOverflow(): void {
while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxRetainedBytes) {
const oldestKey = this.entries.keys().next().value
if (typeof oldestKey !== 'string') {
return
}
this.delete(oldestKey)
}
}
}
function utf8ByteLength(value: string): number {
let bytes = 0
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x7f) {
bytes += 1
} else if (code <= 0x7ff) {
bytes += 2
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index += 1
} else {
bytes += 3
}
} else {
bytes += 3
}
}
return bytes
}
+2 -20
View File
@@ -1,17 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
getCachedRepos,
MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
resetRepoCacheForTests,
setCachedRepos
} from './repo-cache'
import { getCachedRepos, setCachedRepos } from './repo-cache'
describe('repo cache', () => {
beforeEach(() => {
resetRepoCacheForTests()
})
it('returns recent host-scoped repos', () => {
const repos = [{ id: 'repo-1' }]
@@ -32,13 +23,4 @@ describe('repo cache', () => {
vi.useRealTimers()
}
})
it('retains the exact per-host item cap and rejects one over', () => {
const exact = Array.from({ length: MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST }, () => null)
setCachedRepos('host', exact)
expect(getCachedRepos('host')).toBe(exact)
setCachedRepos('host', [...exact, null])
expect(getCachedRepos('host')).toBeNull()
})
})
+24 -18
View File
@@ -2,29 +2,35 @@
// host-scoped cache lets workspace creation open from the last known list while
// a fresh repo.list refresh happens in the background.
import { MobileRpcListCache } from './mobile-rpc-list-cache'
type CachedRepos = {
repos: unknown[]
at: number
}
const cache = new Map<string, CachedRepos>()
const MAX_AGE_MS = 60_000
export const MOBILE_REPO_CACHE_MAX_ENTRIES = 20
export const MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST = 10_000
export const MOBILE_REPO_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
const cache = new MobileRpcListCache(
MAX_AGE_MS,
MOBILE_REPO_CACHE_MAX_ENTRIES,
MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
MOBILE_REPO_CACHE_MAX_RETAINED_BYTES
)
const MAX_ENTRIES = 20
export function setCachedRepos(hostId: string, repos: unknown[]): void {
cache.set(hostId, repos)
cache.delete(hostId)
cache.set(hostId, { repos, at: Date.now() })
if (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value
if (oldest) {
cache.delete(oldest)
}
}
}
export function getCachedRepos(hostId: string): unknown[] | null {
return cache.get(hostId)
}
/** Test-only: clear process-lifetime cache state between cases. */
export function resetRepoCacheForTests(): void {
cache.clear()
const entry = cache.get(hostId)
if (!entry) {
return null
}
if (Date.now() - entry.at > MAX_AGE_MS) {
cache.delete(hostId)
return null
}
return entry.repos
}
+2 -20
View File
@@ -1,19 +1,10 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
getCachedWorktrees,
MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
resetWorktreeCacheForTests,
setCachedWorktrees
} from './worktree-cache'
import { describe, expect, it } from 'vitest'
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
// Why: AC #8498 guarantees a reconnect refetch writes through the
// same cache path the host detail screen seeds from, so a reconnect can't
// serve a stale snapshot. This unit pins the write-through contract.
describe('worktree-cache write-through', () => {
beforeEach(() => {
resetWorktreeCacheForTests()
})
it('returns the most-recently written snapshot, not a stale one', () => {
const hostId = 'host-write-through'
const stale = [{ worktreeId: 'a', name: 'stale' }]
@@ -49,13 +40,4 @@ describe('worktree-cache write-through', () => {
// A fresh screen mount reads the cache — must see the connected set.
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
})
it('retains the exact per-host item cap and rejects one over', () => {
const exact = Array.from({ length: MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST }, () => null)
setCachedWorktrees('host', exact)
expect(getCachedWorktrees('host')).toBe(exact)
setCachedWorktrees('host', [...exact, null])
expect(getCachedWorktrees('host')).toBeNull()
})
})
+27 -18
View File
@@ -2,29 +2,38 @@
// so the host detail page can render instantly on navigation instead of
// waiting for a fresh RPC connection + fetch cycle.
import { MobileRpcListCache } from './mobile-rpc-list-cache'
type CachedWorktrees = {
worktrees: unknown[]
at: number
}
const cache = new Map<string, CachedWorktrees>()
const MAX_AGE_MS = 30_000
export const MOBILE_WORKTREE_CACHE_MAX_ENTRIES = 20
export const MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST = 10_000
export const MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
const cache = new MobileRpcListCache(
MAX_AGE_MS,
MOBILE_WORKTREE_CACHE_MAX_ENTRIES,
MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES
)
const MAX_ENTRIES = 20
export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
cache.set(hostId, worktrees)
// Why: Map.set on an existing key does not move it to the end of iteration
// order. Delete first so the re-inserted key becomes the newest entry,
// giving us true LRU eviction when the cap is hit.
cache.delete(hostId)
cache.set(hostId, { worktrees, at: Date.now() })
if (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value
if (oldest) {
cache.delete(oldest)
}
}
}
export function getCachedWorktrees(hostId: string): unknown[] | null {
return cache.get(hostId)
}
/** Test-only: clear process-lifetime cache state between cases. */
export function resetWorktreeCacheForTests(): void {
cache.clear()
const entry = cache.get(hostId)
if (!entry) {
return null
}
if (Date.now() - entry.at > MAX_AGE_MS) {
cache.delete(hostId)
return null
}
return entry.worktrees
}
+24 -15
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
@@ -10,19 +11,15 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
import {
CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS,
CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS,
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../terminal/custom-accessory-key-store'
export {
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../terminal/custom-accessory-key-store'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
export type CustomKey = {
id: string
label: string
bytes: string
enter: boolean
}
type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro'
@@ -67,6 +64,19 @@ type Props = {
onManageShortcuts?: () => void
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
try {
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
return raw ? (JSON.parse(raw) as CustomKey[]) : []
} catch {
return []
}
}
export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
}
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
const [step, setStep] = useState<Step>('choose-type')
const [shortcutKey, setShortcutKey] = useState('c')
@@ -94,7 +104,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
async (key: Omit<CustomKey, 'id'>) => {
const existing = await loadCustomKeys()
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
const updated = await saveCustomKeys([...existing, newKey])
const updated = [...existing, newKey]
await saveCustomKeys(updated)
onKeysChanged(updated)
onClose()
},
@@ -361,7 +372,6 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
maxLength={CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS}
/>
<Text style={styles.fieldLabel}>Command</Text>
<TextInput
@@ -372,7 +382,6 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
maxLength={CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS - 1}
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Press Enter</Text>
@@ -117,7 +117,7 @@ export function TerminalShortcutSettings({
pendingCustomKeysWritesRef.current += 1
customKeysWriteChainRef.current = customKeysWriteChainRef.current
.catch(() => {})
.then(() => saveCustomKeys(next).then(() => undefined))
.then(() => saveCustomKeys(next))
.catch(() => {})
.finally(() => {
pendingCustomKeysWritesRef.current -= 1
@@ -167,10 +167,7 @@ function TableBlock({
block: Extract<MarkdownBlock, { kind: 'table' }>
base: number
}) {
let columnCount = Math.max(block.headers.length, 1)
for (const row of block.rows) {
columnCount = Math.max(columnCount, row.length)
}
const columnCount = Math.max(block.headers.length, ...block.rows.map((r) => r.length), 1)
const columns = Array.from({ length: columnCount }, (_, c) => c)
return (
<ScrollView
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { COMMENT_MARKDOWN_LIMITS, parseInline, parseMarkdownBlocks } from './markdown-blocks'
import { parseInline, parseMarkdownBlocks } from './markdown-blocks'
describe('parseMarkdownBlocks', () => {
it('classifies headings, fenced code, quotes, lists, hr, and paragraphs', () => {
@@ -100,42 +100,6 @@ describe('parseMarkdownBlocks tables', () => {
])
})
it('accepts the exact table column limit and falls back one column later', () => {
const header = Array.from({ length: COMMENT_MARKDOWN_LIMITS.tableColumns }, (_, index) =>
String(index)
).join('|')
const delimiter = Array.from(
{ length: COMMENT_MARKDOWN_LIMITS.tableColumns },
() => '---'
).join('|')
expect(parseMarkdownBlocks(`${header}\n${delimiter}`)[0]).toMatchObject({
kind: 'table',
headers: { length: COMMENT_MARKDOWN_LIMITS.tableColumns }
})
expect(parseMarkdownBlocks(`${header}|extra\n${delimiter}|---`)[0]).toMatchObject({
kind: 'paragraph'
})
})
it('falls back to a bounded preview before splitting oversized input', () => {
const exact = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits)
const content = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits + 1)
const [block] = parseMarkdownBlocks(content)
expect(parseMarkdownBlocks(exact)).toEqual([{ kind: 'paragraph', text: exact }])
expect(block).toMatchObject({ kind: 'paragraph' })
expect(block?.kind === 'paragraph' ? block.text.length : 0).toBeLessThan(
COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits + 100
)
})
it('checks normalized break tags before allocating line arrays', () => {
const content = '<br>'.repeat(COMMENT_MARKDOWN_LIMITS.lines + 1)
expect(parseMarkdownBlocks(content)[0]).toMatchObject({ kind: 'paragraph' })
})
it('reads per-column alignment from the delimiter row', () => {
const md = ['| L | C | R |', '| :--- | :---: | ---: |', '| a | b | c |'].join('\n')
const block = parseMarkdownBlocks(md)[0]
@@ -40,20 +40,6 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/
const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i
const SUMMARY = /<summary\b[^>]*>([\s\S]*?)<\/summary>/i
export const COMMENT_MARKDOWN_LIMITS = {
sourceCodeUnits: 1024 * 1024,
lines: 10_000,
detailsDepth: 64,
tableColumns: 256,
tableRows: 2000,
tableCells: 50_000,
fallbackCodeUnits: 64 * 1024
} as const
const LIMIT_FALLBACK_SUFFIX = '\n\n[Comment preview truncated for safety]'
class CommentMarkdownLimitError extends Error {}
// Removes residual HTML tags from rendered text so stray <b>/<kbd>/<sub> etc. don't
// show literally. Conservative: only matches `<tag ...>` / `</tag>` shapes, so a bare
// "a < b" in prose is left alone.
@@ -62,56 +48,9 @@ export function stripHtmlTags(text: string): string {
}
export function parseMarkdownBlocks(content: string): MarkdownBlock[] {
if (!isCommentMarkdownWithinPreparseLimits(content)) {
return commentMarkdownLimitFallback(content)
}
// Drop HTML comments and normalize <br> before block parsing.
const cleaned = content.replace(/<!--[\s\S]*?-->/g, '').replace(/<br\s*\/?>/gi, '\n')
if (!isCommentMarkdownWithinPreparseLimits(cleaned)) {
return commentMarkdownLimitFallback(content)
}
try {
return parseSegment(cleaned)
} catch (error) {
if (error instanceof CommentMarkdownLimitError) {
return commentMarkdownLimitFallback(content)
}
throw error
}
}
function isCommentMarkdownWithinPreparseLimits(content: string): boolean {
if (content.length > COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) {
return false
}
let lines = 1
let detailsDepth = 0
const detailsPattern = /<\/?details\b/gi
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) !== 10) {
continue
}
lines += 1
if (lines > COMMENT_MARKDOWN_LIMITS.lines) {
return false
}
}
for (const match of content.matchAll(detailsPattern)) {
if (match[0][1] === '/') {
detailsDepth = Math.max(0, detailsDepth - 1)
continue
}
detailsDepth += 1
if (detailsDepth > COMMENT_MARKDOWN_LIMITS.detailsDepth) {
return false
}
}
return true
}
function commentMarkdownLimitFallback(content: string): MarkdownBlock[] {
const preview = content.slice(0, COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits)
return [{ kind: 'paragraph', text: `${preview}${LIMIT_FALLBACK_SUFFIX}` }]
return parseSegment(cleaned)
}
// Splits a segment at top-level <details>/<blockquote> regions (preserving order),
@@ -181,17 +120,8 @@ function parseLines(content: string): MarkdownBlock[] {
const align = parseAlignRow(lines[i + 1])
i += 2
const rows: string[][] = []
let tableCells = headers.length
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
if (rows.length >= COMMENT_MARKDOWN_LIMITS.tableRows) {
throw new CommentMarkdownLimitError('Table row limit exceeded')
}
const row = splitTableRow(lines[i])
tableCells += row.length
if (tableCells > COMMENT_MARKDOWN_LIMITS.tableCells) {
throw new CommentMarkdownLimitError('Table cell limit exceeded')
}
rows.push(row)
rows.push(splitTableRow(lines[i]))
i += 1
}
blocks.push({ kind: 'table', headers, rows, align })
@@ -277,18 +207,12 @@ function splitTableRow(line: string): string[] {
continue
}
if (ch === '|') {
if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
throw new CommentMarkdownLimitError('Table column limit exceeded')
}
cells.push(cell.trim())
cell = ''
continue
}
cell += ch
}
if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
throw new CommentMarkdownLimitError('Table column limit exceeded')
}
cells.push(cell.trim())
return cells
}
@@ -132,27 +132,6 @@ describe('DictationSetupPollController', () => {
poller.dispose()
})
it('shares one completion while repeated manual refreshes are pending', async () => {
const requests = [deferred<boolean>(), deferred<boolean>()]
const refresh = vi.fn(() => requests[refresh.mock.calls.length - 1].promise)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
const completions = Array.from({ length: 1_000 }, () => poller.refreshNow())
expect(new Set(completions).size).toBe(1)
expect(refresh).toHaveBeenCalledOnce()
requests[0].resolve(true)
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(2)
requests[1].resolve(false)
await expect(completions[0]).resolves.toBeUndefined()
poller.dispose()
})
it('refreshes immediately when visibility or foreground eligibility resumes', async () => {
const refresh = vi.fn().mockResolvedValue(true)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
@@ -11,7 +11,7 @@ export class DictationSetupPollController {
private timer: ReturnType<typeof setTimeout> | null = null
private inFlight = false
private immediateRefreshPending = false
private refreshCompletion: { promise: Promise<void>; resolve: () => void } | null = null
private refreshWaiters: Array<() => void> = []
private disposed = false
// Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire.
// Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be
@@ -40,22 +40,17 @@ export class DictationSetupPollController {
if (this.disposed || !this.isEligible()) {
return Promise.resolve()
}
if (!this.refreshCompletion) {
let resolve!: () => void
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve
})
this.refreshCompletion = { promise, resolve }
}
this.requestRefresh(true)
return this.refreshCompletion.promise
return new Promise((resolve) => {
this.refreshWaiters.push(resolve)
this.requestRefresh(true)
})
}
dispose(): void {
this.disposed = true
this.immediateRefreshPending = false
this.clearTimer()
this.resolveRefreshCompletion()
this.resolveRefreshWaiters()
}
private update(next: Partial<PollState>): void {
@@ -118,7 +113,7 @@ export class DictationSetupPollController {
this.state.polling = shouldContinue
}
if (this.disposed || !this.isEligible()) {
this.resolveRefreshCompletion()
this.resolveRefreshWaiters()
return
}
if (this.immediateRefreshPending) {
@@ -126,7 +121,7 @@ export class DictationSetupPollController {
this.requestRefresh(true)
return
}
this.resolveRefreshCompletion()
this.resolveRefreshWaiters()
if (this.state.polling) {
this.scheduleRefresh()
}
@@ -149,9 +144,10 @@ export class DictationSetupPollController {
}
}
private resolveRefreshCompletion(): void {
const completion = this.refreshCompletion
this.refreshCompletion = null
completion?.resolve()
private resolveRefreshWaiters(): void {
const waiters = this.refreshWaiters.splice(0)
for (const resolve of waiters) {
resolve()
}
}
}
@@ -2,7 +2,6 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
import { LEGACY_MOBILE_FILE_PATH_MAX_BYTES } from './file-list-fallback'
import type { MobileDirEntry } from './file-tree'
import type { RpcResponse } from '../transport/types'
@@ -207,18 +206,6 @@ describe('MobileFileExplorerPanel', () => {
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('rejects an extreme directory listing instead of silently truncating it', async () => {
const client = createMockClient({
'': Array.from({ length: 10_001 }, (_, index) => entry(`file-${index}.txt`))
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('This folder is too large to show safely on mobile')
expect(renderedText(renderer)).toContain('10,000 items')
})
it('keeps the loaded tree visible during a transient disconnect', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
@@ -379,43 +366,6 @@ describe('MobileFileExplorerPanel', () => {
expect(renderedText(renderer)).toContain('Showing first 5000')
})
it('reports a bounded-memory error when a legacy path exceeds the fallback cap', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
if (method === 'files.readDir') {
return {
id: 'response-id',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-id' }
}
}
return {
id: 'response-id',
ok: true,
result: {
files: [
{
relativePath: 'x'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES + 1),
basename: 'oversized',
kind: 'text'
}
],
totalCount: 1,
truncated: false
},
_meta: { runtimeId: 'runtime-id' }
}
})
}
mockTransport.client = legacyClient
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('legacy file list is too large')
expect(renderedText(renderer)).toContain('Update Orca Desktop')
})
it('reports the files.list failure when the fallback itself fails', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
+46 -84
View File
@@ -16,15 +16,14 @@ import {
flattenDirectoryCache,
getDirectoryCacheState,
type DirectoryCache,
type DirectoryState,
type FileExplorerRow
type FileExplorerRow,
type MobileDirEntry
} from './file-tree'
import type { RpcSuccess } from '../transport/types'
import { colors } from '../theme/mobile-theme'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
forgetDirectoryLoadBranches,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
@@ -37,12 +36,6 @@ import {
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
import {
MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE,
parseBoundedMobileDirectoryEntries,
removeEvictedExpandedPaths,
retainMobileDirectoryState
} from './mobile-directory-cache-retention'
export function MobileFileExplorerPanel(props: {
hostId: string
@@ -61,48 +54,13 @@ export function MobileFileExplorerPanel(props: {
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
const directoryCacheRef = useRef<DirectoryCache>({})
const directoryCacheAccessRef = useRef(0)
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const expandedRef = useRef(expanded)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
const commitDirectoryState = useCallback(
(relativePath: string, state: DirectoryState): boolean => {
const retained = retainMobileDirectoryState(
directoryCacheRef.current,
relativePath,
{ ...state, lastAccess: ++directoryCacheAccessRef.current },
expandedRef.current
)
if (!retained.admitted) {
return false
}
directoryCacheRef.current = retained.cache
setDirectoryCache(retained.cache)
if (retained.evictedPaths.length > 0) {
forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, retained.evictedPaths)
for (const pendingPath of pendingDirectoryRetriesRef.current) {
if (
retained.evictedPaths.some(
(evicted) => pendingPath === evicted || pendingPath.startsWith(`${evicted}/`)
)
) {
pendingDirectoryRetriesRef.current.delete(pendingPath)
}
}
const nextExpanded = removeEvictedExpandedPaths(expandedRef.current, retained.evictedPaths)
expandedRef.current = nextExpanded
setExpanded(nextExpanded)
}
return true
},
[]
)
const loadDirectory = useCallback(
async (relativePath: string) => {
const scope = scopeRef.current
@@ -119,10 +77,13 @@ export function MobileFileExplorerPanel(props: {
// Why: transient reconnects should not blank an already browsable tree.
setError(hasLoadedRoot ? null : message)
} else {
commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
error: message
})
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
}
return
}
@@ -137,16 +98,13 @@ export function MobileFileExplorerPanel(props: {
}
setError(null)
}
const admittedLoading = commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
loading: true
})
if (!admittedLoading) {
forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, [relativePath])
setLoading(false)
setError(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
return
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
loading: true
}
}))
try {
const response = await client.sendRequest('files.readDir', {
@@ -175,9 +133,7 @@ export function MobileFileExplorerPanel(props: {
return
}
const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
const legacyCache = directoryCacheFromFileList(legacyResult.files)
directoryCacheRef.current = legacyCache
setDirectoryCache(legacyCache)
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
// Why: the capped list silently omits files past the cap — keep
// the legacy explorer's "Showing first 5000" note.
setLegacyListTruncated(legacyResult.truncated)
@@ -194,13 +150,14 @@ export function MobileFileExplorerPanel(props: {
) {
return
}
const entries = parseBoundedMobileDirectoryEntries((response as RpcSuccess).result)
const entries = (response as RpcSuccess).result as MobileDirEntry[]
if (rootLoad) {
setLegacyListTruncated(false)
}
if (!commitDirectoryState(relativePath, { entries })) {
throw new Error(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: { entries }
}))
} catch (err) {
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
@@ -213,10 +170,13 @@ export function MobileFileExplorerPanel(props: {
// only a cold load surfaces the full-screen error.
setError(hadLoadedRoot ? null : message)
} else {
commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
error: message
})
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
}
} finally {
if (
@@ -227,7 +187,7 @@ export function MobileFileExplorerPanel(props: {
}
}
},
[client, commitDirectoryState, connState, worktreeId]
[client, connState, worktreeId]
)
useEffect(() => {
@@ -235,15 +195,17 @@ export function MobileFileExplorerPanel(props: {
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
pendingDirectoryRetriesRef.current.clear()
directoryCacheRef.current = {}
directoryCacheAccessRef.current = 0
setDirectoryCache({})
expandedRef.current = new Set()
setExpanded(expandedRef.current)
setExpanded(new Set())
setLoading(true)
setError(null)
setLegacyListTruncated(false)
}, [scope])
useEffect(() => {
directoryCacheRef.current = directoryCache
}, [directoryCache])
useEffect(() => {
void loadDirectory('')
}, [hostId, loadDirectory])
@@ -266,21 +228,21 @@ export function MobileFileExplorerPanel(props: {
const toggleDirectory = useCallback(
(relativePath: string) => {
const wasExpanded = expandedRef.current.has(relativePath)
const nextExpanded = new Set(expandedRef.current)
if (wasExpanded) {
nextExpanded.delete(relativePath)
} else {
nextExpanded.add(relativePath)
}
expandedRef.current = nextExpanded
setExpanded(nextExpanded)
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relativePath)) {
next.delete(relativePath)
} else {
next.add(relativePath)
}
return next
})
const state = getDirectoryCacheState(directoryCache, relativePath)
if (!wasExpanded && !state?.loading && (!state?.entries || state.error)) {
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
void loadDirectory(relativePath)
}
},
[directoryCache, loadDirectory]
[directoryCache, expanded, loadDirectory]
)
const retryDirectory = useCallback(
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
forgetDirectoryLoadBranches,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
@@ -42,17 +41,4 @@ describe('directory-load-revisions', () => {
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
})
it('invalidates evicted branches without staling unrelated loads', () => {
const revisions = createDirectoryLoadRevisions()
const src = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
const nested = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src/lib')
const docs = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'docs')
forgetDirectoryLoadBranches(revisions, ['src'])
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', src)).toBe(false)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', nested)).toBe(false)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', docs)).toBe(true)
})
})
@@ -19,21 +19,6 @@ export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions):
revisions.revisionsByPath.clear()
}
export function forgetDirectoryLoadBranches(
revisions: DirectoryLoadRevisions,
relativePaths: readonly string[]
): void {
for (const loadedPath of revisions.revisionsByPath.keys()) {
if (
relativePaths.some(
(relativePath) => loadedPath === relativePath || loadedPath.startsWith(`${relativePath}/`)
)
) {
revisions.revisionsByPath.delete(loadedPath)
}
}
}
export function beginDirectoryLoad(
revisions: DirectoryLoadRevisions,
scope: string,
+11 -153
View File
@@ -1,34 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
directoryCacheFromFileList,
isMobileMethodUnavailableError,
LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES,
LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES,
LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES,
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE,
LEGACY_MOBILE_FILE_LIST_MAX_FILES,
LEGACY_MOBILE_FILE_PATH_MAX_BYTES,
LEGACY_MOBILE_FILE_PATH_MAX_DEPTH,
type LegacyMobileFileEntry
} from './file-list-fallback'
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
import { getDirectoryCacheState } from './file-tree'
const RETAINED_NODE_ESTIMATE_BYTES = 64
function file(relativePath: string): LegacyMobileFileEntry {
return { relativePath, basename: relativePath, kind: 'text' }
}
function directoryChainPath(prefix: string, directories: number): string {
const names = Array.from({ length: directories }, (_, index) => (index === 0 ? prefix : 'd'))
return `${names.join('/')}/file`
}
function fixedLengthUniqueName(index: number, length: number): string {
const suffix = `-${index.toString(36)}`
return `${'x'.repeat(length - suffix.length)}${suffix}`
}
describe('isMobileMethodUnavailableError', () => {
it('detects old-desktop allowlist and missing-method failures', () => {
expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
@@ -47,9 +20,9 @@ describe('isMobileMethodUnavailableError', () => {
describe('directoryCacheFromFileList', () => {
it('synthesizes every ancestor directory from flat paths', () => {
const cache = directoryCacheFromFileList([
file('src/lib/util.ts'),
file('src/app.ts'),
file('README.md')
{ relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
])
expect(cache['']?.entries).toEqual(
expect.arrayContaining([
@@ -66,27 +39,11 @@ describe('directoryCacheFromFileList', () => {
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
})
it('preserves empty-segment filtering and first-seen entry order', () => {
const cache = directoryCacheFromFileList([
file('//src///lib//util.ts//'),
file('/README.md'),
file('src/app.ts'),
file('///')
])
expect(cache['']?.entries).toEqual([
{ name: 'src', isDirectory: true },
{ name: 'README.md', isDirectory: false }
])
expect(cache['src']?.entries).toEqual([
{ name: 'lib', isDirectory: true },
{ name: 'app.ts', isDirectory: false }
])
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
})
it('keeps a name a directory when it appears as both file and dir prefix', () => {
const cache = directoryCacheFromFileList([file('src'), file('src/app.ts')])
const cache = directoryCacheFromFileList([
{ relativePath: 'src', basename: 'src', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
])
expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
})
@@ -96,112 +53,13 @@ describe('directoryCacheFromFileList', () => {
})
it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
const cache = directoryCacheFromFileList([file('__proto__/pollute.js')])
const cache = directoryCacheFromFileList([
{ relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
])
expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
{ name: 'pollute.js', isDirectory: false }
])
})
it('accepts a delimiter-heavy path at the exact byte cap without retaining empty segments', () => {
const cache = directoryCacheFromFileList([file('/'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES))])
expect(cache['']?.entries).toEqual([])
})
it('measures the per-file path cap in UTF-8 bytes and rejects one byte over', () => {
const exactPath = 'é'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES / 2)
expect(directoryCacheFromFileList([file(exactPath)])['']?.entries).toEqual([
{ name: exactPath, isDirectory: false }
])
expect(() => directoryCacheFromFileList([file(`${exactPath}a`)])).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('accepts the exact path depth and rejects the next segment', () => {
const exactPath = Array.from({ length: LEGACY_MOBILE_FILE_PATH_MAX_DEPTH }, () => 'd').join('/')
const parentPath = exactPath.slice(0, exactPath.lastIndexOf('/'))
expect(
getDirectoryCacheState(directoryCacheFromFileList([file(exactPath)]), parentPath)
).toEqual({
entries: [{ name: 'd', isDirectory: false }]
})
expect(() => directoryCacheFromFileList([file(`${exactPath}/overflow`)])).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('rejects a response above the desktop files.list record cap', () => {
const files = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES + 1 }, () => file(''))
expect(() => directoryCacheFromFileList(files)).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
})
it('accepts the exact directory cap and rejects one additional directory', () => {
const fullChains = Math.floor((LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1) / 255)
const remainingDirectories = LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1 - fullChains * 255
const files = Array.from({ length: fullChains }, (_, index) =>
file(directoryChainPath(`root-${index}`, 255))
)
if (remainingDirectories > 0) {
files.push(file(directoryChainPath('tail', remainingDirectories)))
}
expect(Object.keys(directoryCacheFromFileList(files))).toHaveLength(
LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES
)
expect(() =>
directoryCacheFromFileList([...files, file(directoryChainPath('overflow', 1))])
).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
})
it('accepts the exact aggregate entry cap and rejects one additional entry', () => {
const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) =>
file(`root-${index}/a/b/file`)
)
const overflowFiles = exactFiles.map((entry, index) =>
index === 0 ? file('root-0/a/b/c/file') : entry
)
const cache = directoryCacheFromFileList(exactFiles)
const entryCount = Object.values(cache).reduce(
(total, state) => total + (state?.entries.length ?? 0),
0
)
expect(entryCount).toBe(LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES)
expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('accepts the exact retained-byte budget and rejects two bytes over', () => {
const namesBytes =
LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES -
RETAINED_NODE_ESTIMATE_BYTES -
LEGACY_MOBILE_FILE_LIST_MAX_FILES * RETAINED_NODE_ESTIMATE_BYTES
const namesCharacters = namesBytes / 2
const baseLength = Math.floor(namesCharacters / LEGACY_MOBILE_FILE_LIST_MAX_FILES)
const longerNames = namesCharacters % LEGACY_MOBILE_FILE_LIST_MAX_FILES
const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => {
const length = baseLength + (index < longerNames ? 1 : 0)
return file(fixedLengthUniqueName(index, length))
})
const overflowFiles = exactFiles.map((entry, index) =>
index === 0 ? file(`${entry.relativePath}x`) : entry
)
expect(directoryCacheFromFileList(exactFiles)['']?.entries).toHaveLength(
LEGACY_MOBILE_FILE_LIST_MAX_FILES
)
expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('rejects malformed legacy response shapes with a clear error', () => {
expect(() => directoryCacheFromFileList({ files: [] })).toThrow('invalid legacy file list')
expect(() => directoryCacheFromFileList([{ relativePath: 42 }])).toThrow(
'invalid legacy file list'
)
})
})
+35 -167
View File
@@ -1,22 +1,8 @@
// Fallback for desktops that predate files.readDir in the mobile RPC
// allowlist: synthesize the lazy directory cache from the flat, capped
// files.list result so the Files tab stays browsable against old desktops.
import { getUtf8ByteLengthForCodePoint } from '../../../src/shared/utf8-byte-limits'
import type { DirectoryCache, MobileDirEntry } from './file-tree'
// Why: each flat legacy path can amplify into many retained ancestor records.
export const LEGACY_MOBILE_FILE_LIST_MAX_FILES = 5_000
export const LEGACY_MOBILE_FILE_PATH_MAX_BYTES = 16 * 1024
export const LEGACY_MOBILE_FILE_PATH_MAX_DEPTH = 256
export const LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES = 16_384
export const LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES = 20_000
export const LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
export const LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE =
'This legacy file list is too large to show safely on mobile. Update Orca Desktop to browse it folder by folder.'
const DIRECTORY_RETAINED_BYTES = 64
const ENTRY_RETAINED_BYTES = 64
export type LegacyMobileFileEntry = {
relativePath: string
basename: string
@@ -29,13 +15,6 @@ export type LegacyFilesListResult = {
truncated: boolean
}
type LegacyFileCacheBuildState = {
childrenByDir: Map<string, Map<string, MobileDirEntry>>
directories: number
entries: number
retainedBytes: number
}
// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
// 'forbidden' = method exists but is not mobile-allowlisted on the old
// desktop; 'method_not_found' = desktop predates the method entirely.
@@ -50,156 +29,45 @@ export function isMobileMethodUnavailableError(
)
}
export function directoryCacheFromFileList(files: unknown): DirectoryCache {
if (!Array.isArray(files)) {
throw new Error('Desktop returned an invalid legacy file list.')
export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
const childrenByDir = new Map<string, Map<string, boolean>>()
const ensureDir = (path: string): Map<string, boolean> => {
let children = childrenByDir.get(path)
if (!children) {
children = new Map()
childrenByDir.set(path, children)
}
return children
}
if (files.length > LEGACY_MOBILE_FILE_LIST_MAX_FILES) {
throwLegacyFileListLimitError()
}
const state: LegacyFileCacheBuildState = {
childrenByDir: new Map(),
directories: 0,
entries: 0,
retainedBytes: 0
}
ensureDirectory(state, '')
ensureDir('')
for (const file of files) {
const relativePath = getLegacyRelativePath(file)
if (relativePath === null) {
throw new Error('Desktop returned an invalid legacy file list.')
}
addFilePath(state, relativePath, measureBoundedPathDepth(relativePath))
}
return createDirectoryCache(state.childrenByDir)
}
function getLegacyRelativePath(value: unknown): string | null {
if (value === null || typeof value !== 'object') {
return null
}
const relativePath = (value as { relativePath?: unknown }).relativePath
return typeof relativePath === 'string' ? relativePath : null
}
function measureBoundedPathDepth(relativePath: string): number {
let bytes = 0
let depth = 0
let insideSegment = false
for (let index = 0; index < relativePath.length; index += 1) {
const codePoint = relativePath.codePointAt(index) ?? 0
bytes += getUtf8ByteLengthForCodePoint(codePoint)
if (bytes > LEGACY_MOBILE_FILE_PATH_MAX_BYTES) {
throwLegacyFileListLimitError()
}
if (codePoint === 47) {
if (insideSegment) {
depth += 1
assertPathDepth(depth)
}
insideSegment = false
} else {
insideSegment = true
}
if (codePoint > 0xffff) {
index += 1
}
}
if (insideSegment) {
depth += 1
assertPathDepth(depth)
}
return depth
}
function assertPathDepth(depth: number): void {
if (depth > LEGACY_MOBILE_FILE_PATH_MAX_DEPTH) {
throwLegacyFileListLimitError()
}
}
function addFilePath(state: LegacyFileCacheBuildState, relativePath: string, depth: number): void {
let parentPath = ''
let segmentStart = 0
let segmentIndex = 0
for (let cursor = 0; cursor <= relativePath.length; cursor += 1) {
if (cursor < relativePath.length && relativePath.charCodeAt(cursor) !== 47) {
continue
}
if (cursor > segmentStart) {
segmentIndex += 1
const name = relativePath.slice(segmentStart, cursor)
const isDirectory = segmentIndex < depth
addDirectoryEntry(state, parentPath, name, isDirectory)
const parts = file.relativePath.split('/').filter(Boolean)
let parentPath = ''
parts.forEach((name, index) => {
const isDirectory = index < parts.length - 1
const children = ensureDir(parentPath)
children.set(name, children.get(name) === true || isDirectory)
parentPath = parentPath ? `${parentPath}/${name}` : name
if (isDirectory) {
parentPath = parentPath ? `${parentPath}/${name}` : name
ensureDirectory(state, parentPath)
ensureDir(parentPath)
}
}
segmentStart = cursor + 1
}
}
function ensureDirectory(state: LegacyFileCacheBuildState, path: string): void {
if (state.childrenByDir.has(path)) {
return
}
const retainedBytes = path.length * 2 + DIRECTORY_RETAINED_BYTES
if (
state.directories >= LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES ||
state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
) {
throwLegacyFileListLimitError()
}
state.childrenByDir.set(path, new Map())
state.directories += 1
state.retainedBytes += retainedBytes
}
function addDirectoryEntry(
state: LegacyFileCacheBuildState,
parentPath: string,
name: string,
isDirectory: boolean
): void {
const children = state.childrenByDir.get(parentPath)
if (!children) {
throw new Error('Legacy file cache builder lost its parent directory.')
}
const existing = children.get(name)
if (existing) {
existing.isDirectory ||= isDirectory
return
}
const retainedBytes = name.length * 2 + ENTRY_RETAINED_BYTES
if (
state.entries >= LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES ||
state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
) {
throwLegacyFileListLimitError()
}
children.set(name, { name, isDirectory })
state.entries += 1
state.retainedBytes += retainedBytes
}
function createDirectoryCache(
childrenByDir: ReadonlyMap<string, ReadonlyMap<string, MobileDirEntry>>
): DirectoryCache {
const cache: DirectoryCache = {}
for (const [path, children] of childrenByDir) {
// Why: assignment to a '__proto__' path would invoke its legacy setter.
Object.defineProperty(cache, path, {
configurable: true,
enumerable: true,
value: { entries: Array.from(children.values()) },
writable: true
})
}
return cache
}
function throwLegacyFileListLimitError(): never {
throw new Error(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
// Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
// object's prototype instead of storing the directory; fromEntries always
// creates own keys.
return Object.fromEntries(
Array.from(childrenByDir, ([path, children]) => [
path,
{
entries: Array.from(
children,
([name, isDirectory]): MobileDirEntry => ({
name,
isDirectory
})
)
}
])
)
}
-1
View File
@@ -22,7 +22,6 @@ export type DirectoryState = {
entries: MobileDirEntry[]
loading?: boolean
error?: string
lastAccess?: number
}
export type DirectoryCache = Record<string, DirectoryState | undefined>
@@ -1,57 +1,44 @@
import { describe, expect, it } from 'vitest'
import { mobileDiffImageDataUri } from './mobile-diff-image-preview'
function pngBase64(width = 1): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(1, 20)
return bytes.toString('base64')
}
describe('mobileDiffImageDataUri', () => {
it('renders a modified image diff from the post-change bytes', () => {
const modifiedContent = pngBase64(2)
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent,
modifiedContent: 'bmV3',
isImage: true,
mimeType: 'image/png'
})
).toBe(`data:image/png;base64,${modifiedContent}`)
).toBe('data:image/png;base64,bmV3')
})
it('renders an added image diff (no original) from the modified bytes', () => {
const modifiedContent = pngBase64()
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent,
modifiedContent: 'bmV3',
isImage: true,
mimeType: 'image/png'
})
).toBe(`data:image/png;base64,${modifiedContent}`)
).toBe('data:image/png;base64,bmV3')
})
it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => {
const originalContent = pngBase64()
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent,
originalContent: 'b2xk',
originalIsBinary: true,
modifiedContent: '',
modifiedIsBinary: false,
modifiedDeleted: true,
isImage: true,
mimeType: 'image/png'
mimeType: 'image/jpeg'
})
).toBe(`data:image/png;base64,${originalContent}`)
).toBe('data:image/jpeg;base64,b2xk')
})
// The reviewer's read-failure case: a relay/SSH read returns an empty modified
@@ -1,98 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DirectoryCache, DirectoryState } from './file-tree'
import {
MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
parseBoundedMobileDirectoryEntries,
removeEvictedExpandedPaths,
retainMobileDirectoryState
} from './mobile-directory-cache-retention'
function state(name: string, lastAccess: number): DirectoryState {
return { entries: [{ name, isDirectory: false }], lastAccess }
}
describe('mobile directory cache retention', () => {
it('keeps the cache bounded across an unlimited sequence of visited directories', () => {
let cache: DirectoryCache = { '': state('root', 0) }
for (let index = 1; index <= MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3; index++) {
const result = retainMobileDirectoryState(
cache,
`dir-${index}`,
state(`file-${index}`, index),
new Set()
)
expect(result.admitted).toBe(true)
cache = result.cache
}
expect(Object.keys(cache)).toHaveLength(MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES)
expect(cache['']).toBeDefined()
expect(cache['dir-1']).toBeUndefined()
expect(cache[`dir-${MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3}`]).toBeDefined()
})
it('evicts a collapsed branch before an older expanded branch', () => {
const cache: DirectoryCache = {
'': state('root', 0),
expanded: state('expanded', 1),
collapsed: state('collapsed', 2)
}
const result = retainMobileDirectoryState(
cache,
'new',
state('new', 3),
new Set(['expanded']),
{ directories: 3, entries: 100, retainedBytes: 10_000 }
)
expect(result.cache.expanded).toBeDefined()
expect(result.cache.collapsed).toBeUndefined()
expect(result.evictedPaths).toEqual(['collapsed'])
})
it('evicts old content when aggregate entry retention reaches its cap', () => {
const cache: DirectoryCache = {
'': state('root', 0),
old: state('old', 1)
}
const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
directories: 10,
entries: 2,
retainedBytes: 10_000
})
expect(result.cache.old).toBeUndefined()
expect(result.cache.new).toBeDefined()
})
it('evicts old content when aggregate retained bytes reach their cap', () => {
const cache: DirectoryCache = {
'': state('root', 0),
old: state('x'.repeat(100), 1)
}
const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
directories: 10,
entries: 100,
retainedBytes: 300
})
expect(result.cache.old).toBeUndefined()
expect(result.cache.new).toBeDefined()
})
it('collapses expanded descendants whose cached branch was evicted', () => {
expect([...removeEvictedExpandedPaths(new Set(['src', 'src/lib', 'docs']), ['src'])]).toEqual([
'docs'
])
})
it('rejects malformed listings instead of retaining untrusted response shapes', () => {
expect(() => parseBoundedMobileDirectoryEntries([{ name: 'src' }])).toThrow(
'invalid folder listing'
)
})
})
@@ -1,156 +0,0 @@
import {
assertMobileFileDirectoryWithinLimit,
estimateMobileDirectoryEntryBytes,
MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE,
MOBILE_FILE_DIRECTORY_MAX_ENTRIES
} from '../../../src/shared/mobile-file-directory-limit'
import type { DirectoryCache, DirectoryState, MobileDirEntry } from './file-tree'
// Why: old collapsed branches can reload on demand once explorer metadata reaches a phone-safe ceiling.
export const MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES = 128
export const MOBILE_DIRECTORY_CACHE_MAX_ENTRIES = 25_000
export const MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
export const MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE =
'Too many folders are open to load this folder safely. Close another folder and retry.'
type CacheLimits = {
directories: number
entries: number
retainedBytes: number
}
type RetentionResult = {
cache: DirectoryCache
evictedPaths: string[]
admitted: boolean
}
const DEFAULT_LIMITS: CacheLimits = {
directories: MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
entries: MOBILE_DIRECTORY_CACHE_MAX_ENTRIES,
retainedBytes: MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES
}
export function parseBoundedMobileDirectoryEntries(value: unknown): MobileDirEntry[] {
if (!Array.isArray(value)) {
throw new Error('Desktop returned an invalid folder listing.')
}
if (value.length > MOBILE_FILE_DIRECTORY_MAX_ENTRIES) {
throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE)
}
for (const entry of value) {
if (!isMobileDirectoryEntry(entry)) {
throw new Error('Desktop returned an invalid folder listing.')
}
}
const entries = value as MobileDirEntry[]
assertMobileFileDirectoryWithinLimit(entries)
return entries
}
export function retainMobileDirectoryState(
cache: DirectoryCache,
relativePath: string,
state: DirectoryState,
expandedPaths: ReadonlySet<string>,
limits: CacheLimits = DEFAULT_LIMITS
): RetentionResult {
const next: DirectoryCache = { ...cache, [relativePath]: state }
const evictedPaths: string[] = []
const essentialPaths = directoryAncestors(relativePath)
while (cacheExceedsLimits(next, limits)) {
const victim = selectEvictionPath(next, expandedPaths, essentialPaths)
if (victim === null) {
return { cache, evictedPaths: [], admitted: false }
}
for (const path of Object.keys(next)) {
if (path === victim || path.startsWith(`${victim}/`)) {
delete next[path]
evictedPaths.push(path)
}
}
}
return { cache: next, evictedPaths, admitted: true }
}
export function removeEvictedExpandedPaths(
expandedPaths: ReadonlySet<string>,
evictedPaths: readonly string[]
): Set<string> {
if (evictedPaths.length === 0) {
return new Set(expandedPaths)
}
return new Set(
[...expandedPaths].filter(
(expanded) =>
!evictedPaths.some((evicted) => expanded === evicted || expanded.startsWith(`${evicted}/`))
)
)
}
function isMobileDirectoryEntry(value: unknown): value is MobileDirEntry {
if (!value || typeof value !== 'object') {
return false
}
const entry = value as Record<string, unknown>
return (
typeof entry.name === 'string' &&
typeof entry.isDirectory === 'boolean' &&
(entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean')
)
}
function directoryAncestors(relativePath: string): Set<string> {
const ancestors = new Set(['', relativePath])
let cursor = relativePath
while (cursor.includes('/')) {
cursor = cursor.slice(0, cursor.lastIndexOf('/'))
ancestors.add(cursor)
}
return ancestors
}
function selectEvictionPath(
cache: DirectoryCache,
expandedPaths: ReadonlySet<string>,
essentialPaths: ReadonlySet<string>
): string | null {
const candidates = Object.keys(cache)
.filter((path) => !essentialPaths.has(path))
.sort((left, right) => accessOrder(cache, left) - accessOrder(cache, right))
return candidates.find((path) => !expandedPaths.has(path)) ?? candidates[0] ?? null
}
function accessOrder(cache: DirectoryCache, path: string): number {
return cache[path]?.lastAccess ?? 0
}
function cacheExceedsLimits(cache: DirectoryCache, limits: CacheLimits): boolean {
const paths = Object.keys(cache)
if (paths.length > limits.directories) {
return true
}
let entries = 0
let retainedBytes = 0
for (const path of paths) {
const state = cache[path]
if (!state) {
continue
}
entries += state.entries.length
retainedBytes += estimateDirectoryStateBytes(path, state)
if (entries > limits.entries || retainedBytes > limits.retainedBytes) {
return true
}
}
return false
}
function estimateDirectoryStateBytes(path: string, state: DirectoryState): number {
let bytes = path.length * 2 + (state.error?.length ?? 0) * 2 + 64
for (const entry of state.entries) {
bytes += estimateMobileDirectoryEntryBytes(entry)
}
return bytes
}
@@ -28,16 +28,6 @@ function clientWithResponses(responses: RpcResponse[]) {
}
}
function pngBase64(width = 1, height = 1): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(height, 20)
return bytes.toString('base64')
}
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({
@@ -55,13 +45,14 @@ describe('mobile-file-preview-request', () => {
})
it('loads images through readPreview and never calls files.open', async () => {
const content = pngBase64()
const client = clientWith(ok({ content, isBinary: true, isImage: true, mimeType: 'image/png' }))
const client = clientWith(
ok({ content: 'aW1hZ2U=', isBinary: true, isImage: true, mimeType: 'image/png' })
)
await expect(loadMobileFilePreview(client, 'wt-1', 'assets/logo.png')).resolves.toEqual({
status: 'ready',
kind: 'image',
dataUri: `data:image/png;base64,${content}`
dataUri: 'data:image/png;base64,aW1hZ2U='
})
expect(client.sendRequest).toHaveBeenCalledWith('files.readPreview', {
worktree: 'id:wt-1',
@@ -536,9 +527,9 @@ describe('mobile-file-preview-request', () => {
})
it.each([
['missing isBinary', { content: pngBase64(), isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: pngBase64(), isBinary: true, mimeType: 'image/png' }],
['missing mimeType', { content: pngBase64(), isBinary: true, isImage: true }],
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
['missing mimeType', { content: 'aW1hZ2U=', isBinary: true, isImage: true }],
['empty content', { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }]
])('rejects invalid image preview results: %s', (_label, result) => {
expect(normalizeMobileFilePreviewResponse('assets/logo.png', ok(result))).toEqual({
@@ -548,24 +539,6 @@ describe('mobile-file-preview-request', () => {
})
})
it('rejects an oversized raster response before React Native receives a data URI', () => {
expect(
normalizeMobileFilePreviewResponse(
'assets/logo.png',
ok({
content: pngBase64(32_769, 1),
isBinary: true,
isImage: true,
mimeType: 'image/png'
})
)
).toEqual({
status: 'error',
message: 'Binary preview unavailable',
reconnect: false
})
})
it('normalizes markdown, html, text, empty, and truncated reads', () => {
expect(
normalizeMobileFilePreviewResponse(
@@ -2,7 +2,6 @@ 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'
import { buildImageDataUri } from '../../../src/shared/image-data-uri'
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
@@ -118,14 +117,10 @@ function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
) {
return previewError('binary_file')
}
const dataUri = buildImageDataUri(preview.mimeType, preview.content)
if (!dataUri) {
return previewError('binary_file')
}
return {
status: 'ready',
kind: 'image',
dataUri
dataUri: `data:${preview.mimeType};base64,${preview.content}`
}
}
+4 -24
View File
@@ -31,16 +31,6 @@ function clientOf(byMethod: Record<string, RpcResponse>): {
const WT = { worktreeId: 'wt1' }
function pngBase64(width = 1): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(1, 20)
return bytes.toString('base64')
}
describe('resolveMobileFileTabDoc', () => {
it('renders a staged text diff', async () => {
const client = clientOf({
@@ -56,12 +46,11 @@ describe('resolveMobileFileTabDoc', () => {
})
it('renders an unstaged image diff from the modified bytes', async () => {
const modifiedContent = pngBase64()
const client = clientOf({
'git.diff': ok({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent,
modifiedContent: 'bmV3',
modifiedIsBinary: true,
isImage: true,
mimeType: 'image/png'
@@ -72,11 +61,7 @@ describe('resolveMobileFileTabDoc', () => {
relativePath: 'm1.png',
diffSource: 'unstaged'
})
expect(doc).toEqual({
status: 'ready',
kind: 'image',
dataUri: `data:image/png;base64,${modifiedContent}`
})
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
})
it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => {
@@ -103,16 +88,11 @@ describe('resolveMobileFileTabDoc', () => {
})
it('renders a live image preview via files.readPreview', async () => {
const content = pngBase64()
const client = clientOf({
'files.readPreview': ok({ content, isImage: true, mimeType: 'image/png' })
'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' })
})
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
expect(doc).toEqual({
status: 'ready',
kind: 'image',
dataUri: `data:image/png;base64,${content}`
})
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
expect(client.calls).toEqual(['files.readPreview'])
})
@@ -1,42 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
enqueueMobileDictationAudioChunk,
MOBILE_DICTATION_MAX_PENDING_CHUNKS
} from './mobile-dictation-audio-chunk'
import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
describe('enqueueMobileDictationAudioChunk', () => {
it('accepts the exact pending-promise cap and rejects one over even for empty chunks', () => {
const sendRequest = vi.fn(() => new Promise<never>(() => undefined))
const failActiveDictation = vi.fn()
const pendingChunks = new Set<Promise<void>>()
const queue = {
pendingChunks,
pendingAudioBudget: new MobileDictationPendingAudioBudget(),
shouldReleaseBudget: () => true,
failActiveDictation
}
const event = { data: new Uint8Array() }
for (let index = 0; index < MOBILE_DICTATION_MAX_PENDING_CHUNKS; index += 1) {
enqueueMobileDictationAudioChunk(
{ sendRequest } as unknown as RpcClient,
'dictation',
event,
queue
)
}
expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS)
expect(failActiveDictation).not.toHaveBeenCalled()
enqueueMobileDictationAudioChunk(
{ sendRequest } as unknown as RpcClient,
'dictation',
event,
queue
)
expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS)
expect(failActiveDictation).toHaveBeenCalledOnce()
})
})
@@ -14,21 +14,12 @@ type MobileDictationAudioChunkQueue = {
failActiveDictation: (dictationId: string, err: unknown) => void
}
export const MOBILE_DICTATION_MAX_PENDING_CHUNKS = 256
export function enqueueMobileDictationAudioChunk(
client: RpcClient,
dictationId: string,
event: MicrophoneDataEvent,
queue: MobileDictationAudioChunkQueue
): void {
if (queue.pendingChunks.size >= MOBILE_DICTATION_MAX_PENDING_CHUNKS) {
queue.failActiveDictation(
dictationId,
new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)
)
return
}
const raw = event.data
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
const byteLength = bytes.byteLength
@@ -11,11 +11,9 @@ vi.mock('expo-keep-awake', () => ({
}))
import {
MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS,
MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS,
MobileDictationKeepAwakeOwner,
drainMobileDictationKeepAwakeCleanup,
resetMobileDictationKeepAwakeForTests
drainMobileDictationKeepAwakeCleanup
} from './mobile-dictation-keep-awake'
function deferred(): {
@@ -38,33 +36,10 @@ function deferred(): {
describe('MobileDictationKeepAwakeOwner', () => {
beforeEach(() => {
resetMobileDictationKeepAwakeForTests()
keepAwake.activate.mockReset().mockResolvedValue(undefined)
keepAwake.deactivate.mockReset().mockResolvedValue(undefined)
})
it('accepts the exact tracked-tag cap, rejects one over, and recovers after release', async () => {
const owners = Array.from(
{ length: MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS },
() => new MobileDictationKeepAwakeOwner()
)
await Promise.all(owners.map((owner, index) => owner.acquire(`dictation-${index}`)))
expect(keepAwake.activate).toHaveBeenCalledTimes(MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS)
const oneOver = new MobileDictationKeepAwakeOwner()
await expect(oneOver.acquire('one-over')).rejects.toThrow(
'Too many dictation keep-awake operations are pending'
)
expect(keepAwake.activate).toHaveBeenCalledTimes(MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS)
await owners[0]!.release('dictation-0')
await expect(oneOver.reacquire('one-over')).resolves.toBeUndefined()
await Promise.all([
...owners.slice(1).map((owner, index) => owner.release(`dictation-${index + 1}`)),
oneOver.release('one-over')
])
})
it('retries a failed native deactivation after the hook owner is replaced', async () => {
const firstOwner = new MobileDictationKeepAwakeOwner()
@@ -5,7 +5,6 @@ const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation'
// Native keep-awake promises can be lost during Activity teardown; a bounded
// wait keeps the serialized queue below from wedging dictation until restart.
export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000
export const MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS = 128
let nextOwnerId = 0
let keepAwakeOperation: Promise<void> = Promise.resolve()
@@ -52,34 +51,7 @@ function withNativeCallTimeout(nativeCall: Promise<void>): Promise<void> {
})
}
function trackedTagCount(): number {
let count = activeTags.size
for (const tag of pendingCleanupTags) {
if (!activeTags.has(tag)) {
count += 1
}
}
for (const tag of pendingActivations.keys()) {
if (!activeTags.has(tag) && !pendingCleanupTags.has(tag)) {
count += 1
}
}
return count
}
function assertTrackedTagCapacity(tag: string): void {
if (
!activeTags.has(tag) &&
!pendingCleanupTags.has(tag) &&
!pendingActivations.has(tag) &&
trackedTagCount() >= MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS
) {
throw new Error('Too many dictation keep-awake operations are pending')
}
}
async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise<void> {
assertTrackedTagCapacity(tag)
const nativeActivation = activateKeepAwakeAsync(tag)
try {
await withNativeCallTimeout(nativeActivation)
@@ -262,12 +234,3 @@ export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeO
export function drainMobileDictationKeepAwakeCleanup(): Promise<void> {
return enqueueKeepAwakeOperation(cleanupPendingTags)
}
/** Test-only: drop retained native-operation bookkeeping between cases. */
export function resetMobileDictationKeepAwakeForTests(): void {
nextOwnerId = 0
keepAwakeOperation = Promise.resolve()
activeTags.clear()
pendingCleanupTags.clear()
pendingActivations.clear()
}
@@ -1,203 +0,0 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import { MobileNotificationDeliveryLedger } from './mobile-notification-retention'
import {
MobileScheduledNotificationRegistry,
type MobileScheduledNotificationState
} from './mobile-scheduled-notification-registry'
export type NotificationEvent = {
type: 'notification'
source: DesktopNotificationSource
title: string
body: string
worktreeId?: string
notificationId?: string
notificationSeq?: number
}
export type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
}
const scheduledNotifications = new MobileScheduledNotificationRegistry()
const notificationDeliveryLedger = new MobileNotificationDeliveryLedger()
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
scheduledNotifications.resetForTests(max)
notificationDeliveryLedger.resetForTests(max)
}
export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
return {
granted: status === 'granted',
status,
canAskAgain,
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
}
}
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
export async function ensureNotificationPermissions(): Promise<boolean> {
const existing = await getNotificationPermissionState()
if (existing.granted) {
return true
}
const { status } = await Notifications.requestPermissionsAsync()
return status === 'granted'
}
export function configureNotificationChannel(): void {
if (Platform.OS === 'android') {
void Notifications.setNotificationChannelAsync('orca-desktop', {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#6366f1'
})
}
}
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise<void> {
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
if (!storedKey) {
if (!(await loadPushNotificationsEnabled()) || !(await ensureNotificationPermissions())) {
return
}
await scheduleLocalNotification(event, hostId)
return
}
let state = scheduledNotifications.get(storedKey)
let evictedIdentifiers: string[] = []
if (state?.pending) {
return
}
if (!state) {
const reservation = scheduledNotifications.reserve(storedKey)
if (!reservation) {
return
}
state = reservation.state
evictedIdentifiers = reservation.evictedIdentifiers
}
const notificationState = state
const pending = scheduleTrackedNotification(event, hostId, notificationState, evictedIdentifiers)
notificationState.pending = pending
try {
const scheduledIdentifier = await pending
if (!scheduledIdentifier) {
if (!notificationState.identifier) {
scheduledNotifications.delete(storedKey)
}
return
}
if (notificationState.dismissAfterSchedule) {
notificationState.dismissAfterSchedule = false
scheduledNotifications.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
return
}
if (!scheduledNotifications.retainIdentifier(notificationState, scheduledIdentifier)) {
scheduledNotifications.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
}
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
notificationState.dismissAfterSchedule = false
}
}
}
async function scheduleTrackedNotification(
event: NotificationEvent,
hostId: string,
state: MobileScheduledNotificationState,
evictedIdentifiers: string[]
): Promise<string | null> {
// Why: retaining the native id makes a later desktop dismiss work; eviction must close it before slot reuse.
for (const identifier of evictedIdentifiers) {
await Notifications.dismissNotificationAsync(identifier).catch(() => {})
}
if (!(await loadPushNotificationsEnabled()) || !(await ensureNotificationPermissions())) {
return null
}
if (state.identifier) {
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
scheduledNotifications.clearIdentifier(state)
}
return scheduleLocalNotification(event, hostId)
}
function scheduleLocalNotification(event: NotificationEvent, hostId: string): Promise<string> {
return Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
}
async function dismissLocalNotification(
event: DismissNotificationEvent,
hostId: string
): Promise<void> {
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const state = scheduledNotifications.get(storedKey)
if (!state) {
return
}
if (state.pending) {
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
if (!state.identifier) {
return
}
scheduledNotifications.delete(storedKey)
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
export function startLocalNotificationDelivery(
event: NotificationEvent | DismissNotificationEvent,
hostId: string
): Promise<void> | null {
const releaseDelivery = notificationDeliveryLedger.claim(event, hostId)
if (!releaseDelivery) {
return null
}
const delivery =
event.type === 'notification'
? showLocalNotification(event, hostId)
: dismissLocalNotification(event, hostId)
return delivery.finally(() => {
releaseDelivery()
})
}
@@ -1,112 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES,
MOBILE_NOTIFICATION_EVENT_MAX_BYTES,
MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES,
MOBILE_NOTIFICATION_ID_MAX_BYTES,
MobileNotificationDeliveryLedger,
measureMobileNotificationDeliveryBytes
} from './mobile-notification-retention'
import {
MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES,
MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES,
MobileScheduledNotificationRegistry
} from './mobile-scheduled-notification-registry'
describe('mobile notification retention', () => {
it('accepts an exact-limit event and rejects one more byte', () => {
const baseEvent = {
type: 'notification',
source: 'test',
title: '',
body: '',
notificationId: 'notification-1'
}
const baseBytes = measureMobileNotificationDeliveryBytes(baseEvent, 'host-1')!
const exactEvent = {
...baseEvent,
body: 'x'.repeat(MOBILE_NOTIFICATION_EVENT_MAX_BYTES - baseBytes)
}
expect(measureMobileNotificationDeliveryBytes(exactEvent, 'host-1')).toBe(
MOBILE_NOTIFICATION_EVENT_MAX_BYTES
)
expect(
measureMobileNotificationDeliveryBytes(
{ ...exactEvent, body: `${exactEvent.body}x` },
'host-1'
)
).toBeNull()
})
it('bounds host and notification identifiers before URI key expansion', () => {
const event = {
type: 'dismiss',
notificationId: 'n'.repeat(MOBILE_NOTIFICATION_ID_MAX_BYTES)
}
expect(
measureMobileNotificationDeliveryBytes(
event,
'h'.repeat(MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES)
)
).not.toBeNull()
expect(
measureMobileNotificationDeliveryBytes(
{ ...event, notificationId: `${event.notificationId}n` },
'host-1'
)
).toBeNull()
expect(
measureMobileNotificationDeliveryBytes(
event,
'h'.repeat(MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES + 1)
)
).toBeNull()
})
it('caps aggregate active bytes and restores capacity on release', () => {
const ledger = new MobileNotificationDeliveryLedger()
const baseEvent = { type: 'notification', title: '', body: '' }
const baseBytes = measureMobileNotificationDeliveryBytes(baseEvent, 'host-1')!
const exactEvent = {
...baseEvent,
body: 'x'.repeat(MOBILE_NOTIFICATION_EVENT_MAX_BYTES - baseBytes)
}
const exactClaims = MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES / MOBILE_NOTIFICATION_EVENT_MAX_BYTES
const releases = Array.from({ length: exactClaims }, () => ledger.claim(exactEvent, 'host-1'))
expect(releases.every(Boolean)).toBe(true)
expect(ledger.claim(exactEvent, 'host-1')).toBeNull()
releases[0]?.()
expect(ledger.claim(exactEvent, 'host-1')).not.toBeNull()
})
it('rejects a scheduled key that alone exceeds the aggregate budget', () => {
const registry = new MobileScheduledNotificationRegistry()
const maximumKeyCharacters = (MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES - 64) / 2
expect(registry.reserve('x'.repeat(maximumKeyCharacters))).not.toBeNull()
registry.resetForTests()
expect(registry.reserve('x'.repeat(maximumKeyCharacters + 1))).toBeNull()
})
it('retains exact-limit native ids and rejects oversized values', () => {
const registry = new MobileScheduledNotificationRegistry()
const exactState = registry.reserve('exact')!.state
const oversizedState = registry.reserve('oversized')!.state
expect(
registry.retainIdentifier(exactState, 'x'.repeat(MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES))
).toBe(true)
expect(
registry.retainIdentifier(
oversizedState,
'x'.repeat(MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES + 1)
)
).toBe(false)
expect(registry.getRetainedBytesForTests()).toBeLessThanOrEqual(
MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
)
})
})
@@ -1,95 +0,0 @@
import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
export const MOBILE_NOTIFICATION_EVENT_MAX_BYTES = 256 * 1024
export const MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES = 4 * 1024 * 1024
export const MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES = 256
export const MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES = 8 * 1024
export const MOBILE_NOTIFICATION_ID_MAX_BYTES = 8 * 1024
export const MOBILE_NOTIFICATION_WORKTREE_ID_MAX_BYTES = 16 * 1024
type MobileNotificationRetentionEvent = {
type: string
source?: string
title?: string
body?: string
worktreeId?: string
notificationId?: string
}
function boundedStringBytes(value: unknown, maxBytes: number): number | null {
if (value === undefined) {
return 0
}
if (typeof value !== 'string') {
return null
}
const measured = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes })
return measured.exceededLimit ? null : measured.byteLength
}
export function isMobileNotificationHostIdRetainable(hostId: string): boolean {
return boundedStringBytes(hostId, MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES) !== null
}
export function measureMobileNotificationDeliveryBytes(
event: MobileNotificationRetentionEvent,
hostId: string
): number | null {
const hostBytes = boundedStringBytes(hostId, MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES)
const notificationIdBytes = boundedStringBytes(
event.notificationId,
MOBILE_NOTIFICATION_ID_MAX_BYTES
)
const worktreeIdBytes = boundedStringBytes(
event.worktreeId,
MOBILE_NOTIFICATION_WORKTREE_ID_MAX_BYTES
)
if (hostBytes === null || notificationIdBytes === null || worktreeIdBytes === null) {
return null
}
let retainedBytes = 256 + hostBytes + notificationIdBytes + worktreeIdBytes
for (const value of [event.type, event.source, event.title, event.body]) {
const remaining = MOBILE_NOTIFICATION_EVENT_MAX_BYTES - retainedBytes
const valueBytes = boundedStringBytes(value, remaining)
if (valueBytes === null) {
return null
}
retainedBytes += valueBytes
}
return retainedBytes <= MOBILE_NOTIFICATION_EVENT_MAX_BYTES ? retainedBytes : null
}
export class MobileNotificationDeliveryLedger {
private activeEntries = 0
private activeBytes = 0
private maxEntries = MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES
claim(event: MobileNotificationRetentionEvent, hostId: string): (() => void) | null {
const retainedBytes = measureMobileNotificationDeliveryBytes(event, hostId)
if (
retainedBytes === null ||
this.activeEntries >= this.maxEntries ||
this.activeBytes + retainedBytes > MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES
) {
return null
}
this.activeEntries += 1
this.activeBytes += retainedBytes
let released = false
return () => {
if (released) {
return
}
released = true
this.activeEntries = Math.max(0, this.activeEntries - 1)
this.activeBytes = Math.max(0, this.activeBytes - retainedBytes)
}
}
resetForTests(maxEntries?: number): void {
this.activeEntries = 0
this.activeBytes = 0
this.maxEntries = maxEntries ?? MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES
}
}
@@ -313,11 +313,9 @@ describe('subscribeToDesktopNotifications', () => {
// Why: notificationId is unique per completion, so the map grew unbounded when
// the desktop never sent a dismiss (the remote-mobile case). It is now capped.
it('dismisses the oldest scheduled entry when reusing its bounded slot', async () => {
it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
setScheduledNotificationsMaxForTests(1)
try {
vi.mocked(Notifications.scheduleNotificationAsync).mockReset()
vi.mocked(Notifications.dismissNotificationAsync).mockReset()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
@@ -343,12 +341,10 @@ describe('subscribeToDesktopNotifications', () => {
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' })
await flushAsync()
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-old')
// The older entry was already dismissed during eviction, so a later desktop dismiss is a no-op...
// The older entry was evicted by the cap: dismissing it is a no-op...
onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
await flushAsync()
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
// ...while the most-recent entry is retained and still dismissable.
onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
@@ -358,105 +354,6 @@ describe('subscribeToDesktopNotifications', () => {
setScheduledNotificationsMaxForTests()
}
})
it('keeps overload gaps replayable and advances after reconnect catches up', async () => {
setScheduledNotificationsMaxForTests(1)
const firstSchedule = makeDeferred<string>()
try {
vi.mocked(Notifications.scheduleNotificationAsync).mockReset()
vi.mocked(Notifications.dismissNotificationAsync).mockReset()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync)
.mockReturnValueOnce(firstSchedule.promise)
.mockResolvedValueOnce('scheduled-after-drain')
.mockResolvedValueOnce('scheduled-replayed')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'two',
body: 'two',
notificationId: 'two',
notificationSeq: 2
},
{
type: 'notification',
title: 'three',
body: 'three',
notificationId: 'three',
notificationSeq: 3
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-hung')
onEvent?.({ type: 'ready', subscriptionId: 'sub-1' })
onEvent?.({
type: 'notification',
title: 'one',
body: 'one',
notificationId: 'one',
notificationSeq: 1
})
await flushAsync()
onEvent?.({
type: 'notification',
title: 'two',
body: 'two',
notificationId: 'two',
notificationSeq: 2
})
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
firstSchedule.resolve('scheduled-first')
await flushAsync()
onEvent?.({
type: 'notification',
title: 'three',
body: 'three',
notificationId: 'three',
notificationSeq: 3
})
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
onEvent?.({ type: 'ready', subscriptionId: 'sub-2' })
await flushAsync()
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(3)
expect(AsyncStorage.setItem).toHaveBeenLastCalledWith(
'orca:mobileNotificationsLastSeq:host-hung',
'3'
)
} finally {
firstSchedule.resolve('scheduled-first')
setScheduledNotificationsMaxForTests()
}
})
})
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
+230 -70
View File
@@ -1,14 +1,8 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import {
configureNotificationChannel,
startLocalNotificationDelivery,
type DismissNotificationEvent,
type NotificationEvent
} from './mobile-notification-delivery'
import {
isMobileNotificationHostIdRetainable,
measureMobileNotificationDeliveryBytes
} from './mobile-notification-retention'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import {
createSeenNotificationGuard,
loadLastSeenSeq,
@@ -16,81 +10,249 @@ import {
seenKeyForEvent
} from './notification-reconnect-catchup'
export {
ensureNotificationPermissions,
getNotificationPermissionState,
setScheduledNotificationsMaxForTests
} from './mobile-notification-delivery'
export type { NotificationPermissionState } from './mobile-notification-delivery'
type NotificationEvent = {
type: 'notification'
source: DesktopNotificationSource
title: string
body: string
worktreeId?: string
notificationId?: string
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
notificationSeq?: number
}
type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
}
type SubscribeResult = {
type: 'ready'
subscriptionId: string
}
type ScheduledNotificationState = {
identifier?: string
pending?: Promise<string | null>
dismissAfterSchedule?: boolean
}
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
if (!state.pending) {
scheduledNotificationsByHostAndNotificationId.delete(key)
evicted = true
break
}
}
if (!evicted) {
break
}
}
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
return {
granted: status === 'granted',
status,
canAskAgain,
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
}
}
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
export async function ensureNotificationPermissions(): Promise<boolean> {
const existing = await getNotificationPermissionState()
if (existing.granted) {
return true
}
const { status } = await Notifications.requestPermissionsAsync()
return status === 'granted'
}
function configureNotificationChannel(): void {
if (Platform.OS === 'android') {
void Notifications.setNotificationChannelAsync('orca-desktop', {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#6366f1'
})
}
}
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise<void> {
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
if (!storedKey) {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return
}
await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
return
}
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (state?.pending) {
return
}
if (!state) {
state = {}
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
}
const notificationState = state
const pending = (async () => {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return null
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return null
}
if (notificationState.identifier) {
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
notificationState.identifier = undefined
}
return Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
})()
notificationState.pending = pending
try {
const scheduledIdentifier = await pending
if (!scheduledIdentifier) {
if (!notificationState.identifier) {
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
}
return
}
if (notificationState.dismissAfterSchedule) {
notificationState.dismissAfterSchedule = false
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
return
}
notificationState.identifier = scheduledIdentifier
boundScheduledNotifications()
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
notificationState.dismissAfterSchedule = false
}
}
}
async function dismissLocalNotification(
event: DismissNotificationEvent,
hostId: string
): Promise<void> {
if (!event.notificationId) {
return
}
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (!state) {
return
}
if (state.pending) {
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
if (!state.identifier) {
return
}
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
if (!isMobileNotificationHostIdRetainable(hostId)) {
return () => {}
}
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
// Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut.
let lastDeliveredSeq = 0
let watermarkBlockedThroughSeq: number | null = null
// Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary.
const seenReplay = createSeenNotificationGuard()
function eventSequence(event: NotificationEvent | DismissNotificationEvent): number | null {
return typeof event.notificationSeq === 'number' && Number.isSafeInteger(event.notificationSeq)
? event.notificationSeq
: null
}
function advanceDeliveredWatermark(
event: NotificationEvent | DismissNotificationEvent,
replay: boolean
): void {
const seq = eventSequence(event)
if (seq == null || seq <= lastDeliveredSeq) {
return
}
if (watermarkBlockedThroughSeq !== null) {
if (!replay) {
watermarkBlockedThroughSeq = Math.max(watermarkBlockedThroughSeq, seq)
return
}
if (seq < watermarkBlockedThroughSeq) {
return
}
watermarkBlockedThroughSeq = null
}
lastDeliveredSeq = seq
void saveLastSeenSeq(hostId, lastDeliveredSeq)
}
function deliverLive(
event: NotificationEvent | DismissNotificationEvent,
replay = false
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
const delivery = startLocalNotificationDelivery(event, hostId)
if (!delivery) {
const seq = eventSequence(event)
if (seq !== null && seq > lastDeliveredSeq) {
// Why: advancing past a dropped event would make reconnect catch-up permanently skip it.
watermarkBlockedThroughSeq = Math.max(watermarkBlockedThroughSeq ?? seq, seq)
}
return Promise.resolve()
if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
lastDeliveredSeq = event.notificationSeq
void saveLastSeenSeq(hostId, lastDeliveredSeq)
}
// Why (#8129): only accepted work is seen; overload drops remain eligible for reconnect catch-up.
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
const key = seenKeyForEvent(event)
if (key) {
seenReplay.add(key)
}
advanceDeliveredWatermark(event, replay)
return delivery
if (type === 'notification') {
return showLocalNotification(event as NotificationEvent, hostId)
}
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap).
@@ -110,19 +272,17 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
.catch(() => [])
for (const raw of missed) {
const event = raw as NotificationEvent | DismissNotificationEvent
if (measureMobileNotificationDeliveryBytes(event, hostId) === null) {
await deliverLive(event, true)
continue
}
const key = seenKeyForEvent(event)
if (key && seenReplay.has(key)) {
advanceDeliveredWatermark(event, true)
continue
}
if (key) {
seenReplay.add(key)
}
if (event.type === 'notification') {
await deliverLive(event, true)
await deliverLive('notification', event)
} else if (event.type === 'dismiss') {
await deliverLive(event, true)
await deliverLive('dismiss', event)
}
}
}
@@ -171,9 +331,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return
}
if (event.type === 'notification') {
void deliverLive(event as NotificationEvent)
void deliverLive('notification', event as NotificationEvent)
} else if (event.type === 'dismiss') {
void deliverLive(event as DismissNotificationEvent)
void deliverLive('dismiss', event as DismissNotificationEvent)
}
})
@@ -1,127 +0,0 @@
import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
export const MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES = 256
export const MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES = 2 * 1024 * 1024
export const MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES = 8 * 1024
export type MobileScheduledNotificationState = {
identifier?: string
pending?: Promise<string | null>
dismissAfterSchedule?: boolean
retainedKeyBytes: number
retainedIdentifierBytes: number
retained: boolean
}
export type MobileScheduledNotificationReservation = {
state: MobileScheduledNotificationState
evictedIdentifiers: string[]
}
function retainedStringBytes(value: string): number {
return 64 + value.length * 2
}
export class MobileScheduledNotificationRegistry {
private readonly entries = new Map<string, MobileScheduledNotificationState>()
private retainedBytes = 0
private maxEntries = MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES
get(key: string): MobileScheduledNotificationState | undefined {
return this.entries.get(key)
}
reserve(key: string): MobileScheduledNotificationReservation | null {
const retainedKeyBytes = retainedStringBytes(key)
if (retainedKeyBytes > MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES) {
return null
}
const evictedIdentifiers: string[] = []
while (
this.entries.size >= this.maxEntries ||
this.retainedBytes + retainedKeyBytes > MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
) {
const settled = this.findOldestSettled()
if (!settled) {
return null
}
this.delete(settled[0])
if (settled[1].identifier) {
evictedIdentifiers.push(settled[1].identifier)
}
}
const state: MobileScheduledNotificationState = {
retainedKeyBytes,
retainedIdentifierBytes: 0,
retained: true
}
this.entries.set(key, state)
this.retainedBytes += retainedKeyBytes
return { state, evictedIdentifiers }
}
delete(key: string): boolean {
const state = this.entries.get(key)
if (!state) {
return false
}
this.entries.delete(key)
this.retainedBytes -= state.retainedKeyBytes + state.retainedIdentifierBytes
state.retained = false
return true
}
clearIdentifier(state: MobileScheduledNotificationState): void {
if (!state.identifier) {
return
}
if (state.retained) {
this.retainedBytes -= state.retainedIdentifierBytes
}
state.identifier = undefined
state.retainedIdentifierBytes = 0
}
retainIdentifier(state: MobileScheduledNotificationState, identifier: string): boolean {
const measurement = measureUtf8ByteLength(identifier, {
stopAfterBytes: MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES
})
if (measurement.exceededLimit || !state.retained) {
return false
}
const identifierBytes = retainedStringBytes(identifier)
if (
this.retainedBytes - state.retainedIdentifierBytes + identifierBytes >
MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
) {
return false
}
this.clearIdentifier(state)
state.identifier = identifier
state.retainedIdentifierBytes = identifierBytes
this.retainedBytes += identifierBytes
return true
}
resetForTests(maxEntries?: number): void {
for (const state of this.entries.values()) {
state.retained = false
}
this.entries.clear()
this.retainedBytes = 0
this.maxEntries = maxEntries ?? MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES
}
getRetainedBytesForTests(): number {
return this.retainedBytes
}
private findOldestSettled(): [string, MobileScheduledNotificationState] | null {
for (const entry of this.entries) {
if (!entry[1].pending) {
return entry
}
}
return null
}
}
@@ -1,6 +1,4 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
import { MOBILE_NOTIFICATION_ID_MAX_BYTES } from './mobile-notification-retention'
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
// from mobile-notifications.ts so that file stays under its max-lines budget.
@@ -86,23 +84,14 @@ export function seenKeyForEvent(event: {
notificationSeq?: number
}): string | null {
const id = event.notificationId
const seq =
typeof event.notificationSeq === 'number' && Number.isSafeInteger(event.notificationSeq)
? event.notificationSeq
: null
const retainedId =
typeof id === 'string' &&
!measureUtf8ByteLength(id, { stopAfterBytes: MOBILE_NOTIFICATION_ID_MAX_BYTES }).exceededLimit
? id
: null
if (retainedId !== null && seq !== null) {
return `id:${retainedId}#${seq}`
if (id != null && event.notificationSeq != null) {
return `id:${id}#${event.notificationSeq}`
}
if (retainedId !== null) {
return `id:${retainedId}`
if (id != null) {
return `id:${id}`
}
if (seq !== null) {
return `seq:${seq}`
if (event.notificationSeq != null) {
return `seq:${event.notificationSeq}`
}
return null
}
@@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest'
import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
describe('MobileImageBase64Accumulator', () => {
it('preserves bytes delivered as 100,000 one-byte fragments', () => {
const accumulator = new MobileImageBase64Accumulator()
const expected = Buffer.alloc(100_000)
for (let index = 0; index < expected.byteLength; index += 1) {
const value = index % 251
expected[index] = value
accumulator.append(Uint8Array.of(value))
}
expect(accumulator.finish()).toBe(expected.toString('base64'))
})
})
@@ -1,40 +0,0 @@
import { Buffer } from 'buffer'
const MOBILE_IMAGE_BASE64_CHUNK_BYTES = 256 * 1024 - 1
export class MobileImageBase64Accumulator {
private readonly staging = new Uint8Array(MOBILE_IMAGE_BASE64_CHUNK_BYTES)
private readonly encodedChunks: string[] = []
private stagingLength = 0
append(bytes: Uint8Array): void {
let offset = 0
while (offset < bytes.byteLength) {
const copied = Math.min(
this.staging.byteLength - this.stagingLength,
bytes.byteLength - offset
)
this.staging.set(bytes.subarray(offset, offset + copied), this.stagingLength)
this.stagingLength += copied
offset += copied
if (this.stagingLength === this.staging.byteLength) {
this.flushStaging()
}
}
}
finish(): string {
this.flushStaging()
return this.encodedChunks.join('')
}
private flushStaging(): void {
if (this.stagingLength === 0) {
return
}
this.encodedChunks.push(
Buffer.from(this.staging.subarray(0, this.stagingLength)).toString('base64')
)
this.stagingLength = 0
}
}
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image'
vi.mock('expo-image-picker', () => ({
requestMediaLibraryPermissionsAsync: vi.fn(),
@@ -8,9 +7,6 @@ vi.mock('expo-image-picker', () => ({
vi.mock('expo-document-picker', () => ({
getDocumentAsync: vi.fn()
}))
vi.mock('expo-file-system', () => ({
File: vi.fn()
}))
import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker'
@@ -19,48 +15,17 @@ const granted = { granted: true } as Awaited<
>
const denied = { granted: false } as typeof granted
function fileFactory(
chunks: Uint8Array[],
options?: { fileSize?: number; handleSize?: number | null; readError?: Error }
) {
const close = vi.fn()
const readBytes = vi.fn(() => {
if (options?.readError) {
throw options.readError
}
return chunks.shift() ?? new Uint8Array()
})
const open = vi.fn(() => ({
size: options?.handleSize ?? options?.fileSize ?? 0,
readBytes,
close
}))
const createFile = vi.fn(() => ({ size: options?.fileSize ?? 0, open }))
return { close, createFile, open, readBytes }
}
describe('pickMobileImage', () => {
it('returns base64 from the photo library', async () => {
const bytes = new Uint8Array([0, 1, 2, 3])
const file = fileFactory([bytes])
const launchLibrary = vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }]
})
const result = await pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
launchLibrary,
createFile: file.createFile
launchLibrary: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }]
})
})
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///x.jpg'
})
expect(launchLibrary).toHaveBeenCalledWith(
expect.objectContaining({ base64: false, allowsMultipleSelection: false })
)
expect(file.close).toHaveBeenCalledTimes(1)
expect(result).toEqual({ base64: 'AAAA', uri: 'file:///x.jpg' })
})
it('throws when photo library permission is denied', async () => {
@@ -83,25 +48,22 @@ describe('pickMobileImage', () => {
it('reads a picked file URI into base64 for the files source', async () => {
const bytes = new Uint8Array([1, 2, 3, 4])
const file = fileFactory([bytes])
const launchFiles = vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///doc.png', size: bytes.length }]
})
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } }))
const result = await pickMobileImage('files', {
launchFiles,
createFile: file.createFile
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///doc.png' }]
})
})
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///doc.png'
})
expect(launchFiles).toHaveBeenCalledWith(
expect.objectContaining({ copyToCacheDirectory: true })
)
expect(file.close).toHaveBeenCalledTimes(1)
fetchSpy.mockRestore()
})
it('returns null when the files picker is cancelled', async () => {
@@ -111,72 +73,4 @@ describe('pickMobileImage', () => {
expect(result).toBeNull()
})
it('rejects a declared oversized asset before opening it', async () => {
const file = fileFactory([], { fileSize: 1 })
await expect(
pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }]
}),
createFile: file.createFile
})
).rejects.toThrow('Clipboard image is too large')
expect(file.createFile).not.toHaveBeenCalled()
expect(file.open).not.toHaveBeenCalled()
})
it('does not let stale size metadata bypass the bounded read', async () => {
const close = vi.fn()
const readBytes = vi.fn((length: number) => new Uint8Array(length))
const createFile = vi.fn(() => ({
size: 1,
open: () => ({ size: 1, readBytes, close })
}))
await expect(
pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
launchLibrary: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///grew.png', fileSize: 1 }]
}),
createFile
})
).rejects.toThrow('Clipboard image is too large')
expect(readBytes).toHaveBeenLastCalledWith(1)
expect(close).toHaveBeenCalledTimes(1)
})
it('closes the file handle when a read fails', async () => {
const file = fileFactory([], { fileSize: 4, readError: new Error('read failed') })
await expect(
pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///broken.png', size: 4 }]
}),
createFile: file.createFile
})
).rejects.toThrow('read failed')
expect(file.close).toHaveBeenCalledTimes(1)
})
it('preserves bytes across chunk boundaries that are not base64 aligned', async () => {
const chunks = [new Uint8Array([1]), new Uint8Array([2, 3]), new Uint8Array([4, 5])]
const file = fileFactory([...chunks], { fileSize: 5, handleSize: 5 })
const result = await pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///chunked.png', size: 5 }]
}),
createFile: file.createFile
})
expect(result).toEqual({
base64: Buffer.from([1, 2, 3, 4, 5]).toString('base64'),
uri: 'file:///chunked.png'
})
expect(file.close).toHaveBeenCalledTimes(1)
})
})
@@ -1,12 +1,8 @@
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
import { Buffer } from 'buffer'
import * as DocumentPicker from 'expo-document-picker'
import { File as FsFile } from 'expo-file-system'
import * as ImagePicker from 'expo-image-picker'
import {
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
assertClipboardImageBase64LengthWithinLimit,
assertClipboardImageByteLengthWithinLimit
} from '../../../src/shared/clipboard-image'
import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
export type MobileImageSource = 'library' | 'files'
@@ -25,68 +21,18 @@ export class ImageLibraryPermissionError extends Error {
}
}
const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024
type MobileImageFileHandle = {
readonly size: number | null
readBytes(length: number): Uint8Array
close(): void
}
type MobileImageFile = {
readonly size: number
open(): MobileImageFileHandle
}
export type MobileImageFileFactory = (uri: string) => MobileImageFile
function defaultMobileImageFileFactory(uri: string): MobileImageFile {
return new FsFile(uri)
}
async function readUriAsBase64(
uri: string,
declaredSize: number | undefined,
createFile: MobileImageFileFactory
): Promise<string> {
if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) {
assertClipboardImageByteLengthWithinLimit(declaredSize)
}
const file = createFile(uri)
assertClipboardImageByteLengthWithinLimit(file.size)
const handle = file.open()
try {
if (handle.size !== null) {
assertClipboardImageByteLengthWithinLimit(handle.size)
}
const accumulator = new MobileImageBase64Accumulator()
let bytesRead = 0
while (bytesRead <= CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) {
const requested = Math.min(
MOBILE_IMAGE_READ_CHUNK_BYTES,
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES - bytesRead + 1
)
const bytes = handle.readBytes(requested)
if (bytes.byteLength === 0) {
break
}
bytesRead += bytes.byteLength
assertClipboardImageByteLengthWithinLimit(bytesRead)
accumulator.append(bytes)
}
const base64 = accumulator.finish()
assertClipboardImageBase64LengthWithinLimit(base64.length)
return base64
} finally {
handle.close()
}
// Why: expo-document-picker returns a file URI, not base64. Read it through
// fetch + Buffer so we match the base64 contract the upload pipeline expects
// without pulling in expo-file-system.
async function readUriAsBase64(uri: string): Promise<string> {
const response = await fetch(uri)
const bytes = new Uint8Array(await response.arrayBuffer())
return Buffer.from(bytes).toString('base64')
}
async function pickFromLibrary(
requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync,
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync
): Promise<PickedMobileImage | null> {
const permission = await requestPermission()
// Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
@@ -95,7 +41,7 @@ async function pickFromLibrary(
}
const result = await launch({
mediaTypes: ['images'],
base64: false,
base64: true,
allowsMultipleSelection: false,
quality: 1
})
@@ -103,7 +49,7 @@ async function pickFromLibrary(
return null
}
const asset = result.assets[0]
const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null
const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null)
if (!base64) {
return null
}
@@ -111,8 +57,7 @@ async function pickFromLibrary(
}
async function pickFromFiles(
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync,
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync
): Promise<PickedMobileImage | null> {
const result = await launch({
type: 'image/*',
@@ -126,8 +71,7 @@ async function pickFromFiles(
if (!asset?.uri) {
return null
}
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
return base64 ? { base64, uri: asset.uri } : null
return { base64: await readUriAsBase64(asset.uri), uri: asset.uri }
}
export async function pickMobileImage(
@@ -136,11 +80,10 @@ export async function pickMobileImage(
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
readonly createFile?: MobileImageFileFactory
}
): Promise<PickedMobileImage | null> {
if (source === 'library') {
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile)
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary)
}
return pickFromFiles(deps?.launchFiles, deps?.createFile)
return pickFromFiles(deps?.launchFiles)
}
@@ -1,128 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
MobileSessionFileDocLifecycle,
beginMobileFileDocLoad,
createMobileFileDocLifecycle,
finishMobileFileDocLoad,
forgetMobileFileDocTab,
reconcileMobileFileDocTabs,
removeMobileFileDoc,
resetMobileFileDocLifecycle,
retainLiveMobileFileDocs,
type MobileFileTabIdentity
} from './mobile-session-file-doc-lifecycle'
import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc'
type TestFileDoc = MobileFileTabDoc | { status: 'loading' } | { status: 'error'; message: string }
function fileTab(id: string, relativePath = `${id}.ts`): MobileFileTabIdentity {
return {
id,
filePath: `/repo/${relativePath}`,
relativePath,
mode: 'edit'
}
}
describe('mobile session file document lifecycle', () => {
it('retains payloads only for file tabs in the accepted live snapshot', () => {
const lifecycle = createMobileFileDocLifecycle()
reconcileMobileFileDocTabs(lifecycle, [fileTab('live-a'), fileTab('live-b')])
const docs = new Map([
['closed', { payload: 'x'.repeat(1_000_000) }],
['live-a', { payload: 'a' }],
['live-b', { payload: 'b' }]
])
const retained = retainLiveMobileFileDocs(docs, lifecycle)
expect([...retained.keys()]).toEqual(['live-a', 'live-b'])
expect(retained.get('live-a')).toBe(docs.get('live-a'))
})
it('deletes a successfully closed tab immediately', () => {
const lifecycle = createMobileFileDocLifecycle()
reconcileMobileFileDocTabs(lifecycle, [fileTab('closed')])
forgetMobileFileDocTab(lifecycle, 'closed')
expect(removeMobileFileDoc(new Map([['closed', { payload: 'large' }]]), 'closed').size).toBe(0)
})
it('rejects a late read after its tab closes', () => {
const lifecycle = createMobileFileDocLifecycle()
const tab = fileTab('file')
reconcileMobileFileDocTabs(lifecycle, [tab])
const token = beginMobileFileDocLoad(lifecycle, tab)
expect(token).not.toBeNull()
forgetMobileFileDocTab(lifecycle, tab.id)
expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false)
})
it('keeps route state empty when a read resolves after close', async () => {
const lifecycle = new MobileSessionFileDocLifecycle()
const tab = fileTab('file')
let docs = new Map<string, TestFileDoc>()
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
docs = update(docs)
}
lifecycle.reconcile([{ ...tab, type: 'file' }], updateDocs)
let resolveRead: (doc: MobileFileTabDoc) => void = () => undefined
const read = new Promise<MobileFileTabDoc>((resolve) => {
resolveRead = resolve
})
const pending = lifecycle.load(tab, updateDocs, () => read)
expect(docs.get(tab.id)).toEqual({ status: 'loading' })
lifecycle.close(tab.id, updateDocs)
resolveRead({ status: 'ready', kind: 'file', content: 'late', truncated: false, byteLength: 4 })
await pending
expect(docs.size).toBe(0)
})
it('rejects a late read when the same tab id is replaced with another file', () => {
const lifecycle = createMobileFileDocLifecycle()
const oldTab = fileTab('file', 'old.ts')
reconcileMobileFileDocTabs(lifecycle, [oldTab])
const token = beginMobileFileDocLoad(lifecycle, oldTab)
expect(token).not.toBeNull()
const replaced = reconcileMobileFileDocTabs(lifecycle, [fileTab('file', 'replacement.ts')])
expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false)
expect(
retainLiveMobileFileDocs(
new Map([['file', { payload: 'old contents' }]]),
lifecycle,
replaced
)
).toEqual(new Map())
})
it('lets only the newest read for a live tab commit', () => {
const lifecycle = createMobileFileDocLifecycle()
const tab = fileTab('file')
reconcileMobileFileDocTabs(lifecycle, [tab])
const first = beginMobileFileDocLoad(lifecycle, tab)
const second = beginMobileFileDocLoad(lifecycle, tab)
expect(finishMobileFileDocLoad(lifecycle, first!)).toBe(false)
expect(finishMobileFileDocLoad(lifecycle, second!)).toBe(true)
})
it('invalidates requests when the route scope resets even if tab ids are reused', () => {
const lifecycle = createMobileFileDocLifecycle()
const tab = fileTab('reused')
reconcileMobileFileDocTabs(lifecycle, [tab])
const oldScope = beginMobileFileDocLoad(lifecycle, tab)
resetMobileFileDocLifecycle(lifecycle)
reconcileMobileFileDocTabs(lifecycle, [tab])
const newScope = beginMobileFileDocLoad(lifecycle, tab)
expect(finishMobileFileDocLoad(lifecycle, oldScope!)).toBe(false)
expect(finishMobileFileDocLoad(lifecycle, newScope!)).toBe(true)
})
})
@@ -1,242 +0,0 @@
import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc'
export type MobileFileTabIdentity = {
id: string
filePath: string
relativePath: string
mode?: 'edit' | 'diff'
diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
}
export type MobileFileDocLoadToken = {
generation: number
requestId: number
tabId: string
tabIdentity: string
}
export type MobileFileDocLifecycle = {
generation: number
nextRequestId: number
liveTabIdentityById: Map<string, string>
activeRequestIdByTabId: Map<string, number>
}
type MobileFileTabCandidate = {
type: string
id: string
filePath?: unknown
relativePath?: unknown
mode?: unknown
diffSource?: unknown
}
type MobileSessionFileDoc =
| MobileFileTabDoc
| { status: 'loading' }
| { status: 'error'; message: string }
type MobileSessionFileDocUpdater = (
update: (current: Map<string, MobileSessionFileDoc>) => Map<string, MobileSessionFileDoc>
) => void
export class MobileSessionFileDocLifecycle {
private readonly lifecycle = createMobileFileDocLifecycle()
reconcile(
tabs: readonly MobileFileTabCandidate[],
updateDocs: MobileSessionFileDocUpdater
): void {
const replacedTabIds = reconcileMobileFileDocTabs(
this.lifecycle,
tabs.filter(isMobileFileTabIdentity)
)
updateDocs((current) => retainLiveMobileFileDocs(current, this.lifecycle, replacedTabIds))
}
async load(
tab: MobileFileTabIdentity,
updateDocs: MobileSessionFileDocUpdater,
read: () => Promise<MobileFileTabDoc>
): Promise<void> {
const token = beginMobileFileDocLoad(this.lifecycle, tab)
if (!token) {
return
}
updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' }))
try {
const doc = await read()
if (finishMobileFileDocLoad(this.lifecycle, token)) {
updateDocs((current) => new Map(current).set(tab.id, doc))
}
} catch (error) {
if (finishMobileFileDocLoad(this.lifecycle, token)) {
updateDocs((current) =>
new Map(current).set(tab.id, {
status: 'error',
message: getMobileFileDocLoadErrorMessage(tab, error)
})
)
}
}
}
close(tabId: string, updateDocs: MobileSessionFileDocUpdater): void {
forgetMobileFileDocTab(this.lifecycle, tabId)
updateDocs((current) => removeMobileFileDoc(current, tabId))
}
reset(): void {
resetMobileFileDocLifecycle(this.lifecycle)
}
}
export function createMobileFileDocLifecycle(): MobileFileDocLifecycle {
return {
generation: 0,
nextRequestId: 0,
liveTabIdentityById: new Map(),
activeRequestIdByTabId: new Map()
}
}
export function reconcileMobileFileDocTabs(
lifecycle: MobileFileDocLifecycle,
tabs: readonly MobileFileTabIdentity[]
): ReadonlySet<string> {
const nextIdentityById = new Map(tabs.map((tab) => [tab.id, getMobileFileTabIdentity(tab)]))
const replacedTabIds = new Set<string>()
for (const tabId of lifecycle.activeRequestIdByTabId.keys()) {
const nextIdentity = nextIdentityById.get(tabId)
const currentIdentity = lifecycle.liveTabIdentityById.get(tabId)
if (!nextIdentity || nextIdentity !== currentIdentity) {
lifecycle.activeRequestIdByTabId.delete(tabId)
}
}
for (const [tabId, currentIdentity] of lifecycle.liveTabIdentityById) {
const nextIdentity = nextIdentityById.get(tabId)
if (nextIdentity && nextIdentity !== currentIdentity) {
replacedTabIds.add(tabId)
}
}
lifecycle.liveTabIdentityById.clear()
for (const [tabId, identity] of nextIdentityById) {
lifecycle.liveTabIdentityById.set(tabId, identity)
}
return replacedTabIds
}
export function beginMobileFileDocLoad(
lifecycle: MobileFileDocLifecycle,
tab: MobileFileTabIdentity
): MobileFileDocLoadToken | null {
const tabIdentity = getMobileFileTabIdentity(tab)
if (lifecycle.liveTabIdentityById.get(tab.id) !== tabIdentity) {
return null
}
const requestId = ++lifecycle.nextRequestId
lifecycle.activeRequestIdByTabId.set(tab.id, requestId)
return {
generation: lifecycle.generation,
requestId,
tabId: tab.id,
tabIdentity
}
}
export function finishMobileFileDocLoad(
lifecycle: MobileFileDocLifecycle,
token: MobileFileDocLoadToken
): boolean {
if (!isCurrentMobileFileDocLoad(lifecycle, token)) {
return false
}
lifecycle.activeRequestIdByTabId.delete(token.tabId)
return true
}
export function forgetMobileFileDocTab(lifecycle: MobileFileDocLifecycle, tabId: string): void {
lifecycle.liveTabIdentityById.delete(tabId)
lifecycle.activeRequestIdByTabId.delete(tabId)
}
export function resetMobileFileDocLifecycle(lifecycle: MobileFileDocLifecycle): void {
lifecycle.generation += 1
lifecycle.liveTabIdentityById.clear()
lifecycle.activeRequestIdByTabId.clear()
}
export function retainLiveMobileFileDocs<T>(
docs: Map<string, T>,
lifecycle: MobileFileDocLifecycle,
replacedTabIds?: ReadonlySet<string>
): Map<string, T> {
let next: Map<string, T> | null = null
for (const tabId of docs.keys()) {
if (lifecycle.liveTabIdentityById.has(tabId) && !replacedTabIds?.has(tabId)) {
continue
}
next ??= new Map(docs)
next.delete(tabId)
}
return next ?? docs
}
export function removeMobileFileDoc<T>(docs: Map<string, T>, tabId: string): Map<string, T> {
if (!docs.has(tabId)) {
return docs
}
const next = new Map(docs)
next.delete(tabId)
return next
}
function isCurrentMobileFileDocLoad(
lifecycle: MobileFileDocLifecycle,
token: MobileFileDocLoadToken
): boolean {
return (
lifecycle.generation === token.generation &&
lifecycle.liveTabIdentityById.get(token.tabId) === token.tabIdentity &&
lifecycle.activeRequestIdByTabId.get(token.tabId) === token.requestId
)
}
function getMobileFileTabIdentity(tab: MobileFileTabIdentity): string {
return JSON.stringify([
tab.id,
tab.filePath,
tab.relativePath,
tab.mode ?? '',
tab.diffSource ?? ''
])
}
function isMobileFileTabIdentity(
tab: MobileFileTabCandidate
): tab is MobileFileTabCandidate & MobileFileTabIdentity {
return (
tab.type === 'file' &&
typeof tab.filePath === 'string' &&
typeof tab.relativePath === 'string' &&
(tab.mode === undefined || tab.mode === 'edit' || tab.mode === 'diff') &&
(tab.diffSource === undefined ||
tab.diffSource === 'staged' ||
tab.diffSource === 'unstaged' ||
tab.diffSource === 'branch' ||
tab.diffSource === 'commit')
)
}
function getMobileFileDocLoadErrorMessage(tab: MobileFileTabIdentity, error: unknown): string {
const message = error instanceof Error ? error.message : ''
if (message === 'binary_file') {
return 'Binary preview unavailable'
}
if (message === 'file_too_large') {
return 'File too large for mobile preview'
}
return tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
? "Couldn't load diff preview"
: "Couldn't load file preview"
}
@@ -1,106 +0,0 @@
import { describe, expect, it } from 'vitest'
import type {
MarkdownDocState,
MobileSessionTab
} from '../../app/h/[hostId]/session/mobile-session-route-types'
import { MobileSessionMarkdownDocLifecycle } from './mobile-session-markdown-doc-lifecycle'
type MarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
type ReadyMarkdownDoc = Extract<MarkdownDocState, { status: 'ready' }>
function markdownTab(id: string, relativePath = `${id}.md`): MarkdownTab {
return {
type: 'markdown',
id,
title: relativePath,
filePath: `/repo/${relativePath}`,
relativePath,
isDirty: false,
isActive: false,
documentVersion: 'v1'
}
}
function readyDoc(content: string, isDirty = false): ReadyMarkdownDoc {
return {
status: 'ready',
content,
localContent: content,
baseVersion: 'v1',
isDirty,
editable: true
}
}
describe('mobile session markdown document lifecycle', () => {
it('retains payloads only for markdown tabs in the accepted snapshot', () => {
const lifecycle = new MobileSessionMarkdownDocLifecycle()
let docs = new Map<string, MarkdownDocState>([
['closed', readyDoc('x'.repeat(1_000_000))],
['live', readyDoc('live')]
])
lifecycle.reconcile([markdownTab('live')], (update) => {
docs = update(docs)
})
expect([...docs.keys()]).toEqual(['live'])
})
it('retains a dirty orphan when reconciliation keeps its draft tab', () => {
const lifecycle = new MobileSessionMarkdownDocLifecycle()
let docs = new Map<string, MarkdownDocState>([['draft', readyDoc('unsaved', true)]])
lifecycle.reconcile([markdownTab('draft')], (update) => {
docs = update(docs)
})
expect(docs.get('draft')).toEqual(readyDoc('unsaved', true))
})
it('does not let a late read resurrect a closed tab', async () => {
const lifecycle = new MobileSessionMarkdownDocLifecycle()
const tab = markdownTab('late')
let docs = new Map<string, MarkdownDocState>()
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
docs = update(docs)
}
lifecycle.reconcile([tab], updateDocs)
let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined
const read = new Promise<ReadyMarkdownDoc>((resolve) => {
resolveRead = resolve
})
const pending = lifecycle.load(tab, updateDocs, () => read)
lifecycle.close(tab.id, updateDocs)
resolveRead(readyDoc('late payload'))
await pending
expect(docs.size).toBe(0)
})
it('does not let an old path overwrite a replacement using the same tab id', async () => {
const lifecycle = new MobileSessionMarkdownDocLifecycle()
const oldTab = markdownTab('same', 'old.md')
let docs = new Map<string, MarkdownDocState>()
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
docs = update(docs)
}
lifecycle.reconcile([oldTab], updateDocs)
let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined
const pending = lifecycle.load(
oldTab,
updateDocs,
() =>
new Promise<ReadyMarkdownDoc>((resolve) => {
resolveRead = resolve
})
)
lifecycle.reconcile([markdownTab('same', 'new.md')], updateDocs)
resolveRead(readyDoc('old payload'))
await pending
expect(docs.size).toBe(0)
})
})
@@ -1,147 +0,0 @@
import type {
MarkdownDocState,
MobileSessionTab
} from '../../app/h/[hostId]/session/mobile-session-route-types'
type MobileMarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
type MobileMarkdownDocUpdater = (
update: (current: Map<string, MarkdownDocState>) => Map<string, MarkdownDocState>
) => void
type MobileMarkdownDocLoadToken = {
generation: number
requestId: number
tabId: string
tabIdentity: string
}
export class MobileSessionMarkdownDocLifecycle {
private generation = 0
private nextRequestId = 0
private readonly liveTabIdentityById = new Map<string, string>()
private readonly activeRequestIdByTabId = new Map<string, number>()
reconcile(tabs: readonly MobileSessionTab[], updateDocs: MobileMarkdownDocUpdater): void {
const nextIdentityById = new Map(
tabs
.filter((tab): tab is MobileMarkdownTab => tab.type === 'markdown')
.map((tab) => [tab.id, markdownTabIdentity(tab)])
)
const replacedTabIds = new Set<string>()
for (const [tabId, currentIdentity] of this.liveTabIdentityById) {
const nextIdentity = nextIdentityById.get(tabId)
if (nextIdentity && nextIdentity !== currentIdentity) {
replacedTabIds.add(tabId)
}
}
for (const tabId of this.activeRequestIdByTabId.keys()) {
if (nextIdentityById.get(tabId) !== this.liveTabIdentityById.get(tabId)) {
this.activeRequestIdByTabId.delete(tabId)
}
}
this.liveTabIdentityById.clear()
for (const [tabId, identity] of nextIdentityById) {
this.liveTabIdentityById.set(tabId, identity)
}
updateDocs((current) => retainLiveMarkdownDocs(current, nextIdentityById, replacedTabIds))
}
async load(
tab: MobileMarkdownTab,
updateDocs: MobileMarkdownDocUpdater,
read: () => Promise<Extract<MarkdownDocState, { status: 'ready' }>>
): Promise<void> {
const token = this.beginLoad(tab)
if (!token) {
return
}
updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' }))
try {
const doc = await read()
if (this.finishLoad(token)) {
updateDocs((current) => new Map(current).set(tab.id, doc))
}
} catch {
if (this.finishLoad(token)) {
updateDocs((current) =>
new Map(current).set(tab.id, {
status: 'error',
message: "Couldn't load markdown"
})
)
}
}
}
close(tabId: string, updateDocs: MobileMarkdownDocUpdater): void {
this.liveTabIdentityById.delete(tabId)
this.activeRequestIdByTabId.delete(tabId)
updateDocs((current) => removeMarkdownDoc(current, tabId))
}
reset(): void {
this.generation += 1
this.liveTabIdentityById.clear()
this.activeRequestIdByTabId.clear()
}
private beginLoad(tab: MobileMarkdownTab): MobileMarkdownDocLoadToken | null {
const tabIdentity = markdownTabIdentity(tab)
if (this.liveTabIdentityById.get(tab.id) !== tabIdentity) {
return null
}
const requestId = ++this.nextRequestId
this.activeRequestIdByTabId.set(tab.id, requestId)
return {
generation: this.generation,
requestId,
tabId: tab.id,
tabIdentity
}
}
private finishLoad(token: MobileMarkdownDocLoadToken): boolean {
if (
token.generation !== this.generation ||
this.liveTabIdentityById.get(token.tabId) !== token.tabIdentity ||
this.activeRequestIdByTabId.get(token.tabId) !== token.requestId
) {
return false
}
this.activeRequestIdByTabId.delete(token.tabId)
return true
}
}
function retainLiveMarkdownDocs(
docs: Map<string, MarkdownDocState>,
liveIdentityById: ReadonlyMap<string, string>,
replacedTabIds: ReadonlySet<string>
): Map<string, MarkdownDocState> {
let next: Map<string, MarkdownDocState> | null = null
for (const tabId of docs.keys()) {
if (liveIdentityById.has(tabId) && !replacedTabIds.has(tabId)) {
continue
}
next ??= new Map(docs)
next.delete(tabId)
}
return next ?? docs
}
function removeMarkdownDoc(
docs: Map<string, MarkdownDocState>,
tabId: string
): Map<string, MarkdownDocState> {
if (!docs.has(tabId)) {
return docs
}
const next = new Map(docs)
next.delete(tabId)
return next
}
function markdownTabIdentity(tab: MobileMarkdownTab): string {
return JSON.stringify([tab.id, tab.filePath, tab.relativePath])
}
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
MobileTerminalDiagnostics,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
@@ -44,28 +43,4 @@ describe('mobile terminal diagnostics', () => {
expect(log).toHaveBeenCalledTimes(2)
log.mockRestore()
})
it('accepts the exact diagnostic handle cap and evicts oldest at one over', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const diagnostics = new MobileTerminalDiagnostics()
for (let index = 0; index < MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES; index += 1) {
diagnostics.streamSkipped(`terminal-${index}`, 'inactive', false)
diagnostics.firstStreamEvent(`terminal-${index}`, 1, 'subscribed')
}
expect(diagnostics.retainedHandleCountsForTests()).toEqual({
streamGates: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
firstEvents: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES
})
diagnostics.streamSkipped('one-over', 'inactive', false)
diagnostics.firstStreamEvent('one-over', 1, 'subscribed')
expect(diagnostics.retainedHandleCountsForTests()).toEqual({
streamGates: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
firstEvents: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES
})
diagnostics.streamSkipped('terminal-0', 'inactive', false)
diagnostics.firstStreamEvent('terminal-0', 1, 'subscribed')
expect(log).toHaveBeenCalledTimes(MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES * 2 + 4)
log.mockRestore()
})
})
@@ -1,5 +1,4 @@
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
export const MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES = 1_024
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
@@ -91,7 +90,7 @@ export class MobileTerminalDiagnostics {
if (this.streamGateByHandle.get(handle) === reason) {
return
}
setBoundedHandleState(this.streamGateByHandle, handle, reason)
this.streamGateByHandle.set(handle, reason)
logMobileTerminalDiagnostic('stream-skipped', {
handle: shortenMobileTerminalDiagnosticId(handle),
reason,
@@ -114,7 +113,7 @@ export class MobileTerminalDiagnostics {
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
return
}
setBoundedHandleState(this.firstStreamEventSeqByHandle, handle, seq)
this.firstStreamEventSeqByHandle.set(handle, seq)
logMobileTerminalDiagnostic('stream-first-event', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
@@ -268,14 +267,6 @@ export class MobileTerminalDiagnostics {
})
}
/** Test-only evidence for process-retained diagnostic dedupe state. */
retainedHandleCountsForTests(): { streamGates: number; firstEvents: number } {
return {
streamGates: this.streamGateByHandle.size,
firstEvents: this.firstStreamEventSeqByHandle.size
}
}
private logTabs(
event: 'tabs-applied' | 'tabs-fetch-success',
snapshot: DiagnosticTabsSnapshot,
@@ -298,15 +289,3 @@ export class MobileTerminalDiagnostics {
})
}
}
function setBoundedHandleState<T>(map: Map<string, T>, handle: string, value: T): void {
map.delete(handle)
map.set(handle, value)
while (map.size > MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES) {
const oldestHandle = map.keys().next().value
if (typeof oldestHandle !== 'string') {
return
}
map.delete(oldestHandle)
}
}
+1 -34
View File
@@ -1,9 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import {
MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS,
PrActionsEngine,
type PrActionMutations
} from './pr-actions-engine'
import { PrActionsEngine, type PrActionMutations } from './pr-actions-engine'
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
function deferred<T>() {
@@ -242,32 +238,3 @@ describe('PrActionsEngine — PR identity changes', () => {
expect(refetch).not.toHaveBeenCalled()
})
})
describe('PrActionsEngine — reviewer retention', () => {
it('releases settled reviewer fields so sequential reviewer actions do not accumulate', async () => {
const engine = makeEngine({})
for (let index = 0; index < MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS + 1; index += 1) {
await engine.requestReviewer(`reviewer-${index}`)
}
expect(engine.retainedReviewerFieldCountForTests()).toBe(0)
})
it('accepts the exact concurrent reviewer cap and rejects one over', async () => {
const pending = deferred<GitHubPrMutationOutcome>()
const engine = makeEngine({ requestReviewers: () => pending.promise })
const actions = Array.from({ length: MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS }, (_, index) =>
engine.requestReviewer(`reviewer-${index}`)
)
expect(engine.retainedReviewerFieldCountForTests()).toBe(MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS)
await expect(engine.requestReviewer('one-over')).rejects.toThrow(
'Too many reviewer actions are pending'
)
pending.resolve({ ok: true })
await Promise.all(actions)
expect(engine.retainedReviewerFieldCountForTests()).toBe(0)
})
})
-23
View File
@@ -8,9 +8,6 @@ import type { PrActionMutations } from './pr-action-mutation-contract'
export type { PrActionMutations } from './pr-action-mutation-contract'
export const MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS = 64
const MOBILE_PR_ACTIONS_MAX_REVIEWER_LOGIN_CHARACTERS = 256
// Pure (React-free) engine for the PR mutation actions: owns optimistic fields,
// busy/error/blocked state, and the success/transient/permanent routing. The hook
// is a thin adapter that subscribes to `onChange` and exposes these methods. Kept
@@ -99,25 +96,12 @@ export class PrActionsEngine {
private reviewerField(login: string): OptimisticField<boolean> {
let f = this.reviewerFields.get(login)
if (!f) {
if (
login.length === 0 ||
login.length > MOBILE_PR_ACTIONS_MAX_REVIEWER_LOGIN_CHARACTERS ||
this.reviewerFields.size >= MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS
) {
throw new Error('Too many reviewer actions are pending')
}
f = createOptimisticField<boolean>(this.cfg.onChange)
this.reviewerFields.set(login, f)
}
return f
}
private releaseReviewerFieldIfIdle(login: string, field: OptimisticField<boolean>): void {
if (field.peek() === undefined && this.reviewerFields.get(login) === field) {
this.reviewerFields.delete(login)
}
}
// Why: action start pairs setBusy + setError(null); skip notify when unchanged
// so we don't force a full PR panel re-render for free.
private setBusy(key: PrActionBusyKey | null): void {
@@ -280,7 +264,6 @@ export class PrActionsEngine {
})
} finally {
this.clearBusyIfOwned(identity, { kind: 'reviewer', login })
this.releaseReviewerFieldIfIdle(login, field)
}
}
@@ -303,7 +286,6 @@ export class PrActionsEngine {
})
} finally {
this.clearBusyIfOwned(identity, { kind: 'reviewer', login })
this.releaseReviewerFieldIfIdle(login, field)
}
}
@@ -337,9 +319,4 @@ export class PrActionsEngine {
const f = this.reviewerFields.get(login)
return f ? f.resolve(authoritative) : authoritative
}
/** Test-only evidence for retained optimistic reviewer state. */
retainedReviewerFieldCountForTests(): number {
return this.reviewerFields.size
}
}
@@ -2,10 +2,7 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
retainMobileNativeChatFilePaths,
useMobileNativeChatFileSearch
} from './use-mobile-native-chat-file-search'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
type SearchState = ReturnType<typeof useMobileNativeChatFileSearch>
@@ -166,15 +163,3 @@ describe('useMobileNativeChatFileSearch', () => {
expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md'])
})
})
describe('retainMobileNativeChatFilePaths', () => {
it('accepts exact count and retained-byte limits and truncates at one over', () => {
const result = {
files: [{ relativePath: 'a' }, { relativePath: 'bb' }, { relativePath: 'ignored' }]
}
const exactBytes = 'a'.length * 2 + 64 + ('bb'.length * 2 + 64)
expect(retainMobileNativeChatFilePaths(result, 2, exactBytes)).toEqual(['a', 'bb'])
expect(retainMobileNativeChatFilePaths(result, 3, exactBytes - 1)).toEqual(['a'])
})
})
@@ -5,31 +5,12 @@ import { rankSuggestions } from './mobile-native-chat-autocomplete'
const FILE_SEARCH_DEBOUNCE_MS = 120
const FILE_SEARCH_RESULT_LIMIT = 16
const FILE_SEARCH_QUERY_CACHE_LIMIT = 20
export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS = 50_000
export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES = 8 * 1024 * 1024
const FILE_SEARCH_RESULT_MAX_RETAINED_BYTES = 256 * 1024
export function retainMobileNativeChatFilePaths(
result: unknown,
maxPaths: number,
maxRetainedBytes: number
): string[] {
function extractPaths(result: unknown): string[] {
const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? []
const paths: string[] = []
let retainedBytes = 0
for (const file of files) {
const path = file.relativePath
if (typeof path !== 'string' || path.length === 0) {
continue
}
const nextBytes = retainedBytes + path.length * 2 + 64
if (paths.length >= maxPaths || nextBytes > maxRetainedBytes) {
break
}
paths.push(path)
retainedBytes = nextBytes
}
return paths
return files
.map((file) => file.relativePath ?? '')
.filter((path): path is string => path.length > 0)
}
/** Debounces current-host path searches, bounds the mobile result/cache, and
@@ -113,11 +94,7 @@ export function useMobileNativeChatFileSearch(args: {
if (!response.ok || generationRef.current !== generation) {
return null
}
const paths = retainMobileNativeChatFilePaths(
response.result,
MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS,
MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES
)
const paths = extractPaths(response.result)
legacyPathsRef.current = paths
return paths
})
@@ -152,13 +129,7 @@ export function useMobileNativeChatFileSearch(args: {
})
if (response.ok) {
searchSupportedRef.current = true
applyPaths(
retainMobileNativeChatFilePaths(
response.result,
FILE_SEARCH_RESULT_LIMIT,
FILE_SEARCH_RESULT_MAX_RETAINED_BYTES
)
)
applyPaths(extractPaths(response.result))
return
}
if (response.error.code === 'method_not_found') {
@@ -31,7 +31,10 @@ function sendResult(accepted: boolean): RpcSuccess {
return { id: 'send', ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
}
function makeClient(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
function makeClient(responses: (RpcResponse | Promise<RpcResponse>)[]): Pick<
RpcClient,
'sendRequest'
> & {
calls: { method: string; params: Record<string, unknown> }[]
} {
const calls: { method: string; params: Record<string, unknown> }[] = []
@@ -52,6 +55,7 @@ type HookArgs = Parameters<typeof useMobileNativeChatImageAttachments>[0]
type Hook = ReturnType<typeof useMobileNativeChatImageAttachments>
const SCOPE_A = 'h\0w\0tab-a'
const SCOPE_B = 'h\0w\0tab-b'
function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): HookArgs {
return {
@@ -403,6 +407,14 @@ describe('useMobileNativeChatImageAttachments', () => {
await act(async () => {
await hook!.attachImage('library')
})
let overlappingAccepted = true
await act(async () => {
overlappingAccepted = await hook!.sendNativeChat('too soon')
})
expect(overlappingAccepted).toBe(false)
expect(baseSend).not.toHaveBeenCalled()
expect(client.calls.filter((call) => call.method === 'terminal.send')).toHaveLength(2)
await act(async () => {
releaseSettle!()
await sendPromise
@@ -498,6 +510,159 @@ describe('useMobileNativeChatImageAttachments', () => {
expect(baseSend).toHaveBeenCalledWith('hi again')
})
it('retains the stale marker when a rejected healing clear blocks text-only send', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
sendResult(true), // Ctrl+U clear
sendResult(true), // image paste accepted
sendResult(false), // first healing Ctrl+U rejected
sendResult(true) // retry healing Ctrl+U accepted
])
const baseSend = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
await act(async () => {
await hook!.attachImage('library')
})
await act(async () => {
await hook!.sendNativeChat('hi')
})
expect(hook!.attachments).toHaveLength(1)
await act(async () => {
hook!.removeAttachment('img-1')
})
let accepted = true
await act(async () => {
accepted = await hook!.sendNativeChat('hi again')
})
expect(accepted).toBe(false)
expect(baseSend).toHaveBeenCalledTimes(1)
await act(async () => {
accepted = await hook!.sendNativeChat('hi again')
})
expect(accepted).toBe(true)
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
expect(sendCalls).toHaveLength(4)
expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
expect(sendCalls[3]?.params).toMatchObject({ text: '\x15', enter: false })
expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg'])
expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again')
})
it('does not reroute text when the active terminal changes during a healing clear', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
let releaseClear: ((response: RpcResponse) => void) | null = null
const deferredClear = new Promise<RpcResponse>((resolve) => {
releaseClear = resolve
})
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
sendResult(true),
sendResult(true),
deferredClear
])
const baseSend = vi.fn().mockResolvedValueOnce(false)
const activeHandleRef = { current: 'term-1' }
mount(baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend }))
await act(async () => {
await hook!.attachImage('library')
})
await act(async () => {
await hook!.sendNativeChat('hi')
})
await act(async () => {
hook!.removeAttachment('img-1')
})
let retry: Promise<boolean> | null = null
await act(async () => {
retry = hook!.sendNativeChat('hi again')
await Promise.resolve()
})
activeHandleRef.current = 'term-2'
let accepted = true
await act(async () => {
releaseClear!(sendResult(true))
accepted = await retry!
})
expect(accepted).toBe(false)
expect(baseSend).toHaveBeenCalledTimes(1)
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false })
})
it('heals rejected image submits independently across terminals', async () => {
pick
.mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' })
.mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' })
const client = makeClient([
methodNotFound('start-a'),
ok('save-a', '/tmp/a.png'),
sendResult(true),
sendResult(true),
methodNotFound('start-b'),
ok('save-b', '/tmp/b.png'),
sendResult(true),
sendResult(true),
sendResult(true),
sendResult(true)
])
const baseSend = vi
.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
const activeHandleRef = { current: 'term-1' }
const args = baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend })
mount(args)
await act(async () => {
await hook!.attachImage('library')
})
await act(async () => {
await hook!.sendNativeChat('first')
})
activeHandleRef.current = 'term-2'
update({ ...args, scopeKey: SCOPE_B })
await act(async () => {
await hook!.attachImage('library')
})
await act(async () => {
await hook!.sendNativeChat('second')
})
await act(async () => {
hook!.removeAttachment('img-2')
})
activeHandleRef.current = 'term-1'
update(args)
await act(async () => {
hook!.removeAttachment('img-1')
})
await act(async () => {
expect(await hook!.sendNativeChat('retry first')).toBe(true)
})
activeHandleRef.current = 'term-2'
update({ ...args, scopeKey: SCOPE_B })
await act(async () => {
expect(await hook!.sendNativeChat('retry second')).toBe(true)
})
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
expect(sendCalls.slice(4).map((call) => call.params)).toMatchObject([
{ terminal: 'term-1', text: '\x15', enter: false },
{ terminal: 'term-2', text: '\x15', enter: false }
])
expect(baseSend).toHaveBeenCalledTimes(4)
})
it('reports a disconnected attach failure via the live connection state', async () => {
const client = makeClient([])
const showToast = vi.fn()
@@ -71,6 +71,10 @@ function withScopeAttachments(
return remaining
}
function markTerminalInputStale(staleInputs: Set<string>, terminal: string): void {
staleInputs.add(terminal)
}
const defaultSleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
@@ -99,9 +103,10 @@ export function useMobileNativeChatImageAttachments({
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
const connStateRef = useRef(connState)
connStateRef.current = connState
// Terminal whose input line may hold a partial paste from a failed send; the
// next send TO THAT terminal must lead with Ctrl+U even if it has no images.
const staleInputTerminalRef = useRef<string | null>(null)
// Terminals whose input may hold a failed paste; each must heal independently.
const staleInputTerminalsRef = useRef(new Set<string>())
// Serialize clear/paste/submit ownership per terminal while allowing other tabs to send.
const sendInFlightTerminalsRef = useRef(new Set<string>())
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
@@ -192,92 +197,120 @@ export function useMobileNativeChatImageAttachments({
const sendNativeChat = useCallback(
async (text: string): Promise<boolean> => {
const scope = scopeKey
const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
if (pendingImages.length === 0 || !scope) {
// Heal a previously failed paste: a text-only send to that terminal would
// otherwise glue the stale image paste onto this message. Best-effort —
// on failure the marker stays set and the send proceeds as before.
const staleTerminal = staleInputTerminalRef.current
if (staleTerminal && staleTerminal === activeHandleRef.current && client) {
try {
await pasteMobileNativeChatImagePaths({
client,
terminal: staleTerminal,
deviceToken: deviceTokenRef.current,
imagePaths: []
})
staleInputTerminalRef.current = null
} catch {
// Leave marked for the next attempt.
}
}
return baseSend(text)
}
const handle = activeHandleRef.current
if (!client || !handle || !enabled || connState !== 'connected') {
onError?.()
// Mirror the text path's failure surface (the base send is never reached).
showToast('Message not sent (disconnected)', 1500)
return false
}
try {
const pasted = await pasteMobileNativeChatImagePaths({
client,
terminal: handle,
deviceToken: deviceTokenRef.current,
imagePaths: pendingImages.map((attachment) => attachment.path)
})
if (!pasted) {
// Keep the chips so the user can retry; the failed paste never submitted.
staleInputTerminalRef.current = handle
onError?.()
showToast('Message not sent', 1500)
return false
}
// The paste's leading Ctrl+U cleared any earlier stale input in `handle`.
if (staleInputTerminalRef.current === handle) {
staleInputTerminalRef.current = null
}
// Let the TUI absorb the image paste before the text + Enter follow. The
// preview URIs ride along to baseSend so the sent bubble shows the photo
// immediately (empty text still submits a bare Enter through baseSend).
await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
// The paste above targeted `handle`; a tab switch during the settle would
// route the text + Enter to a different terminal than the images. Abort —
// the chips keep their scope and a retry's Ctrl+U clears the stale paste.
if (activeHandleRef.current !== handle) {
staleInputTerminalRef.current = handle
onError?.()
showToast('Message not sent', 1500)
return false
}
const accepted = await baseSend(
text,
pendingImages.map((attachment) => attachment.previewUri)
)
if (accepted) {
// Drop only what rode along — a chip attached while this send was in
// flight keeps waiting for its own send.
const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
setAttachmentsByScope((prev) =>
withScopeAttachments(
prev,
scope,
(prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
)
)
}
return accepted
} catch {
// A thrown paste/send (network/RPC) keeps the chips and honors the
// Promise<boolean> contract instead of rejecting. Retry-safe: the next
// attempt's leading Ctrl+U clears whatever fraction of the paste landed.
staleInputTerminalRef.current = handle
const operationTerminal = activeHandleRef.current
if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) {
onError?.()
showToast('Message not sent', 1500)
return false
}
if (operationTerminal) {
sendInFlightTerminalsRef.current.add(operationTerminal)
}
try {
const scope = scopeKey
const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
if (pendingImages.length === 0 || !scope) {
// Heal a previously failed paste: a text-only send to that terminal would
// otherwise glue the stale image paste onto this message. Best-effort —
// on failure the marker stays set and the text must not be submitted.
const staleTerminal = activeHandleRef.current
if (staleTerminal && staleInputTerminalsRef.current.has(staleTerminal) && client) {
let cleared = false
try {
cleared = await pasteMobileNativeChatImagePaths({
client,
terminal: staleTerminal,
deviceToken: deviceTokenRef.current,
imagePaths: []
})
} catch {
// Leave marked for the next attempt.
}
if (!cleared) {
onError?.()
showToast('Message not sent', 1500)
return false
}
staleInputTerminalsRef.current.delete(staleTerminal)
if (activeHandleRef.current !== staleTerminal) {
onError?.()
showToast('Message not sent', 1500)
return false
}
}
return baseSend(text)
}
const handle = activeHandleRef.current
if (!client || !handle || !enabled || connState !== 'connected') {
onError?.()
// Mirror the text path's failure surface (the base send is never reached).
showToast('Message not sent (disconnected)', 1500)
return false
}
try {
const pasted = await pasteMobileNativeChatImagePaths({
client,
terminal: handle,
deviceToken: deviceTokenRef.current,
imagePaths: pendingImages.map((attachment) => attachment.path)
})
if (!pasted) {
// Keep the chips so the user can retry; the failed paste never submitted.
markTerminalInputStale(staleInputTerminalsRef.current, handle)
onError?.()
showToast('Message not sent', 1500)
return false
}
// The paste's leading Ctrl+U cleared any earlier stale input in `handle`.
staleInputTerminalsRef.current.delete(handle)
// Let the TUI absorb the image paste before the text + Enter follow. The
// preview URIs ride along to baseSend so the sent bubble shows the photo
// immediately (empty text still submits a bare Enter through baseSend).
await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
// The paste above targeted `handle`; a tab switch during the settle would
// route the text + Enter to a different terminal than the images. Abort —
// the chips keep their scope and a retry's Ctrl+U clears the stale paste.
if (activeHandleRef.current !== handle) {
markTerminalInputStale(staleInputTerminalsRef.current, handle)
onError?.()
showToast('Message not sent', 1500)
return false
}
const accepted = await baseSend(
text,
pendingImages.map((attachment) => attachment.previewUri)
)
if (!accepted) {
// A rejected submit leaves the successfully pasted image path on this input line.
markTerminalInputStale(staleInputTerminalsRef.current, handle)
}
if (accepted) {
// Drop only what rode along — a chip attached while this send was in
// flight keeps waiting for its own send.
const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
setAttachmentsByScope((prev) =>
withScopeAttachments(
prev,
scope,
(prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
)
)
}
return accepted
} catch {
// A thrown paste/send (network/RPC) keeps the chips and honors the
// Promise<boolean> contract instead of rejecting. Retry-safe: the next
// attempt's leading Ctrl+U clears whatever fraction of the paste landed.
markTerminalInputStale(staleInputTerminalsRef.current, handle)
onError?.()
showToast('Message not sent', 1500)
return false
}
} finally {
if (operationTerminal) {
sendInFlightTerminalsRef.current.delete(operationTerminal)
}
}
},
[
activeHandleRef,
+1 -130
View File
@@ -1,5 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
HOST_DOCK_MAX_WIDTH,
HOST_DOCK_MIN_WIDTH,
@@ -10,29 +10,22 @@ import {
clampHostSidebarWidth,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadPinnedIds,
loadPushNotificationsEnabled,
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
readPushNotificationsPreference,
readDisabledTerminalLiveInputHandlesPreference,
MOBILE_STORED_ID_SET_MAX_ENTRIES,
MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
savePinnedIds,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
} from './preferences'
import {
SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS,
SESSION_VIEW_OVERRIDE_MAX_ENTRIES,
loadDefaultSessionView,
loadSessionViewOverrides,
readDefaultSessionViewPreference,
readSessionViewOverridesPreference,
resetSessionViewPreferencesForTests,
saveDefaultSessionView,
updateSessionViewOverride
} from './session-view-preferences'
@@ -54,15 +47,10 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
describe('session view preference', () => {
beforeEach(() => {
resetSessionViewPreferencesForTests()
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
afterEach(() => {
resetSessionViewPreferencesForTests()
})
it('defaults to terminal and persists the chat default', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
@@ -282,72 +270,6 @@ describe('session view preference', () => {
JSON.stringify({ tab: 'chat' })
)
})
it('loads the exact override count and rejects one over without parsing it into state', async () => {
const exact = Object.fromEntries(
Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ENTRIES }, (_, index) => [
`tab-${index}`,
'chat'
])
)
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(exact))
await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({
overrides: new Map(Object.entries(exact)) as Map<string, 'chat'>,
loaded: true
})
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
JSON.stringify({ ...exact, 'one-over': 'terminal' })
)
await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({
overrides: new Map(),
loaded: false
})
})
it('evicts the oldest override when a new user choice exceeds the count cap', async () => {
const exact = Object.fromEntries(
Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ENTRIES }, (_, index) => [
`tab-${index}`,
'chat'
])
)
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(exact))
await updateSessionViewOverride('host', 'worktree', 'new-tab', 'terminal')
const stored = JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[0]![1]) as Record<
string,
string
>
expect(Object.keys(stored)).toHaveLength(SESSION_VIEW_OVERRIDE_MAX_ENTRIES)
expect(stored['tab-0']).toBeUndefined()
expect(stored['new-tab']).toBe('terminal')
})
it('caps unresolved scoped write barriers and accepts another after one settles', async () => {
const blocked = deferred<void>()
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
vi.mocked(AsyncStorage.setItem).mockImplementation(() => blocked.promise)
const writes = Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS }, (_, index) =>
updateSessionViewOverride(`host-${index}`, 'worktree', 'tab', 'chat')
)
await vi.waitFor(() =>
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS)
)
await expect(updateSessionViewOverride('one-over', 'worktree', 'tab', 'chat')).rejects.toThrow(
'Too many session view override writes are pending'
)
blocked.resolve()
await Promise.all(writes)
vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined)
await expect(
updateSessionViewOverride('recovered', 'worktree', 'tab', 'chat')
).resolves.toBeUndefined()
})
})
describe('push notification preference', () => {
@@ -484,57 +406,6 @@ describe('terminal live input disabled handles preference', () => {
JSON.stringify(['pty-2', 'pty-1'])
)
})
it('accepts the exact handle cap and persists the newest handles at one over', async () => {
const exact = new Set(
Array.from({ length: MOBILE_STORED_ID_SET_MAX_ENTRIES }, (_, index) => `pty-${index}`)
)
await saveDisabledTerminalLiveInputHandles('host', 'worktree', exact)
expect(JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[0]![1])).toHaveLength(
MOBILE_STORED_ID_SET_MAX_ENTRIES
)
exact.add('one-over')
await saveDisabledTerminalLiveInputHandles('host', 'worktree', exact)
const retained = JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[1]![1]) as string[]
expect(retained).toHaveLength(MOBILE_STORED_ID_SET_MAX_ENTRIES)
expect(retained).not.toContain('pty-0')
expect(retained).toContain('one-over')
})
it('rejects an oversized handle payload before parsing', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
'x'.repeat(MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS + 1)
)
await expect(
readDisabledTerminalLiveInputHandlesPreference('host', 'worktree')
).resolves.toEqual({ handles: new Set(), loaded: false })
})
})
describe('pinned worktree ids preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('round-trips normal pins unchanged', async () => {
const pins = new Set(['worktree-2', 'worktree-1'])
await savePinnedIds('host', pins)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pins:host', JSON.stringify([...pins]))
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify([...pins]))
await expect(loadPinnedIds('host')).resolves.toEqual(pins)
})
it('bounds oversized durable pin payloads', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
'x'.repeat(MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS + 1)
)
await expect(loadPinnedIds('host')).resolves.toEqual(new Set())
})
})
describe('host sidebar width preference', () => {
+10 -51
View File
@@ -2,9 +2,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
export const MOBILE_STORED_ID_SET_MAX_ENTRIES = 10_000
export const MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS = 1024 * 1024
export const MOBILE_STORED_ID_MAX_CHARACTERS = 4_096
export type PushNotificationsPreference = {
readonly value: boolean | null
@@ -104,10 +101,7 @@ export async function readDisabledTerminalLiveInputHandlesPreference(
if (!raw) {
return { handles: new Set(), loaded: true }
}
if (raw.length > MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS) {
return { handles: new Set(), loaded: false }
}
return { handles: new Set(retainStoredPreferenceIds(JSON.parse(raw)).ids), loaded: true }
return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
} catch {
return { handles: new Set(), loaded: false }
}
@@ -128,7 +122,7 @@ export async function saveDisabledTerminalLiveInputHandles(
): Promise<void> {
await AsyncStorage.setItem(
terminalLiveInputDisabledKey(hostId, worktreeId),
retainStoredPreferenceIds(handles).serialized
JSON.stringify([...handles])
)
}
@@ -216,59 +210,24 @@ export async function saveTerminalLinkOpenMode(mode: MobileTerminalLinkOpenMode)
await AsyncStorage.setItem(TERMINAL_LINK_OPEN_MODE_KEY, mode)
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: []
}
export async function loadPinnedIds(hostId: string): Promise<Set<string>> {
try {
const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId)
if (!raw) {
return new Set()
}
if (raw.length > MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS) {
return new Set()
}
return new Set(retainStoredPreferenceIds(JSON.parse(raw)).ids)
return new Set(stringArray(JSON.parse(raw)))
} catch {
return new Set()
}
}
export async function savePinnedIds(hostId: string, ids: Set<string>): Promise<void> {
await AsyncStorage.setItem(PINS_PREFIX + hostId, retainStoredPreferenceIds(ids).serialized)
}
function retainStoredPreferenceIds(value: unknown): { ids: string[]; serialized: string } {
const values = Array.isArray(value)
? value
: value && typeof value === 'object' && Symbol.iterator in value
? (value as Iterable<unknown>)
: []
const retained = new Map<string, string>()
let entryCharacters = 0
for (const candidate of values) {
if (
typeof candidate !== 'string' ||
candidate.length > MOBILE_STORED_ID_MAX_CHARACTERS ||
retained.has(candidate)
) {
continue
}
const serialized = JSON.stringify(candidate)
retained.set(candidate, serialized)
entryCharacters += serialized.length
while (
retained.size > MOBILE_STORED_ID_SET_MAX_ENTRIES ||
2 + entryCharacters + Math.max(0, retained.size - 1) >
MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS
) {
const oldestId = retained.keys().next().value
if (typeof oldestId !== 'string') {
break
}
entryCharacters -= retained.get(oldestId)?.length ?? 0
retained.delete(oldestId)
}
}
return {
ids: [...retained.keys()],
serialized: `[${[...retained.values()].join(',')}]`
}
await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids]))
}
+16 -111
View File
@@ -5,11 +5,6 @@ export type MobileSessionView = 'terminal' | 'chat'
const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView'
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
export const SESSION_VIEW_OVERRIDE_MAX_ENTRIES = 4_096
export const SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS = 512 * 1024
export const SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS = 1_024
export const SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS = 64
const SESSION_VIEW_OVERRIDE_MAX_SCOPE_KEY_CHARACTERS = 4_096
// Why: default stays terminal so native chat remains strictly opt-in.
export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
@@ -17,11 +12,8 @@ export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
let defaultViewWriteBarrier: Promise<void> | null = null
const overrideUpdateBarriers = new Map<string, Promise<void>>()
function sessionViewOverridesKey(hostId: string, worktreeId: string): string | null {
const key = `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
worktreeId
)}`
return key.length <= SESSION_VIEW_OVERRIDE_MAX_SCOPE_KEY_CHARACTERS ? key : null
function sessionViewOverridesKey(hostId: string, worktreeId: string): string {
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
function clearDefaultViewWriteBarrier(barrier: Promise<void>): void {
@@ -85,9 +77,6 @@ async function readSessionViewOverridesStorage(
if (!raw) {
return { overrides: new Map(), loaded: true }
}
if (raw.length > SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS) {
return { overrides: new Map(), loaded: false }
}
let parsed: unknown
try {
parsed = JSON.parse(raw) as unknown
@@ -97,38 +86,21 @@ async function readSessionViewOverridesStorage(
}
// Legacy format: an array of tab ids that were showing native chat.
if (Array.isArray(parsed)) {
const overrides = new Map<string, MobileSessionView>()
for (const id of parsed) {
if (typeof id !== 'string' || id.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS) {
continue
}
if (!overrides.has(id) && overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES) {
return { overrides: new Map(), loaded: false }
}
overrides.set(id, 'chat')
return {
overrides: new Map(
parsed
.filter((id): id is string => typeof id === 'string')
.map((id) => [id, 'chat' as const])
),
loaded: true
}
return { overrides, loaded: true }
}
if (parsed && typeof parsed === 'object') {
const overrides = new Map<string, MobileSessionView>()
const record = parsed as Record<string, unknown>
for (const id in record) {
if (!Object.prototype.hasOwnProperty.call(record, id)) {
continue
}
const view = record[id]
if (
(view !== 'terminal' && view !== 'chat') ||
id.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS
) {
continue
}
if (overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES) {
return { overrides: new Map(), loaded: false }
}
overrides.set(id, view)
}
return { overrides, loaded: true }
const entries = Object.entries(parsed as Record<string, unknown>).filter(
(entry): entry is [string, MobileSessionView] =>
entry[1] === 'terminal' || entry[1] === 'chat'
)
return { overrides: new Map(entries), loaded: true }
}
return { overrides: new Map(), loaded: true }
}
@@ -149,9 +121,6 @@ export async function readSessionViewOverridesPreference(
worktreeId: string
): Promise<SessionViewOverridesPreference> {
const key = sessionViewOverridesKey(hostId, worktreeId)
if (!key) {
return { overrides: new Map(), loaded: false }
}
await overrideUpdateBarriers.get(key)
return readSessionViewOverridesStorage(key)
}
@@ -164,17 +133,7 @@ export async function updateSessionViewOverride(
view: MobileSessionView
): Promise<void> {
const key = sessionViewOverridesKey(hostId, worktreeId)
if (!key || tabId.length === 0 || tabId.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS) {
throw new Error('Session view override identifier is too large')
}
const existingBarrier = overrideUpdateBarriers.get(key)
if (
!existingBarrier &&
overrideUpdateBarriers.size >= SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS
) {
throw new Error('Too many session view override writes are pending')
}
const previous = existingBarrier ?? Promise.resolve()
const previous = overrideUpdateBarriers.get(key) ?? Promise.resolve()
const update = previous.then(async () => {
const current = await readSessionViewOverridesStorage(key)
// Why: a transient read failure must not replace valid saved siblings with
@@ -182,17 +141,8 @@ export async function updateSessionViewOverride(
if (!current.loaded) {
throw new Error('Session view overrides could not be read')
}
if (
!current.overrides.has(tabId) &&
current.overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES
) {
const oldestId = current.overrides.keys().next().value
if (typeof oldestId === 'string') {
current.overrides.delete(oldestId)
}
}
current.overrides.set(tabId, view)
await AsyncStorage.setItem(key, serializeSessionViewOverrides(current.overrides, tabId))
await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides)))
})
const barrier = update.catch(() => undefined)
overrideUpdateBarriers.set(key, barrier)
@@ -204,48 +154,3 @@ export async function updateSessionViewOverride(
}
}
}
function serializeSessionViewOverrides(
overrides: Map<string, MobileSessionView>,
protectedId: string
): string {
while (true) {
const serialized = trySerializeSessionViewOverrides(overrides)
if (serialized !== null) {
return serialized
}
let evictionId: string | null = null
for (const id of overrides.keys()) {
if (id !== protectedId) {
evictionId = id
break
}
}
if (evictionId === null) {
throw new Error('Session view override storage limit exceeded')
}
overrides.delete(evictionId)
}
}
function trySerializeSessionViewOverrides(
overrides: ReadonlyMap<string, MobileSessionView>
): string | null {
const entries: string[] = []
let characters = 2
for (const [id, view] of overrides) {
const entry = `${JSON.stringify(id)}:${JSON.stringify(view)}`
characters += entry.length + (entries.length > 0 ? 1 : 0)
if (characters > SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS) {
return null
}
entries.push(entry)
}
return `{${entries.join(',')}}`
}
/** Test-only: drop pending module write barriers between cases. */
export function resetSessionViewPreferencesForTests(): void {
defaultViewWriteBarrier = null
overrideUpdateBarriers.clear()
}
@@ -44,24 +44,6 @@ describe('buildGitHubPrFileDiffLines', () => {
])
})
it('preserves a terminal lone carriage return like the previous line splitter', () => {
expect(buildGitHubPrFileDiffLines('', 'line\r')).toEqual([
{ key: '0:added:1', kind: 'added', newLineNumber: 1, text: 'line\r' }
])
})
it('treats one empty LF line and CRLF line as equal', () => {
expect(buildGitHubPrFileDiffLines('\n', '\r\n')).toEqual([
{
key: '0:context:1:1',
kind: 'context',
oldLineNumber: 1,
newLineNumber: 1,
text: ''
}
])
})
it('keeps all lines for large files without exact diff truncation', () => {
const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n')
const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n')
@@ -89,39 +71,6 @@ describe('buildGitHubPrFileDiffLines', () => {
})
})
it('counts newline-dense files without materializing discarded line arrays', () => {
const original = '\n'.repeat(200_000)
const preview = buildGitHubPrFileDiffPreview(original, 'changed', 3)
expect(preview.totalLineCount).toBe(200_001)
expect(preview.lines).toEqual([
{ key: '0:removed:1', kind: 'removed', oldLineNumber: 1, text: '' },
{ key: '1:removed:2', kind: 'removed', oldLineNumber: 2, text: '' },
{ key: '2:removed:3', kind: 'removed', oldLineNumber: 3, text: '' }
])
})
it('preserves common prefix and suffix rows around a streamed middle diff', () => {
const originalMiddle = Array.from({ length: 500 }, (_, index) => `old-${index}`)
const modifiedMiddle = Array.from({ length: 500 }, (_, index) => `new-${index}`)
const original = ['first', ...originalMiddle, 'last'].join('\r\n')
const modified = ['first', ...modifiedMiddle, 'last'].join('\r\n')
const preview = buildGitHubPrFileDiffPreview(original, modified, 1_002)
expect(preview.totalLineCount).toBe(1_002)
expect(preview.lines[0]).toMatchObject({ kind: 'context', text: 'first' })
expect(preview.lines[1]).toMatchObject({ kind: 'removed', text: 'old-0' })
expect(preview.lines[501]).toMatchObject({ kind: 'added', text: 'new-0' })
expect(preview.lines.at(-1)).toMatchObject({
kind: 'context',
oldLineNumber: 502,
newLineNumber: 502,
text: 'last'
})
})
it('can compute the total without retaining preview rows', () => {
const modified = Array.from({ length: 20 }, (_, index) => `new-${index}`).join('\n')
+77 -61
View File
@@ -1,11 +1,3 @@
import {
collectGitHubPrFileLineRange,
createGitHubPrFileLineSource,
findGitHubPrFileCommonLineEdges,
visitGitHubPrFileLineRange,
type GitHubPrFileLineSource
} from './github-pr-file-line-scan'
export type GitHubPrFileDiffLine = {
key: string
kind: 'context' | 'added' | 'removed'
@@ -26,6 +18,14 @@ type DiffOperation =
const EXACT_DIFF_CELL_LIMIT = 160_000
function splitContentLines(value: string): string[] {
if (!value) {
return []
}
const lines = value.split(/\r?\n/)
return lines.at(-1) === '' ? lines.slice(0, -1) : lines
}
function appendExactLineDiff(
original: string[],
modified: string[],
@@ -79,6 +79,37 @@ function appendExactLineDiff(
}
}
function appendMiddleDiff(
original: string[],
modified: string[],
appendOperation: (operation: DiffOperation) => void
): void {
if (original.length === 0) {
for (const newLine of modified) {
appendOperation({ kind: 'added', newLine })
}
return
}
if (modified.length === 0) {
for (const oldLine of original) {
appendOperation({ kind: 'removed', oldLine })
}
return
}
if (original.length * modified.length <= EXACT_DIFF_CELL_LIMIT) {
appendExactLineDiff(original, modified, appendOperation)
return
}
// Why: the Tasks diff UI renders a capped preview. Stream fallback rows so a
// generated PR file does not allocate thousands of discarded row objects.
for (const oldLine of original) {
appendOperation({ kind: 'removed', oldLine })
}
for (const newLine of modified) {
appendOperation({ kind: 'added', newLine })
}
}
export function buildGitHubPrFileDiffLines(
originalContent: string,
modifiedContent: string
@@ -91,17 +122,42 @@ export function buildGitHubPrFileDiffPreview(
modifiedContent: string,
maxLines = Number.POSITIVE_INFINITY
): GitHubPrFileDiffPreview {
const original = createGitHubPrFileLineSource(originalContent)
const modified = createGitHubPrFileLineSource(modifiedContent)
const { prefixLineCount, suffixLineCount } = findGitHubPrFileCommonLineEdges(original, modified)
const originalMiddleLineCount = original.lineCount - prefixLineCount - suffixLineCount
const modifiedMiddleLineCount = modified.lineCount - prefixLineCount - suffixLineCount
const originalLines = splitContentLines(originalContent)
const modifiedLines = splitContentLines(modifiedContent)
let prefixLength = 0
while (
prefixLength < originalLines.length &&
prefixLength < modifiedLines.length &&
originalLines[prefixLength] === modifiedLines[prefixLength]
) {
prefixLength += 1
}
let suffixLength = 0
while (
suffixLength < originalLines.length - prefixLength &&
suffixLength < modifiedLines.length - prefixLength &&
originalLines[originalLines.length - suffixLength - 1] ===
modifiedLines[modifiedLines.length - suffixLength - 1]
) {
suffixLength += 1
}
const originalMiddle = originalLines.slice(
prefixLength,
suffixLength === 0 ? originalLines.length : originalLines.length - suffixLength
)
const modifiedMiddle = modifiedLines.slice(
prefixLength,
suffixLength === 0 ? modifiedLines.length : modifiedLines.length - suffixLength
)
const result: GitHubPrFileDiffLine[] = []
let oldLineNumber = 1
let newLineNumber = 1
let operationIndex = 0
let totalLineCount = 0
const normalizedMaxLines = Number.isNaN(maxLines) ? 0 : Math.max(0, Math.floor(maxLines))
const normalizedMaxLines = Math.max(0, Math.floor(maxLines))
function appendOperation(operation: DiffOperation): void {
const index = operationIndex
operationIndex += 1
@@ -143,55 +199,15 @@ export function buildGitHubPrFileDiffPreview(
newLineNumber += 1
}
function skipOperations(kind: DiffOperation['kind'], count: number): void {
operationIndex += count
totalLineCount += count
if (kind === 'context') {
oldLineNumber += count
newLineNumber += count
} else if (kind === 'removed') {
oldLineNumber += count
} else {
newLineNumber += count
}
for (let i = 0; i < prefixLength; i += 1) {
const line = originalLines[i] ?? ''
appendOperation({ kind: 'context', oldLine: line, newLine: line })
}
function appendLineRange(
kind: DiffOperation['kind'],
source: GitHubPrFileLineSource,
startLine: number,
lineCount: number
): void {
const retainedLineCount = Math.min(lineCount, Math.max(0, normalizedMaxLines - result.length))
visitGitHubPrFileLineRange(source, startLine, retainedLineCount, (line) => {
if (kind === 'context') {
appendOperation({ kind, oldLine: line, newLine: line })
} else if (kind === 'removed') {
appendOperation({ kind, oldLine: line })
} else {
appendOperation({ kind, newLine: line })
}
})
skipOperations(kind, lineCount - retainedLineCount)
appendMiddleDiff(originalMiddle, modifiedMiddle, appendOperation)
for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) {
const line = originalLines[i] ?? ''
appendOperation({ kind: 'context', oldLine: line, newLine: line })
}
appendLineRange('context', original, 0, prefixLineCount)
if (originalMiddleLineCount === 0) {
appendLineRange('added', modified, prefixLineCount, modifiedMiddleLineCount)
} else if (modifiedMiddleLineCount === 0) {
appendLineRange('removed', original, prefixLineCount, originalMiddleLineCount)
} else if (originalMiddleLineCount * modifiedMiddleLineCount <= EXACT_DIFF_CELL_LIMIT) {
appendExactLineDiff(
collectGitHubPrFileLineRange(original, prefixLineCount, originalMiddleLineCount),
collectGitHubPrFileLineRange(modified, prefixLineCount, modifiedMiddleLineCount),
appendOperation
)
} else {
// Why: large generated files need exact counts without retaining discarded preview rows.
appendLineRange('removed', original, prefixLineCount, originalMiddleLineCount)
appendLineRange('added', modified, prefixLineCount, modifiedMiddleLineCount)
}
appendLineRange('context', original, original.lineCount - suffixLineCount, suffixLineCount)
return { lines: result, totalLineCount }
}
@@ -1,171 +0,0 @@
export type GitHubPrFileLineSource = {
content: string
lineCount: number
}
export type GitHubPrFileCommonLineEdges = {
prefixLineCount: number
suffixLineCount: number
}
type LineBounds = {
start: number
end: number
}
type ForwardLineCursor = {
offset: number
remaining: number
source: GitHubPrFileLineSource
}
type ReverseLineCursor = {
boundary: number
remaining: number
source: GitHubPrFileLineSource
}
export function createGitHubPrFileLineSource(content: string): GitHubPrFileLineSource {
if (content.length === 0) {
return { content, lineCount: 0 }
}
let lineCount = content.endsWith('\n') ? 0 : 1
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) === 10) {
lineCount += 1
}
}
return { content, lineCount }
}
export function findGitHubPrFileCommonLineEdges(
original: GitHubPrFileLineSource,
modified: GitHubPrFileLineSource
): GitHubPrFileCommonLineEdges {
const comparableLineCount = Math.min(original.lineCount, modified.lineCount)
const originalForward = createForwardCursor(original)
const modifiedForward = createForwardCursor(modified)
let prefixLineCount = 0
while (prefixLineCount < comparableLineCount) {
const originalLine = takeNextLine(originalForward)!
const modifiedLine = takeNextLine(modifiedForward)!
if (!lineBoundsEqual(original, originalLine, modified, modifiedLine)) {
break
}
prefixLineCount += 1
}
const originalReverse = createReverseCursor(original)
const modifiedReverse = createReverseCursor(modified)
const maxSuffixLineCount = comparableLineCount - prefixLineCount
let suffixLineCount = 0
while (suffixLineCount < maxSuffixLineCount) {
const originalLine = takePreviousLine(originalReverse)!
const modifiedLine = takePreviousLine(modifiedReverse)!
if (!lineBoundsEqual(original, originalLine, modified, modifiedLine)) {
break
}
suffixLineCount += 1
}
return { prefixLineCount, suffixLineCount }
}
export function visitGitHubPrFileLineRange(
source: GitHubPrFileLineSource,
startLine: number,
lineCount: number,
visit: (line: string) => void
): void {
if (lineCount <= 0) {
return
}
const cursor = createForwardCursor(source)
for (let index = 0; index < startLine; index += 1) {
takeNextLine(cursor)
}
for (let index = 0; index < lineCount; index += 1) {
const bounds = takeNextLine(cursor)
if (!bounds) {
return
}
visit(source.content.slice(bounds.start, bounds.end))
}
}
export function collectGitHubPrFileLineRange(
source: GitHubPrFileLineSource,
startLine: number,
lineCount: number
): string[] {
const lines: string[] = []
visitGitHubPrFileLineRange(source, startLine, lineCount, (line) => lines.push(line))
return lines
}
function createForwardCursor(source: GitHubPrFileLineSource): ForwardLineCursor {
return { source, offset: 0, remaining: source.lineCount }
}
function takeNextLine(cursor: ForwardLineCursor): LineBounds | null {
if (cursor.remaining === 0) {
return null
}
const { content } = cursor.source
const separator = content.indexOf('\n', cursor.offset)
const rawEnd = separator === -1 ? content.length : separator
const end =
separator !== -1 && rawEnd > cursor.offset && content.charCodeAt(rawEnd - 1) === 13
? rawEnd - 1
: rawEnd
const bounds = { start: cursor.offset, end }
cursor.offset = separator === -1 ? content.length : separator + 1
cursor.remaining -= 1
return bounds
}
function createReverseCursor(source: GitHubPrFileLineSource): ReverseLineCursor {
return {
source,
boundary: source.content.endsWith('\n') ? source.content.length - 1 : source.content.length,
remaining: source.lineCount
}
}
function takePreviousLine(cursor: ReverseLineCursor): LineBounds | null {
if (cursor.remaining === 0) {
return null
}
const { content } = cursor.source
const separator = cursor.boundary === 0 ? -1 : content.lastIndexOf('\n', cursor.boundary - 1)
const start = separator + 1
const end =
cursor.boundary < content.length &&
cursor.boundary > start &&
content.charCodeAt(cursor.boundary - 1) === 13
? cursor.boundary - 1
: cursor.boundary
cursor.boundary = separator === -1 ? 0 : separator
cursor.remaining -= 1
return { start, end }
}
function lineBoundsEqual(
leftSource: GitHubPrFileLineSource,
left: LineBounds,
rightSource: GitHubPrFileLineSource,
right: LineBounds
): boolean {
const length = left.end - left.start
if (length !== right.end - right.start) {
return false
}
for (let offset = 0; offset < length; offset += 1) {
if (
leftSource.content.charCodeAt(left.start + offset) !==
rightSource.content.charCodeAt(right.start + offset)
) {
return false
}
}
return true
}
@@ -1,153 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { GitHubPRFileContents } from '../../../src/shared/types'
import {
MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES,
MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES,
MobilePrFileContentCache,
createMobilePrFileContentKey,
createMobilePrFileContentScope,
getMobilePrFileContentByteCount,
getMobilePrFileContentsForScope
} from './mobile-pr-file-content-cache'
import {
createMobileItemPrFileContentScope,
createMobileProjectPrFileContentScope
} from './use-mobile-pr-file-content-cache'
function contents(original: string, modified = ''): GitHubPRFileContents {
return {
original,
modified,
originalIsBinary: false,
modifiedIsBinary: false
}
}
const scopeA = createMobilePrFileContentScope({
source: 'item',
repoId: 'repo-1',
prNumber: 10,
headSha: 'head-a',
baseSha: 'base-a'
})
const scopeB = createMobilePrFileContentScope({
source: 'item',
repoId: 'repo-1',
prNumber: 11,
headSha: 'head-b',
baseSha: 'base-a'
})
describe('MobilePrFileContentCache', () => {
it('derives route scopes only from complete GitHub PR revisions', () => {
expect(
createMobileItemPrFileContentScope(
{ provider: 'github', source: { type: 'pr', repoId: 'repo-1', number: 10 } },
{ provider: 'github', headSha: 'head-a', baseSha: 'base-a' }
)
).toBe(scopeA)
expect(
createMobileProjectPrFileContentScope(
{ itemType: 'PULL_REQUEST', content: { number: 10 } },
{ id: 'repo-1' },
{ provider: 'github', headSha: 'head-a' }
)
).toBeNull()
expect(
createMobileProjectPrFileContentScope(
{ itemType: 'PULL_REQUEST', content: { number: 10 } },
{ id: 'repo-1' },
{ provider: 'github', headSha: 'head-a', baseSha: 'base-a' },
{ host: 'github.example', owner: 'orca', repo: 'app' }
)
).toBe(
createMobilePrFileContentScope({
source: 'project',
repoId: 'repo-1',
prNumber: 10,
headSha: 'head-a',
baseSha: 'base-a',
repository: { host: 'github.example', owner: 'orca', repo: 'app' }
})
)
})
it('uses the desktop diff budget as a hard byte cap with a smaller mobile entry cap', () => {
expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES).toBeLessThan(64)
expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES).toBe(24_000_000)
})
it('evicts least-recently-used files at the entry limit', () => {
const cache = new MobilePrFileContentCache(2, 100)
const a = createMobilePrFileContentKey({ path: 'a.ts' })
const b = createMobilePrFileContentKey({ path: 'b.ts' })
const c = createMobilePrFileContentKey({ path: 'c.ts' })
cache.commitRequest(cache.beginRequest(scopeA, a), contents('a'))
cache.commitRequest(cache.beginRequest(scopeA, b), contents('b'))
expect(cache.select(scopeA, a).contents).toEqual(contents('a'))
cache.commitRequest(cache.beginRequest(scopeA, c), contents('c'))
expect(cache.evidence()).toMatchObject({ entryCount: 2, keysOldestFirst: [a, c] })
expect(cache.select(scopeA, b).contents).toBeUndefined()
})
it('evicts older payloads to stay within the byte budget', () => {
const cache = new MobilePrFileContentCache(10, 8)
const a = createMobilePrFileContentKey({ path: 'a.ts' })
const b = createMobilePrFileContentKey({ path: 'b.ts' })
cache.commitRequest(cache.beginRequest(scopeA, a), contents('12345'))
cache.commitRequest(cache.beginRequest(scopeA, b), contents('67890'))
expect(cache.evidence()).toMatchObject({
entryCount: 1,
retainedBytes: 5,
keysOldestFirst: [b]
})
})
it('measures retained UTF-8 bytes rather than JavaScript code units', () => {
expect(getMobilePrFileContentByteCount(contents('a😀', 'é'))).toBe(7)
})
it('counts the retained side when the other side is an oversized sentinel', () => {
expect(
getMobilePrFileContentByteCount({
...contents('', 'retained'),
originalTooLarge: true
})
).toBe(8)
})
it('rejects a response from a prior PR context after scope replacement', () => {
const cache = new MobilePrFileContentCache(2, 100)
const key = createMobilePrFileContentKey({ path: 'file.ts' })
const oldRequest = cache.beginRequest(scopeA, key)
cache.activateScope(scopeB)
expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale')
expect(cache.evidence()).toMatchObject({ scope: scopeB, entryCount: 0, retainedBytes: 0 })
})
it('rejects an older request after the active file selection changes', () => {
const cache = new MobilePrFileContentCache(2, 100)
const a = createMobilePrFileContentKey({ path: 'a.ts' })
const b = createMobilePrFileContentKey({ path: 'b.ts' })
const oldRequest = cache.beginRequest(scopeA, a)
cache.select(scopeA, b)
const currentRequest = cache.beginRequest(scopeA, b)
expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale')
expect(cache.commitRequest(currentRequest, contents('current'))).toBe('stored')
})
it('publishes only the active scope and refuses a single over-budget payload', () => {
const cache = new MobilePrFileContentCache(2, 4)
const key = createMobilePrFileContentKey({ path: 'file.ts' })
expect(cache.commitRequest(cache.beginRequest(scopeA, key), contents('12345'))).toBe(
'too-large'
)
expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeA)).toEqual({})
expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeB)).toEqual({})
})
})
@@ -1,226 +0,0 @@
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../src/shared/large-diff-render-limit'
import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types'
export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES = 8
export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4
export type MobilePrFileContentScopeInput = {
source: 'item' | 'project'
repoId: string
prNumber: number
headSha: string
baseSha: string
repository?: GitHubRepositoryIdentity | null
}
export type MobilePrFileContentKeyInput = {
path: string
oldPath?: string
status?: string
}
export type MobilePrFileContentRequestToken = {
scope: string
key: string
requestId: number
}
export type MobilePrFileContentCacheSnapshot = {
scope: string | null
contentsByKey: Readonly<Record<string, GitHubPRFileContents | undefined>>
}
export type MobilePrFileContentCacheEvidence = {
scope: string | null
entryCount: number
retainedBytes: number
keysOldestFirst: string[]
}
type CacheEntry = {
contents: GitHubPRFileContents
byteCount: number
}
type CacheSelection = {
contents: GitHubPRFileContents | undefined
scopeChanged: boolean
}
export class MobilePrFileContentCache {
private readonly entries = new Map<string, CacheEntry>()
private scope: string | null = null
private retainedBytes = 0
private requestSequence = 0
private activeRequest: MobilePrFileContentRequestToken | null = null
constructor(
private readonly maxEntries = MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES,
private readonly maxBytes = MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES
) {
if (
!Number.isInteger(maxEntries) ||
maxEntries < 1 ||
!Number.isFinite(maxBytes) ||
maxBytes < 1
) {
throw new Error('Mobile PR file-content cache limits must be positive')
}
}
activateScope(scope: string): boolean {
if (this.scope === scope) {
return false
}
this.scope = scope
this.entries.clear()
this.retainedBytes = 0
this.activeRequest = null
return true
}
clear(): void {
this.scope = null
this.entries.clear()
this.retainedBytes = 0
this.activeRequest = null
}
select(scope: string, key: string): CacheSelection {
const scopeChanged = this.activateScope(scope)
this.activeRequest = null
const entry = this.entries.get(key)
if (!entry) {
return { contents: undefined, scopeChanged }
}
this.entries.delete(key)
this.entries.set(key, entry)
return { contents: entry.contents, scopeChanged }
}
beginRequest(scope: string, key: string): MobilePrFileContentRequestToken {
this.activateScope(scope)
const token = { scope, key, requestId: ++this.requestSequence }
this.activeRequest = token
return token
}
commitRequest(
token: MobilePrFileContentRequestToken,
contents: GitHubPRFileContents
): 'stored' | 'stale' | 'too-large' {
if (!this.isCurrentRequest(token)) {
return 'stale'
}
this.activeRequest = null
const byteCount = getMobilePrFileContentByteCount(contents)
if (byteCount > this.maxBytes) {
return 'too-large'
}
const previous = this.entries.get(token.key)
this.retainedBytes -= previous?.byteCount ?? 0
this.entries.delete(token.key)
this.entries.set(token.key, { contents, byteCount })
this.retainedBytes += byteCount
this.evictOverflow()
return 'stored'
}
rejectRequest(token: MobilePrFileContentRequestToken): boolean {
if (!this.isCurrentRequest(token)) {
return false
}
this.activeRequest = null
return true
}
snapshot(): MobilePrFileContentCacheSnapshot {
return {
scope: this.scope,
contentsByKey: Object.fromEntries(
[...this.entries].map(([key, entry]) => [key, entry.contents])
)
}
}
evidence(): MobilePrFileContentCacheEvidence {
return {
scope: this.scope,
entryCount: this.entries.size,
retainedBytes: this.retainedBytes,
keysOldestFirst: [...this.entries.keys()]
}
}
private isCurrentRequest(token: MobilePrFileContentRequestToken): boolean {
return (
this.scope === token.scope &&
this.activeRequest?.requestId === token.requestId &&
this.activeRequest.scope === token.scope &&
this.activeRequest.key === token.key
)
}
private evictOverflow(): void {
while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxBytes) {
const oldestKey = this.entries.keys().next().value
if (typeof oldestKey !== 'string') {
return
}
const oldest = this.entries.get(oldestKey)
this.retainedBytes -= oldest?.byteCount ?? 0
this.entries.delete(oldestKey)
}
}
}
export function createMobilePrFileContentScope(input: MobilePrFileContentScopeInput): string {
return JSON.stringify([
input.source,
input.repoId,
input.prNumber,
input.repository?.host?.toLowerCase() ?? '',
input.repository?.owner.toLowerCase() ?? '',
input.repository?.repo.toLowerCase() ?? '',
input.headSha,
input.baseSha
])
}
export function createMobilePrFileContentKey(input: MobilePrFileContentKeyInput): string {
return input.path
}
export function getMobilePrFileContentsForScope(
snapshot: MobilePrFileContentCacheSnapshot,
scope: string | null
): Readonly<Record<string, GitHubPRFileContents | undefined>> {
return scope !== null && snapshot.scope === scope ? snapshot.contentsByKey : {}
}
export function getMobilePrFileContentByteCount(contents: GitHubPRFileContents): number {
return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified)
}
function getUtf8ByteCount(value: string): number {
let byteCount = 0
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code < 0x80) {
byteCount += 1
} else if (code < 0x800) {
byteCount += 2
} else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
byteCount += 4
index += 1
} else {
byteCount += 3
}
} else {
byteCount += 3
}
}
return byteCount
}
@@ -1,183 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types'
import {
MobilePrFileContentCache,
createMobilePrFileContentKey,
createMobilePrFileContentScope,
getMobilePrFileContentsForScope,
type MobilePrFileContentKeyInput
} from './mobile-pr-file-content-cache'
type MobilePrScopeTaskItem = {
provider: string
source: unknown
} | null
type MobilePrScopeDetail = {
provider: string
headSha?: unknown
baseSha?: unknown
} | null
type MobileProjectPrScopeItem = {
itemType: string
content: { number?: unknown }
} | null
type MobileProjectPrScopeRepo = { id?: unknown } | null
type MobilePrFileContentLoad = () => Promise<unknown>
type MobilePrFileContentErrorSetter = (message: string) => void
export function createMobileItemPrFileContentScope(
item: MobilePrScopeTaskItem,
detail: MobilePrScopeDetail
): string | null {
const source =
item?.source && typeof item.source === 'object'
? (item.source as { type?: unknown; repoId?: unknown; number?: unknown })
: null
if (
item?.provider !== 'github' ||
source?.type !== 'pr' ||
typeof source.repoId !== 'string' ||
typeof source.number !== 'number' ||
detail?.provider !== 'github' ||
typeof detail.headSha !== 'string' ||
!detail.headSha ||
typeof detail.baseSha !== 'string' ||
!detail.baseSha
) {
return null
}
return createMobilePrFileContentScope({
source: 'item',
repoId: source.repoId,
prNumber: source.number,
headSha: detail.headSha,
baseSha: detail.baseSha
})
}
export function createMobileProjectPrFileContentScope(
item: MobileProjectPrScopeItem,
repo: MobileProjectPrScopeRepo,
detail: MobilePrScopeDetail,
repository?: GitHubRepositoryIdentity | null
): string | null {
if (
item?.itemType !== 'PULL_REQUEST' ||
typeof item.content.number !== 'number' ||
typeof repo?.id !== 'string' ||
detail?.provider !== 'github' ||
typeof detail.headSha !== 'string' ||
!detail.headSha ||
typeof detail.baseSha !== 'string' ||
!detail.baseSha
) {
return null
}
return createMobilePrFileContentScope({
source: 'project',
repoId: repo.id,
prNumber: item.content.number,
repository,
headSha: detail.headSha,
baseSha: detail.baseSha
})
}
export function useMobilePrFileContentCache(activeScope: string | null): {
clear: () => void
contents: Readonly<Record<string, GitHubPRFileContents | undefined>>
load: (
scope: string,
file: MobilePrFileContentKeyInput,
loadContents: MobilePrFileContentLoad,
setError: MobilePrFileContentErrorSetter
) => Promise<void>
loadingPath: string | null
} {
const [cache] = useState(() => new MobilePrFileContentCache())
const [snapshot, setSnapshot] = useState(() => cache.snapshot())
const [loadingPath, setLoadingPath] = useState<string | null>(null)
const clear = useCallback(() => {
cache.clear()
setSnapshot(cache.snapshot())
setLoadingPath(null)
}, [cache])
useEffect(() => {
if (activeScope === null) {
clear()
} else if (cache.activateScope(activeScope)) {
setSnapshot(cache.snapshot())
setLoadingPath(null)
}
}, [activeScope, cache, clear])
const load = useCallback(
async (
scope: string,
file: MobilePrFileContentKeyInput,
loadContents: MobilePrFileContentLoad,
setError: MobilePrFileContentErrorSetter
): Promise<void> => {
const key = createMobilePrFileContentKey(file)
const selection = cache.select(scope, key)
if (selection.scopeChanged) {
setSnapshot(cache.snapshot())
}
if (selection.contents) {
setLoadingPath(null)
return
}
const token = cache.beginRequest(scope, key)
setLoadingPath(file.path)
setError('')
try {
const result = await loadContents()
if (!isGitHubPrFileContents(result)) {
throw new Error('Invalid file contents response')
}
const commit = cache.commitRequest(token, result)
if (commit === 'stale') {
return
}
if (commit === 'too-large') {
setError('File too large for mobile preview.')
} else {
setSnapshot(cache.snapshot())
}
setLoadingPath((current) => (current === file.path ? null : current))
} catch (error) {
if (!cache.rejectRequest(token)) {
return
}
setError(error instanceof Error ? error.message : 'Failed to load file contents')
setLoadingPath((current) => (current === file.path ? null : current))
}
},
[cache]
)
return {
clear,
contents: getMobilePrFileContentsForScope(snapshot, activeScope),
load,
loadingPath
}
}
function isGitHubPrFileContents(value: unknown): value is GitHubPRFileContents {
if (!value || typeof value !== 'object') {
return false
}
const contents = value as Partial<GitHubPRFileContents>
return (
typeof contents.original === 'string' &&
typeof contents.modified === 'string' &&
typeof contents.originalIsBinary === 'boolean' &&
typeof contents.modifiedIsBinary === 'boolean'
)
}
@@ -1,68 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS,
CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES,
CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS,
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from './custom-accessory-key-store'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn()
}
}))
function customKey(index: number, bytes = 'echo ok'): CustomKey {
return { id: `key-${index}`, label: `Key ${index}`, bytes, enter: false }
}
describe('custom accessory key store', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
})
it('round-trips normal custom keys unchanged', async () => {
const keys = [customKey(1), customKey(2)]
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(keys))
await expect(loadCustomKeys()).resolves.toEqual(keys)
await expect(saveCustomKeys(keys)).resolves.toEqual(keys)
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:custom-accessory-keys',
JSON.stringify(keys)
)
})
it('accepts the exact key count and evicts oldest at one over', async () => {
const exact = Array.from({ length: CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES }, (_, index) =>
customKey(index)
)
await expect(saveCustomKeys(exact)).resolves.toEqual(exact)
const oneOver = [...exact, customKey(CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES)]
const retained = await saveCustomKeys(oneOver)
expect(retained).toHaveLength(CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES)
expect(retained[0]?.id).toBe('key-1')
expect(retained.at(-1)?.id).toBe(`key-${CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES}`)
})
it('accepts the exact macro length and drops one character over', async () => {
const exact = customKey(1, 'x'.repeat(CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS))
const oversized = customKey(2, 'x'.repeat(CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS + 1))
await expect(saveCustomKeys([exact, oversized])).resolves.toEqual([exact])
})
it('rejects durable JSON over the storage character budget before parsing', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
'x'.repeat(CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS + 1)
)
await expect(loadCustomKeys()).resolves.toEqual([])
})
})
@@ -1,81 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
export const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
export const CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES = 128
export const CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS = 512 * 1024
export const CUSTOM_ACCESSORY_KEY_MAX_ID_CHARACTERS = 256
export const CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS = 128
export const CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS = 16 * 1024
export type CustomKey = {
id: string
label: string
bytes: string
enter: boolean
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
try {
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
if (!raw || raw.length > CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS) {
return []
}
return retainCustomKeys(JSON.parse(raw) as unknown).keys
} catch {
return []
}
}
export async function saveCustomKeys(keys: CustomKey[]): Promise<CustomKey[]> {
const retained = retainCustomKeys(keys)
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, retained.serialized)
return retained.keys
}
function retainCustomKeys(value: unknown): { keys: CustomKey[]; serialized: string } {
if (!Array.isArray(value)) {
return { keys: [], serialized: '[]' }
}
const retained: Array<{ key: CustomKey; serialized: string }> = []
let storageCharacters = 2
for (let index = value.length - 1; index >= 0; index -= 1) {
if (retained.length >= CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES) {
break
}
const key = normalizeCustomKey(value[index])
if (!key) {
continue
}
const serialized = JSON.stringify(key)
const nextCharacters = storageCharacters + serialized.length + (retained.length > 0 ? 1 : 0)
if (nextCharacters > CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS) {
continue
}
retained.push({ key, serialized })
storageCharacters = nextCharacters
}
retained.reverse()
return {
keys: retained.map(({ key }) => key),
serialized: `[${retained.map(({ serialized }) => serialized).join(',')}]`
}
}
function normalizeCustomKey(value: unknown): CustomKey | null {
if (!value || typeof value !== 'object') {
return null
}
const key = value as Partial<CustomKey>
if (
typeof key.id !== 'string' ||
key.id.length > CUSTOM_ACCESSORY_KEY_MAX_ID_CHARACTERS ||
typeof key.label !== 'string' ||
key.label.length > CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS ||
typeof key.bytes !== 'string' ||
key.bytes.length > CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS ||
typeof key.enter !== 'boolean'
) {
return null
}
return { id: key.id, label: key.label, bytes: key.bytes, enter: key.enter }
}
@@ -1,8 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_ACCESSORY_LAYOUT_MAX_IDS,
TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS,
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
createTerminalAccessoryLayoutPreference,
getDefaultTerminalAccessoryBuiltInIds,
@@ -85,61 +83,6 @@ describe('terminal accessory layout', () => {
)
})
it('accepts the exact storage limit and skips parsing one character more', async () => {
const preference = {
version: 2,
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['escape']
}
const serialized = JSON.stringify(preference)
asyncStorageMock.getItem.mockResolvedValueOnce(
serialized + ' '.repeat(TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS - serialized.length)
)
await expect(loadTerminalAccessoryLayout()).resolves.toMatchObject({
visibleBuiltInIds: ['escape']
})
const parse = vi.spyOn(JSON, 'parse')
asyncStorageMock.getItem.mockResolvedValueOnce(
'x'.repeat(TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS + 1)
)
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
)
expect(parse).not.toHaveBeenCalled()
parse.mockRestore()
})
it('accepts the exact stored id count and falls back on one more', () => {
const exact = Array.from({ length: TERMINAL_ACCESSORY_LAYOUT_MAX_IDS }, (_, index) =>
index === 0 ? 'tab' : 'escape'
)
expect(
normalizeTerminalAccessoryLayoutPreference(
{ version: 2, orderedBuiltInIds: exact, visibleBuiltInIds: ['escape'] },
['escape', 'tab']
)
).toEqual({
version: 2,
orderedBuiltInIds: ['tab', 'escape'],
visibleBuiltInIds: ['escape']
})
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 2,
orderedBuiltInIds: [...exact, 'tab'],
visibleBuiltInIds: ['escape']
},
['escape', 'tab']
)
).toEqual({
version: 2,
orderedBuiltInIds: ['escape', 'tab'],
visibleBuiltInIds: ['escape', 'tab']
})
})
it('preserves a custom v2 order and its visible subset', () => {
const reversed = [...getDefaultTerminalAccessoryBuiltInIds()].toReversed()
@@ -3,9 +3,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys'
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
export const TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS = 64 * 1024
export const TERMINAL_ACCESSORY_LAYOUT_MAX_IDS = 256
export const TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS = 256
export type TerminalAccessoryLayout = {
orderedBuiltInIds: string[]
@@ -29,28 +26,17 @@ function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreferenc
}
function stringArray(value: unknown): string[] | null {
if (!Array.isArray(value) || value.length > TERMINAL_ACCESSORY_LAYOUT_MAX_IDS) {
if (!Array.isArray(value)) {
return null
}
return value.every(
(item): item is string =>
typeof item === 'string' && item.length <= TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS
)
? value
: null
return value.every((item): item is string => typeof item === 'string') ? value : null
}
function dedupeKnownIds(ids: string[], builtInSet: Set<string>): string[] {
const seen = new Set<string>()
const out: string[] = []
const count = Math.min(ids.length, TERMINAL_ACCESSORY_LAYOUT_MAX_IDS)
for (let index = 0; index < count; index++) {
const id = ids[index]!
if (
id.length > TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS ||
!builtInSet.has(id) ||
seen.has(id)
) {
for (const id of ids) {
if (!builtInSet.has(id) || seen.has(id)) {
continue
}
seen.add(id)
@@ -232,9 +218,6 @@ export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLa
if (!raw) {
return defaultPreference()
}
if (raw.length > TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS) {
return defaultPreference()
}
return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw))
} catch {
return defaultPreference()
@@ -243,9 +226,5 @@ export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLa
export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise<void> {
const preference = createTerminalAccessoryLayoutPreference(layout)
const serialized = JSON.stringify(preference)
if (serialized.length > TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS) {
throw new Error('terminal accessory layout exceeds storage limit')
}
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, serialized)
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
}
@@ -1,27 +0,0 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
export const DEFAULT_TERMINAL_WEBVIEW_THEME: RuntimeMobileTerminalTheme['theme'] = {
background: colors.terminalBg,
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: colors.terminalBg,
selectionBackground: '#33467c',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
}
+41 -36
View File
@@ -1,7 +1,7 @@
// xterm.js WebView document; extracted from TerminalWebView.tsx for the max-lines budget.
// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { DEFAULT_TERMINAL_WEBVIEW_THEME } from './terminal-webview-default-theme'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
@@ -12,9 +12,30 @@ import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000
export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096
export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
background: colors.terminalBg,
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: colors.terminalBg,
selectionBackground: '#33467c',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
}
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
export const XTERM_HTML = `<!DOCTYPE html>
@@ -192,11 +213,10 @@ window.onerror = function(msg) {
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
var writeQueue = [], writeQueueUnits = 0;
var writeQueue = [];
var writeQueueHead = 0;
var writesDraining = false, writeBacklogFailed = false, afterDrainCallbacks = [];
var WRITE_QUEUE_MAX_UNITS = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS}, WRITE_QUEUE_MAX_ENTRIES = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES};
var AFTER_DRAIN_MAX_CALLBACKS = ${TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS};
var writesDraining = false;
var afterDrainCallbacks = [];
var termObserverDisposables = [];
var ready = false;
// Why: init() flips ready false on every re-init (live width reflow included)
@@ -267,7 +287,7 @@ window.onerror = function(msg) {
var normalScrollFrameId = null;
var initRows = 24;
var terminalGeneration = 0;
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_WEBVIEW_THEME)};
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
var terminalThemeInput = null;
var terminalTheme = defaultTheme;
var terminalMinimumContrastRatio = 3;
@@ -537,22 +557,9 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
}
function resetWriteQueue() { writeQueue = []; writeQueueHead = 0; writeQueueUnits = 0; }
function failWriteBacklog() {
if (writeBacklogFailed) return;
writeBacklogFailed = true; terminalGeneration++; ready = false; writesDraining = false;
resetWriteQueue(); afterDrainCallbacks = [];
reportEngineError('terminal write backlog exceeded safe limit', null, true);
}
function reserveWriteQueueEntry(units) {
if (writeBacklogFailed) return false;
var pendingEntries = writeQueue.length - writeQueueHead;
if (pendingEntries >= WRITE_QUEUE_MAX_ENTRIES || writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS) {
failWriteBacklog(); return false;
}
writeQueueUnits += units; return true;
function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
@@ -583,12 +590,13 @@ ${TERMINAL_WEBVIEW_THEME_JS}
return normalized;
}
function enqueueWrite(data) { var normalized = normalizeStatusDotPresentation(data);
if (!reserveWriteQueueEntry(normalized.length)) return false;
writeQueue.push(normalized); return true;
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) { if (!reserveWriteQueueEntry(0)) return false; writeQueue.push(callback); return true; }
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
@@ -597,7 +605,6 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
if (typeof next === 'string') writeQueueUnits = Math.max(0, writeQueueUnits - next.length);
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
@@ -653,8 +660,8 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
function afterWritesDrained(callback) {
if (afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS) { failWriteBacklog(); return; }
afterDrainCallbacks.push(callback); pumpWrites(terminalGeneration);
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
${TERMINAL_WEBGL_RECOVERY_JS}
@@ -675,7 +682,6 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
webglAddon = null;
ready = false;
resetWriteQueue();
writeBacklogFailed = false;
statusDotPendingSelector = false;
writesDraining = false;
afterDrainCallbacks = [];
@@ -762,7 +768,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
function write(data) {
updateMouseModeFromData(data);
if (!enqueueWrite(data)) return;
enqueueWrite(data);
pumpWrites(terminalGeneration);
// Why: first live data chunk after init may widen the buffer past
// what the post-replay applyFitScale measured. Re-fit once after this
@@ -926,7 +932,6 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
} else if (msg.type === 'clear') {
terminalGeneration++;
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
writeBacklogFailed = false;
statusDotPendingSelector = false;
afterDrainCallbacks = [];
writesDraining = false;
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import {
createTerminalWebViewPendingMessages,
MAX_PENDING_WEB_MESSAGES
} from './terminal-webview-pending-messages'
function flushPending(queue: ReturnType<typeof createTerminalWebViewPendingMessages>) {
const delivered: TerminalWebViewCommand[] = []
queue.flush((message) => delivered.push(message))
return delivered
}
describe('terminal WebView pending messages', () => {
it('retains only the latest snapshot when init is superseded before readiness', () => {
const queue = createTerminalWebViewPendingMessages()
queue.queue({ type: 'write', data: 'current-document-tail' })
queue.queue({ type: 'init', cols: 80, rows: 24, initialData: 'old snapshot' })
queue.queue({ type: 'write', data: 'covered by the replacement snapshot' })
queue.queue({ type: 'init', cols: 100, rows: 30, initialData: 'new snapshot' })
expect(flushPending(queue)).toEqual([
{ type: 'write', data: 'current-document-tail' },
{ type: 'init', cols: 100, rows: 30, initialData: 'new snapshot' }
])
})
it('caps tiny control-message floods while preserving the newest state', () => {
const queue = createTerminalWebViewPendingMessages()
for (let index = 0; index < MAX_PENDING_WEB_MESSAGES + 100; index += 1) {
queue.queue({ type: 'resize', cols: index + 1, rows: 24 })
}
const delivered = flushPending(queue)
expect(delivered).toHaveLength(MAX_PENDING_WEB_MESSAGES)
expect(delivered.at(-1)).toEqual({
type: 'resize',
cols: MAX_PENDING_WEB_MESSAGES + 100,
rows: 24
})
})
})
@@ -2,7 +2,6 @@ import type { TerminalWebViewCommand } from './terminal-webview-messages'
const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000
const MAX_PENDING_WEB_WRITE_MESSAGES = 4096
export const MAX_PENDING_WEB_MESSAGES = 8192
export function createTerminalWebViewPendingMessages() {
let pending: TerminalWebViewCommand[] = []
@@ -19,42 +18,9 @@ export function createTerminalWebViewPendingMessages() {
resetCounters()
}
const removeAt = (index: number) => {
const [removed] = pending.splice(index, 1)
if (removed?.type === 'write') {
pendingWriteBytes = Math.max(0, pendingWriteBytes - removed.data.length)
pendingWriteCount = Math.max(0, pendingWriteCount - 1)
}
}
const supersedePendingInit = (msg: Extract<TerminalWebViewCommand, { type: 'init' }>) => {
const existingIndex = pending.findIndex((candidate) => candidate.type === 'init')
if (existingIndex === -1) {
return false
}
while (pending.length > existingIndex) {
removeAt(pending.length - 1)
}
pending.push(msg)
return true
}
const trimMessageCount = () => {
while (pending.length > MAX_PENDING_WEB_MESSAGES) {
const controlIndex = pending.findIndex(
(candidate) => candidate.type !== 'write' && candidate.type !== 'init'
)
removeAt(Math.max(controlIndex, 0))
}
}
const queue = (msg: TerminalWebViewCommand) => {
if (msg.type === 'init' && supersedePendingInit(msg)) {
return
}
pending.push(msg)
if (msg.type !== 'write') {
trimMessageCount()
return
}
@@ -69,9 +35,12 @@ export function createTerminalWebViewPendingMessages() {
resetCounters()
return
}
removeAt(dropIndex)
const [dropped] = pending.splice(dropIndex, 1)
if (dropped?.type === 'write') {
pendingWriteBytes = Math.max(0, pendingWriteBytes - dropped.data.length)
pendingWriteCount = Math.max(0, pendingWriteCount - 1)
}
}
trimMessageCount()
}
const flush = (send: (msg: TerminalWebViewCommand) => void) => {
@@ -117,16 +117,6 @@ describe('TerminalWebView scroll routing', () => {
expect(source).not.toContain('writeQueue.shift()')
})
it('fails closed when the in-WebView parser backlog reaches its exact bounds', () => {
expect(source).toContain('export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000')
expect(source).toContain('export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096')
expect(source).toContain('export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256')
expect(source).toContain('writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS')
expect(source).toContain('pendingEntries >= WRITE_QUEUE_MAX_ENTRIES')
expect(source).toContain('afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS')
expect(source).toContain("reportEngineError('terminal write backlog exceeded safe limit'")
})
it('bounds native-side pending WebView writes while preserving control messages', () => {
expect(source).toContain('const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000')
expect(source).toContain('const MAX_PENDING_WEB_WRITE_MESSAGES = 4096')
@@ -91,8 +91,7 @@ describe('TerminalWebView text zoom', () => {
expect(terminalHtmlSource).toContain(
'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)'
)
expect(terminalHtmlSource).toContain('var normalized = normalizeStatusDotPresentation(data)')
expect(terminalHtmlSource).toContain('writeQueue.push(normalized)')
expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))')
})
it('normalizes Claude status dots idempotently across write chunks', () => {
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createTerminalWriteCoalescer,
TERMINAL_WRITE_FLUSH_WINDOW_MS,
TERMINAL_WRITE_MAX_PENDING_CHUNKS,
TERMINAL_WRITE_MAX_PENDING_UNITS
} from './terminal-write-coalescer'
@@ -129,20 +128,6 @@ describe('terminal write coalescer', () => {
expect(sink.delivered).toHaveLength(2)
})
it('flushes tiny chunks before their object count can grow unbounded', () => {
vi.useFakeTimers()
const sink = createDeliverySink()
const coalescer = createTerminalWriteCoalescer(sink.deliver)
coalescer.write('leading')
for (let index = 0; index < TERMINAL_WRITE_MAX_PENDING_CHUNKS; index += 1) {
coalescer.write('x')
}
expect(sink.delivered).toEqual(['leading', 'x'.repeat(TERMINAL_WRITE_MAX_PENDING_CHUNKS)])
expect(vi.getTimerCount()).toBe(0)
})
it('treats write("") as a no-op: no delivery, no buffer append, no timer', () => {
vi.useFakeTimers()
const sink = createDeliverySink()
@@ -5,7 +5,6 @@ export const TERMINAL_WRITE_FLUSH_WINDOW_MS = 48
// Why: defense-in-depth only — server ack flow control bounds inflow; this cap keeps
// an upstream flow-control bug from growing the buffer unboundedly. UTF-16 code units.
export const TERMINAL_WRITE_MAX_PENDING_UNITS = 512 * 1024
export const TERMINAL_WRITE_MAX_PENDING_CHUNKS = 4_096
export function createTerminalWriteCoalescer(deliver: (data: string) => void) {
let pendingChunks: string[] = []
@@ -47,10 +46,7 @@ export function createTerminalWriteCoalescer(deliver: (data: string) => void) {
}
pendingChunks.push(data)
pendingUnits += data.length
if (
pendingUnits > TERMINAL_WRITE_MAX_PENDING_UNITS ||
pendingChunks.length >= TERMINAL_WRITE_MAX_PENDING_CHUNKS
) {
if (pendingUnits > TERMINAL_WRITE_MAX_PENDING_UNITS) {
flushNow()
return
}
@@ -1,46 +0,0 @@
import { once } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it } from 'vitest'
import {
appendProcessOutputTail,
attachBoundedProcessLineReader,
PROCESS_LINE_MAX_BYTES
} from '../../scripts/bounded-process-line-reader.mjs'
describe('attachBoundedProcessLineReader', () => {
it('preserves ordinary LF, CRLF, and split UTF-8 lines', () => {
const stream = new PassThrough()
const lines: string[] = []
attachBoundedProcessLineReader(stream, (line) => lines.push(line))
const unicode = Buffer.from('café\n')
stream.write('first\r\nsecond\r')
stream.write('\n')
stream.write(unicode.subarray(0, -2))
stream.end(unicode.subarray(-2))
expect(lines).toEqual(['first', 'second', 'café'])
})
it('retains only the configured prefix of an unterminated line', async () => {
const stream = new PassThrough()
const lines: string[] = []
attachBoundedProcessLineReader(stream, (line) => lines.push(line), 4)
const ended = once(stream, 'end')
stream.end('abcdefgh')
await ended
expect(lines).toEqual(['abcd… [line truncated]'])
})
it('uses a bounded default for process output lines', () => {
expect(PROCESS_LINE_MAX_BYTES).toBe(64 * 1024)
})
it('keeps exact output below the tail cap and newest output above it', () => {
expect(appendProcessOutputTail('first\n', 'second', 32)).toBe('first\nsecond\n')
expect(appendProcessOutputTail('first\n', 'second', 8)).toBe('\nsecond\n')
expect(appendProcessOutputTail('first\n', 'second', 0)).toBe('')
})
})
@@ -1,34 +0,0 @@
import { describe, expect, it } from 'vitest'
import { responseBodyIncludesWithinLimit } from '../../scripts/bounded-response-body.mjs'
function responseWithChunks(chunks: string[], contentLength?: number): Response {
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
}
}),
{ headers: contentLength === undefined ? {} : { 'content-length': String(contentLength) } }
)
}
describe('responseBodyIncludesWithinLimit', () => {
it('matches a marker split across admitted chunks', async () => {
const response = responseWithChunks(['packager-', 'status:running'])
await expect(
responseBodyIncludesWithinLimit(response, 'packager-status:running', 64)
).resolves.toBe(true)
})
it('rejects streamed and declared bodies above the byte limit', async () => {
const streamed = responseWithChunks(['1234', '5'])
const declared = responseWithChunks(['ok'], 5)
await expect(responseBodyIncludesWithinLimit(streamed, 'missing', 4)).resolves.toBe(false)
await expect(responseBodyIncludesWithinLimit(declared, 'ok', 4)).resolves.toBe(false)
})
})
@@ -1,5 +1,3 @@
import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
const BROWSER_SCREENCAST_KIND = 0x62
const BROWSER_SCREENCAST_VERSION = 1
const HEADER_BYTES = 16
@@ -53,7 +51,7 @@ function byteToFormat(value: number): BrowserScreencastFormat | null {
function decodeJson(bytes: Uint8Array): unknown {
try {
return parseMobileJsonTextWithinLimits(new TextDecoder().decode(bytes))
return JSON.parse(new TextDecoder().decode(bytes)) as unknown
} catch {
return null
}

Some files were not shown because too many files have changed in this diff Show More