mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/tcc-local-build-bundle-id
# Conflicts: # src/main/ipc/notifications.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Vendored
+2
-20
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+24
-18
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+27
-18
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user