mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
Merge remote-tracking branch 'origin/main' into pr16868-head
# Conflicts: # src/renderer/src/i18n/locales/ko.json # src/renderer/src/i18n/locales/zh.json
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
name: Install Node dependencies
|
||||
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
|
||||
description: Installs the Node toolchain and repository dependencies for CI jobs.
|
||||
|
||||
inputs:
|
||||
native-runtime:
|
||||
@@ -10,6 +10,18 @@ inputs:
|
||||
description: Node.js version override; defaults to the version declared in package.json.
|
||||
required: false
|
||||
default: ''
|
||||
persist-native-cache:
|
||||
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
outputs:
|
||||
node-version:
|
||||
description: Resolved Node.js version used for the install.
|
||||
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
|
||||
native-cache-scope:
|
||||
description: Operating-system image scope used by the native module cache.
|
||||
value: ${{ steps.native-cache-scope.outputs.scope }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -18,6 +30,7 @@ runs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -51,7 +64,7 @@ runs:
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
|
||||
- name: Use external node-gyp
|
||||
if: inputs.native-runtime != 'none'
|
||||
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
@@ -74,18 +87,50 @@ runs:
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
# Job containers can run composite steps from a source mirror without .git.
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
|
||||
# Node version, and the patch whose contents the build has to match.
|
||||
- name: Restore compiled native modules
|
||||
# Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
|
||||
# an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
|
||||
# Native addons built on a newer Linux image can require glibc symbols
|
||||
# missing from an older runner/container. ImageOS distinguishes hosted
|
||||
# Windows/macOS images; /etc/os-release also distinguishes Linux containers.
|
||||
- name: Resolve native cache scope
|
||||
id: native-cache-scope
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
scope="${ImageOS:-$RUNNER_OS}"
|
||||
if [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
scope="${ID:-linux}-${VERSION_ID:-unknown}"
|
||||
fi
|
||||
echo "scope=$scope" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore compiled native modules
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Restore compiled native modules without saving
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
|
||||
@@ -71,30 +71,17 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
|
||||
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
|
||||
# permission on Linux runners; node-pty's install fallback then fails
|
||||
# before this smoke job can exercise the native package.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
|
||||
# is no root config) and falls back to the 5s default timeout with no
|
||||
# Windows worker cap, so the real csc.exe launcher-compile tests time out
|
||||
# on hosted Windows. Use the shared config so this job matches pnpm test.
|
||||
- run: >-
|
||||
pnpm vitest run --config config/vitest.config.ts
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
@@ -159,13 +146,9 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
- name: Owner-loss benchmark process cleanup
|
||||
run: >-
|
||||
pnpm vitest run
|
||||
|
||||
+35
-102
@@ -17,6 +17,10 @@ on:
|
||||
description: JSON array of changed specs; empty runs the full suite
|
||||
required: false
|
||||
type: string
|
||||
ssh_source_changed:
|
||||
description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -40,36 +44,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
|
||||
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
|
||||
# errors.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
# Why: the build's plain-Node daemon smoke load resolves node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: node
|
||||
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
@@ -130,11 +108,11 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: pnpm install rebuilds native modules, and those postinstall
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
# Why: pnpm install used to rebuild native modules here; the composite
|
||||
# action restores them from cache and only compiles on a miss. The
|
||||
# toolchain is still required for that miss path, and for paired Quick
|
||||
# Open coverage which exercises the resource-bounded host search.
|
||||
- name: Install native build tools
|
||||
# Why: paired Quick Open coverage exercises the resource-bounded host search.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
@@ -143,33 +121,9 @@ jobs:
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
|
||||
# release.yml so the invariant "this workaround is Linux-only" is
|
||||
# consistent across all three workflows, even though this job
|
||||
# currently pins runs-on: ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -222,25 +176,9 @@ jobs:
|
||||
# lane now receives those specs from pr.yml's SSH source mapping.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -287,14 +225,21 @@ jobs:
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
# Why ssh_source_changed first: this lane used to trigger on SSH source only as a side
|
||||
# effect of one route listing a startup-readiness spec — pruning that spec would have
|
||||
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
|
||||
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
|
||||
# lane, so editing one must still run it here.
|
||||
if: >-
|
||||
inputs.test_files == '' ||
|
||||
inputs.ssh_source_changed == 'true' ||
|
||||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
|
||||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
# Why 35: the parking, retention, startup-exec, and paired parity specs run
|
||||
# serially on isolated Electron/SSH fixtures after watcher isolation.
|
||||
timeout-minutes: 35
|
||||
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
|
||||
# ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
|
||||
# — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -305,29 +250,9 @@ jobs:
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
|
||||
# stays consistent across workflows even while this job is ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -346,6 +271,14 @@ jobs:
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
|
||||
# spec below skipped itself while the shard still reported green. Running them on this one
|
||||
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
|
||||
# legible as an SSH-named failure.
|
||||
- name: Run remaining Docker SSH E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Node next compatibility
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Full future-runtime coverage is useful, but not worth doubling every PR matrix.
|
||||
- cron: '0 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: node-next-compat
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["26"]'
|
||||
+127
-108
@@ -16,15 +16,29 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Why: a README/docs-only PR used to start the full matrix (32 test shards,
|
||||
# Why: a README/docs-only PR used to start the full matrix (test shards,
|
||||
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
|
||||
# filters on `on.pull_request` would drop the `verify` check entirely; this
|
||||
# detector keeps verify as the required aggregate and skips the expensive jobs.
|
||||
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
|
||||
# inputs are unchanged; empty diffs fail closed and run everything.
|
||||
code_paths:
|
||||
name: detect code-relevant changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
|
||||
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
|
||||
test: ${{ steps.filter.outputs.test }}
|
||||
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
|
||||
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
|
||||
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
|
||||
package: ${{ steps.filter.outputs.package }}
|
||||
package_windows: ${{ steps.filter.outputs.package_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -48,14 +62,12 @@ jobs:
|
||||
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
|
||||
echo "Changed paths:"
|
||||
printf '%s\n' "$CHANGED"
|
||||
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
|
||||
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
|
||||
echo "should_run=$SHOULD_RUN"
|
||||
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -70,6 +82,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Lint
|
||||
run: pnpm exec oxlint --format github
|
||||
@@ -188,7 +202,7 @@ jobs:
|
||||
|
||||
typecheck:
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -216,7 +230,7 @@ jobs:
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.git_compatibility == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -283,7 +297,7 @@ jobs:
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -313,7 +327,7 @@ jobs:
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.shell_contracts == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Why: this job's cost is almost entirely package download, and a stalled mirror has
|
||||
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
|
||||
@@ -429,17 +443,13 @@ jobs:
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
# Cache-key input changes would otherwise make every shard compile the same
|
||||
# native addon concurrently. Prime the supported Node ABI before the matrix fans out.
|
||||
test_native_cache:
|
||||
name: prepare test native cache node 24
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.native_cache_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shard_total: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -450,39 +460,24 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: '24'
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
test:
|
||||
needs: [code_paths, test_native_cache]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
|
||||
# would pay for it 32 times to run one file in whichever shard it landed in.
|
||||
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
|
||||
# would pay for it on every shard to run one file in whichever shard it landed in.
|
||||
orcad_browser:
|
||||
name: orcad browser provider
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.orcad_browser == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -518,7 +513,7 @@ jobs:
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.cross-version-wire == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -550,7 +545,7 @@ jobs:
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -575,7 +570,7 @@ jobs:
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.package == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -647,7 +642,7 @@ jobs:
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.package_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -657,17 +652,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
@@ -678,18 +662,22 @@ jobs:
|
||||
restore-keys: |
|
||||
electron-builder-windows-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
# Why persist-native-cache false: this job later rebuilds the same path for
|
||||
# Electron. A post-job save would store the Electron ABI under the Node key.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: deps
|
||||
with:
|
||||
native-runtime: node
|
||||
persist-native-cache: 'false'
|
||||
|
||||
# Why: node-pty prefers its upstream prebuild, which does not contain
|
||||
# Orca's Windows patch, so the job-object exports would be absent and the
|
||||
# suite below would test an unpatched binary. build_from_source removes
|
||||
# the prebuild, and the package's postinstall restores the ConPTY runtime
|
||||
# files that a bare node-gyp rebuild would miss.
|
||||
- name: Rebuild node-pty from patched source
|
||||
env:
|
||||
npm_config_build_from_source: 'true'
|
||||
run: pnpm rebuild node-pty
|
||||
- name: Save compiled Node native modules
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
@@ -724,9 +712,26 @@ jobs:
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
# job already packages and smoke-tests an AppImage built that way.
|
||||
- name: Cache Windows CLI launcher
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: native/windows-cli-launcher/.build
|
||||
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
|
||||
|
||||
- name: Build package inputs
|
||||
env:
|
||||
ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
|
||||
run: pnpm run build:release:parallel
|
||||
|
||||
- name: Restore compiled Electron native modules
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
|
||||
@@ -754,6 +759,7 @@ jobs:
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
test_files: ${{ steps.filter.outputs.test_files }}
|
||||
ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -776,6 +782,11 @@ jobs:
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
|
||||
# spec name surviving in a route's list. Same routes, so the two cannot drift.
|
||||
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
|
||||
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "SSH source changed: $SSH_SOURCE_CHANGED"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed E2E specs: $TEST_FILES_JSON"
|
||||
@@ -794,6 +805,7 @@ jobs:
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
test_files: ${{ needs.e2e-paths.outputs.test_files }}
|
||||
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
@@ -807,6 +819,7 @@ jobs:
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- cross-version-wire
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
@@ -826,57 +839,63 @@ jobs:
|
||||
CODE_PATHS: ${{ needs.code_paths.result }}
|
||||
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
|
||||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
|
||||
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
|
||||
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
|
||||
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
|
||||
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
|
||||
run: |
|
||||
if [ "$CODE_PATHS" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHOULD_RUN" != "true" ]; then
|
||||
echo "Docs-only change; expensive PR checks skipped."
|
||||
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
for result in \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "skipped" ]; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
# Require success when the PR has code-relevant changes
|
||||
for result in \
|
||||
"$CODE_PATHS" \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
failed=0
|
||||
check_job() {
|
||||
local name="$1" result="$2" should="$3"
|
||||
if [ "$should" = "true" ]; then
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "$name: expected success, got $result"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
if [ "$result" != "skipped" ]; then
|
||||
echo "$name: expected skipped, got $result"
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
# Require success when the PR has code-relevant changes
|
||||
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
|
||||
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
|
||||
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
|
||||
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
|
||||
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
|
||||
check_job test "$TEST" "$TEST_SHOULD_RUN"
|
||||
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
|
||||
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
|
||||
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
|
||||
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
|
||||
check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
|
||||
exit "$failed"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Unit tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_versions:
|
||||
description: JSON array of Node.js major versions to test.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ${{ fromJSON(inputs.node_versions) }}
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
@@ -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.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/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.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/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.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [Download APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
@@ -23,14 +19,7 @@ inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
@@ -52,7 +41,6 @@ inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
@@ -61,15 +49,12 @@ inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
@@ -82,40 +67,25 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.ts
|
||||
inline src/renderer/src/runtime/runtime-linear-client.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
mobile-config scripts/repro-worktree-startup-stream.ts
|
||||
mobile-config src/browser/MobileBrowserPane.tsx
|
||||
mobile-config src/components/CustomKeyModal.tsx
|
||||
mobile-config src/components/NewWorktreeModal.tsx
|
||||
mobile-config src/components/mobile-rich-markdown-editor-html.ts
|
||||
mobile-config src/terminal/terminal-accessory-keys.ts
|
||||
mobile-config src/terminal/terminal-webview-html.ts
|
||||
mobile-config src/transport/rpc-client.ts
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -3,7 +3,7 @@
|
||||
@@ -3,7 +3,6 @@
|
||||
{
|
||||
"target_name": "windows_process_tree",
|
||||
"dependencies": [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
"conditions": [
|
||||
['OS=="win"', {
|
||||
@@ -16,9 +16,6 @@
|
||||
@@ -15,14 +14,13 @@
|
||||
"src/process_commandline.cc"
|
||||
],
|
||||
"include_dirs": [],
|
||||
- "include_dirs": [],
|
||||
+ "include_dirs": ["deps/node-addon-api"],
|
||||
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
|
||||
"libraries": [ 'psapi.lib' ],
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
+ "ExceptionHandling": 1,
|
||||
"AdditionalOptions": [
|
||||
"/guard:cf",
|
||||
"/sdl",
|
||||
diff --git a/src/process.cc b/src/process.cc
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
|
||||
--- a/src/process.cc
|
||||
|
||||
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -1,13 +1,10 @@
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'dependencies': [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
|
||||
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
|
||||
+ 'cflags_cc!': [
|
||||
+ '-std=gnu++20'
|
||||
+ ],
|
||||
+ 'cflags_cc': [
|
||||
+ '-std=gnu++2a'
|
||||
+ ],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
- 'msvs_configuration_attributes': {
|
||||
|
||||
+532
-117
File diff suppressed because one or more lines are too long
@@ -1,41 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
export function shouldReuseCompiledWindowsCliLauncher(
|
||||
outputPath,
|
||||
sourcePath,
|
||||
{ reuseCached = false } = {}
|
||||
) {
|
||||
if (!existsSync(outputPath)) {
|
||||
return false
|
||||
}
|
||||
// Why reuseCached: Actions cache keys already hash the C# source, but restore
|
||||
// does not preserve mtimes, so a hit would look stale and recompile anyway.
|
||||
if (reuseCached) {
|
||||
return true
|
||||
}
|
||||
return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
|
||||
}
|
||||
|
||||
function defaultOutputPath(projectRoot) {
|
||||
@@ -58,3 +41,47 @@ function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index !== -1 ? process.argv[index + 1] : undefined
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
if (
|
||||
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
|
||||
reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
|
||||
})
|
||||
) {
|
||||
console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
|
||||
process.exit(0)
|
||||
}
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
|
||||
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
@@ -37,6 +40,33 @@ function itWindows(name, test) {
|
||||
}
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
it('reuses a compiled launcher that is at least as new as the C# source', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
|
||||
try {
|
||||
const sourcePath = join(root, 'OrcaCliLauncher.cs')
|
||||
const outputPath = join(root, '.build', 'orca.exe')
|
||||
mkdirSync(join(root, '.build'))
|
||||
writeFileSync(sourcePath, 'source\n')
|
||||
writeFileSync(outputPath, 'binary\n')
|
||||
const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
|
||||
utimesSync(outputPath, later, later)
|
||||
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
|
||||
writeFileSync(sourcePath, 'changed\n')
|
||||
const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
|
||||
utimesSync(sourcePath, sourceLater, sourceLater)
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
|
||||
expect(
|
||||
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
|
||||
).toBe(true)
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
|
||||
false
|
||||
)
|
||||
} finally {
|
||||
removeFixtureTree(root)
|
||||
}
|
||||
})
|
||||
|
||||
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
|
||||
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
|
||||
try {
|
||||
|
||||
@@ -26,9 +26,11 @@ import {
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync
|
||||
readSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..')
|
||||
@@ -67,13 +69,16 @@ function assertPatchApplied() {
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
|
||||
if (bindingGyp.includes('node_addon_api.gyp')) {
|
||||
throw new Error(
|
||||
'binding.gyp still uses require("node-addon-api").targets. That path is ' +
|
||||
'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
|
||||
'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
|
||||
'project path incorrectly on Windows. ' +
|
||||
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
|
||||
throw new Error('binding.gyp does not use the staged node-addon-api headers.')
|
||||
}
|
||||
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
|
||||
if (processCc.includes('process_count < 1024')) {
|
||||
throw new Error(
|
||||
@@ -83,6 +88,58 @@ function assertPatchApplied() {
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm can materialize this CRLF package without applying its patch. Repair the
|
||||
// load-bearing build settings before node-gyp so the release build stays safe.
|
||||
function applyWindowsProcessTreeBuildFixes() {
|
||||
const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
|
||||
const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
|
||||
const nodeAddonApiDir = dirname(
|
||||
createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
|
||||
)
|
||||
const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
|
||||
let bindingGyp = readFileSync(bindingPath, 'utf8')
|
||||
let processCc = readFileSync(processPath, 'utf8')
|
||||
const originalBinding = bindingGyp
|
||||
const originalProcess = processCc
|
||||
|
||||
for (const dynamicDependency of [
|
||||
String.raw`<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except`,
|
||||
String.raw`<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except`,
|
||||
'../../node-addon-api/node_addon_api.gyp:node_addon_api_except'
|
||||
]) {
|
||||
bindingGyp = bindingGyp.replace(`"${dynamicDependency}",`, '')
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"include_dirs": []',
|
||||
'"include_dirs": ["deps/node-addon-api"],\n "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"]'
|
||||
)
|
||||
if (!bindingGyp.includes('"ExceptionHandling": 1')) {
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"VCCLCompilerTool": {',
|
||||
'"VCCLCompilerTool": {\n "ExceptionHandling": 1,'
|
||||
)
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
/\r?\n\s*"msvs_configuration_attributes": \{\s*"SpectreMitigation": "Spectre"\s*\},?/s,
|
||||
''
|
||||
)
|
||||
processCc = processCc.replace(/process_count < 1024 && /, '')
|
||||
|
||||
if (bindingGyp !== originalBinding) {
|
||||
writeFileSync(bindingPath, bindingGyp)
|
||||
}
|
||||
if (processCc !== originalProcess) {
|
||||
writeFileSync(processPath, processCc)
|
||||
}
|
||||
mkdirSync(stagedHeaderDir, { recursive: true })
|
||||
for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h']) {
|
||||
copyFileSync(join(nodeAddonApiDir, header), join(stagedHeaderDir, header))
|
||||
}
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
|
||||
function readPeMachine(binaryPath) {
|
||||
const fd = openSync(binaryPath, 'r')
|
||||
@@ -109,6 +166,7 @@ function main() {
|
||||
if (!existsSync(PACKAGE_DIR)) {
|
||||
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
|
||||
}
|
||||
applyWindowsProcessTreeBuildFixes()
|
||||
assertPatchApplied()
|
||||
|
||||
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
|
||||
|
||||
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
|
||||
const job = workflow.jobs['mac-native-owner-smoke']
|
||||
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job['runs-on']).toBe('macos-15')
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(install.with['native-runtime']).toBe('electron')
|
||||
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
|
||||
const cleanupRun = runs.find((run) =>
|
||||
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { release } from 'node:os'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
|
||||
if (!initial.ok) {
|
||||
printCheckError(initial)
|
||||
}
|
||||
runPnpm(['rebuild', 'node-pty'])
|
||||
const failedModules = initial.failures.map((failure) => failure.moduleName)
|
||||
const rebuildModules = [
|
||||
'node-pty',
|
||||
...failedModules.filter((moduleName) => moduleName !== 'node-pty')
|
||||
]
|
||||
rebuildNodeRuntimeModules(rebuildModules)
|
||||
verifyNodeRuntimeAfterRebuild()
|
||||
return
|
||||
}
|
||||
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
|
||||
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
|
||||
)
|
||||
printCheckError(initial)
|
||||
runPnpm(['rebuild', ...failedModules])
|
||||
rebuildNodeRuntimeModules(failedModules)
|
||||
verifyNodeRuntimeAfterRebuild()
|
||||
}
|
||||
|
||||
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
|
||||
// patch only lands in the source-built build/Release artifacts.
|
||||
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
|
||||
// Windows patches only land in the source-built build/Release artifacts.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
}
|
||||
|
||||
function requiresPatchedNodePtySourceBuild() {
|
||||
function patchedNodePtyArtifactPaths(nodePtyDir) {
|
||||
if (process.platform === 'win32') {
|
||||
return false
|
||||
const releaseDir = resolve(nodePtyDir, 'build', 'Release')
|
||||
return [
|
||||
resolve(releaseDir, 'conpty.node'),
|
||||
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
|
||||
]
|
||||
}
|
||||
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
return artifactPaths
|
||||
}
|
||||
|
||||
function requiresPatchedNodePtySourceBuild() {
|
||||
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
|
||||
if (!existsSync(nodePtyPatchPath)) {
|
||||
return false
|
||||
@@ -351,16 +365,32 @@ function getWindowsBuildNumber() {
|
||||
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
|
||||
}
|
||||
|
||||
function runPnpm(args) {
|
||||
function rebuildNodeRuntimeModules(moduleNames) {
|
||||
for (const moduleName of moduleNames) {
|
||||
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
|
||||
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
||||
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
|
||||
if (moduleName === 'node-pty' && process.platform === 'win32') {
|
||||
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runPnpm(args, { cwd = projectDir } = {}) {
|
||||
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env =
|
||||
process.platform === 'linux' && args.includes('node-gyp')
|
||||
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
|
||||
: process.env
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: projectDir,
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32'
|
||||
shell: process.platform === 'win32',
|
||||
env
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
|
||||
if (result.error) {
|
||||
console.error(formatError(result.error))
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
@@ -42,7 +43,11 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm rebuild node-pty\n')
|
||||
expect(log).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
if (process.platform === 'linux') {
|
||||
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
|
||||
}
|
||||
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
|
||||
@@ -52,6 +57,41 @@ describe('ensure-native-runtime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'rebuilds other failed Windows addons with patched node-pty',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
expect(log).toContain(join('node_modules', 'windows-native-registry'))
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds patched node-pty artifacts even when the Node load check passes',
|
||||
() => {
|
||||
@@ -80,7 +120,7 @@ describe('ensure-native-runtime', () => {
|
||||
expect(result.stderr).toContain(
|
||||
'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
)
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -114,7 +154,7 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -148,7 +188,7 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -178,9 +218,15 @@ function envWithPrependedPath(binDir, extraEnv) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakeNativeModules(projectDir) {
|
||||
function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'package.json'),
|
||||
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
|
||||
)
|
||||
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
|
||||
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
@@ -197,15 +243,29 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
if (!markerExists) {
|
||||
throw new Error('ABI mismatch sentinel')
|
||||
}
|
||||
return {
|
||||
dir: '../build/Release/',
|
||||
module: {
|
||||
listJobProcessIds() {},
|
||||
terminateJob() {},
|
||||
assignCurrentProcessToJob() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'package.json'),
|
||||
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
|
||||
)
|
||||
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
|
||||
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
@@ -218,23 +278,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
const dir = ${JSON.stringify(nativeDir)} ??
|
||||
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
|
||||
return { dir, module: {} }
|
||||
return {
|
||||
dir,
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
}
|
||||
|
||||
function writeFakeWindowsRegistry(projectDir) {
|
||||
function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
|
||||
mkdirSync(registryDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(registryDir, 'index.js'),
|
||||
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
|
||||
join(registryDir, 'package.json'),
|
||||
'{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
|
||||
)
|
||||
const markerGate = requiresMarker
|
||||
? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
|
||||
: ''
|
||||
writeFileSync(
|
||||
join(registryDir, 'index.js'),
|
||||
`exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
|
||||
)
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(processTreeDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
@@ -245,6 +322,13 @@ function writeNodePtyPatchFile(projectDir) {
|
||||
function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(join(buildDir, 'conpty.node'), '')
|
||||
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
|
||||
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
|
||||
return
|
||||
}
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
@@ -260,6 +344,15 @@ function writeFakePnpm(binDir) {
|
||||
const { appendFileSync, writeFileSync } = require('node:fs')
|
||||
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
|
||||
)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
|
||||
)
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
|
||||
}
|
||||
|
||||
describe('install-node-dependencies action', () => {
|
||||
it('skips the lockfile diff when a job container has no Git metadata', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
|
||||
|
||||
const result = executeInstallScript(fixture)
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the lockfile diff for a bare repository', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
|
||||
expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
|
||||
|
||||
const result = executeInstallScript(fixture)
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['package.json', '{"name":"changed"}\n'],
|
||||
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
|
||||
|
||||
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
|
||||
expect(copyStep.run).toContain('git add "$CASK_PATH"')
|
||||
})
|
||||
|
||||
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const installStep = parsedWorkflow.jobs.test.steps.find(
|
||||
it('installs the Electron package binary in the shared unit-test workflow', () => {
|
||||
const unitTestWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
|
||||
)
|
||||
const installStep = unitTestWorkflow.jobs.test.steps.find(
|
||||
(step) => step.name === 'Install Electron package binary for tests'
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,196 @@ const DOCS_ONLY_FILES = new Set([
|
||||
|
||||
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
|
||||
|
||||
export const PR_CHECK_JOBS = [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
]
|
||||
|
||||
const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
|
||||
|
||||
const GLOBAL_FORCE_PREFIXES = [
|
||||
'.github/workflows/pr.yml',
|
||||
'.github/actions/install-node-dependencies/',
|
||||
'config/scripts/pr-code-change-scope'
|
||||
]
|
||||
|
||||
const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
|
||||
|
||||
const GIT_COMPAT_PREFIXES = [
|
||||
'src/shared/git-',
|
||||
'src/shared/review-head-tracking-ref',
|
||||
'src/main/git/',
|
||||
'src/relay/git-',
|
||||
'config/scripts/git-binary-compatibility'
|
||||
]
|
||||
|
||||
const XTERM_PREFIXES = [
|
||||
'config/patches/xterm-upstream.json',
|
||||
'config/patches/@xterm',
|
||||
'config/patches/xterm-src/',
|
||||
'config/scripts/regenerate-xterm-patches'
|
||||
]
|
||||
|
||||
const SHELL_PREFIXES = [
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
|
||||
'src/main/daemon/shell-ready',
|
||||
'src/main/daemon/daemon-bash-shell-ready',
|
||||
'src/main/daemon/daemon-shell-ready-wrapper',
|
||||
'src/main/daemon/node-pty-fd-leak',
|
||||
'src/main/providers/local-pty-shell-ready',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example',
|
||||
'src/main/pty/',
|
||||
'src/main/shell-templates',
|
||||
'src/main/shell-startup-',
|
||||
'src/main/shell-wrapper-',
|
||||
'src/main/terminal-history-fish',
|
||||
'src/main/zsh-',
|
||||
'src/renderer/src/components/terminal-pane/fish-color-scheme',
|
||||
'src/shared/fish-',
|
||||
'src/shared/pty-reply-echo-shapes',
|
||||
'src/shared/startup-shell-portability',
|
||||
'src/shared/posix-command-path-lookup',
|
||||
'config/patches/node-pty@',
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/node-pty-job-ownership'
|
||||
]
|
||||
|
||||
const ORCAD_BROWSER_PREFIXES = [
|
||||
'src/main/orcad/external-chromium-',
|
||||
'src/main/orcad/orcad-browser-provider',
|
||||
'src/main/orcad/orcad-agent-browser-binary',
|
||||
'src/main/orcad/electron-serve-browser-process'
|
||||
]
|
||||
|
||||
const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'tests/e2e/cross-version-wire/',
|
||||
'src/shared/protocol-version',
|
||||
'src/shared/terminal-stream-protocol',
|
||||
'src/shared/browser-client-host-protocol',
|
||||
'src/shared/browser-network-tunnel-protocol',
|
||||
'src/shared/browser-client-host-placement',
|
||||
'src/main/runtime/rpc/dispatcher',
|
||||
'src/main/runtime/rpc/methods/browser-tab-create-schema',
|
||||
'src/main/runtime/rpc/methods/terminal',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
|
||||
]
|
||||
|
||||
const MANAGED_HOOK_PREFIXES = [
|
||||
'config/scripts/smoke-managed-hook-runtime-node18',
|
||||
'config/scripts/build-relay',
|
||||
'src/relay/',
|
||||
'src/shared/agent-hook',
|
||||
'src/main/agent-hooks/'
|
||||
]
|
||||
|
||||
const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/rebuild-native-deps',
|
||||
'config/scripts/node-pty-job-ownership',
|
||||
'config/scripts/electron-builder-native-rebuild',
|
||||
'config/patches/node-pty@',
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
]
|
||||
|
||||
const NATIVE_CACHE_FILES = new Set([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs'
|
||||
])
|
||||
|
||||
const NATIVE_CACHE_PREFIXES = [
|
||||
'config/patches/node-pty@',
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
]
|
||||
|
||||
const SHARED_PACKAGE_PREFIXES = [
|
||||
'electron.vite.config.ts',
|
||||
'config/electron-builder',
|
||||
'config/packaged-runtime',
|
||||
'config/build-plugins/',
|
||||
'config/scripts/build-',
|
||||
'config/scripts/smoke-packaged',
|
||||
'config/scripts/install-electron-package-binary',
|
||||
'config/scripts/verify-packaged',
|
||||
'config/scripts/verify-linux-glibc',
|
||||
'config/scripts/run-electron-vite',
|
||||
'skills/',
|
||||
'skill-guides/',
|
||||
'resources/build/',
|
||||
'resources/onboarding/',
|
||||
'resources/plugins/',
|
||||
'resources/skills/',
|
||||
...NATIVE_RUNTIME_PREFIXES
|
||||
]
|
||||
|
||||
const LINUX_PACKAGE_PREFIXES = [
|
||||
...SHARED_PACKAGE_PREFIXES,
|
||||
'native/computer-use-linux/',
|
||||
'resources/linux/',
|
||||
'config/scripts/run-headless-serve'
|
||||
]
|
||||
|
||||
const WINDOWS_PACKAGE_PREFIXES = [
|
||||
...SHARED_PACKAGE_PREFIXES,
|
||||
'native/windows-cli-launcher/',
|
||||
'native/computer-use-windows/',
|
||||
'resources/win32/',
|
||||
'config/scripts/build-windows-cli-launcher',
|
||||
'config/scripts/windows-pty-native-capability',
|
||||
'tests/tools/windows-pty-native-capability-smoke/'
|
||||
]
|
||||
|
||||
const LINUX_PACKAGE_TESTS = [
|
||||
'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
|
||||
'src/main/browser/browser-route-tcp-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-h3-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
|
||||
]
|
||||
|
||||
const WINDOWS_PACKAGE_TESTS = [
|
||||
...LINUX_PACKAGE_TESTS,
|
||||
'config/scripts/rebuild-native-deps.test.mjs',
|
||||
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
|
||||
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
|
||||
'src/shared/child-process/windows-command-line.win32.test.ts',
|
||||
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
|
||||
'src/main/windows/windows-pty-job.win32.test.ts',
|
||||
'src/main/windows/windows-host-job.win32.test.ts',
|
||||
'src/main/wsl/wsl-runner.test.ts',
|
||||
'src/main/wsl/wsl-guest-environment.test.ts',
|
||||
'src/main/wsl/wsl-invocation-boundary.test.ts',
|
||||
'src/main/wsl/wsl-executable-path.win32.test.ts',
|
||||
'src/main/wsl/wsl-w1-w3-contract.test.ts',
|
||||
'src/shared/source-scan/source-tree-scan.test.ts',
|
||||
'src/main/cli/wsl-cli-powershell-boundary.test.ts',
|
||||
'src/main/cursor/hook-service.test.ts',
|
||||
'src/main/orca-profiles/profile-index-store.test.ts',
|
||||
'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
|
||||
'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
|
||||
'src/shared/secure-file-fsync-flags.test.ts',
|
||||
'src/main/ipc/pty-codex-account-attribution.test.ts',
|
||||
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
|
||||
]
|
||||
|
||||
const DESKTOP_IRRELEVANT_PREFIXES = [
|
||||
'mobile/',
|
||||
'.github/workflows/mobile.yml',
|
||||
'.github/workflows/mobile-ios-release.yml',
|
||||
'.github/workflows/mobile-android-release.yml'
|
||||
]
|
||||
|
||||
export function isDocsOnlyPath(file) {
|
||||
if (DOCS_ONLY_FILES.has(file)) {
|
||||
return true
|
||||
@@ -32,10 +222,91 @@ export function shouldRunPrChecks(changedFiles) {
|
||||
if (changedFiles.length === 0) {
|
||||
return true
|
||||
}
|
||||
return changedFiles.some((file) => !isDocsOnlyPath(file))
|
||||
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
|
||||
}
|
||||
|
||||
export function classifyPrJobs(changedFiles) {
|
||||
const emptyDiff = changedFiles.length === 0
|
||||
const shouldRun = shouldRunPrChecks(changedFiles)
|
||||
const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
|
||||
const jobs = Object.fromEntries(
|
||||
PR_CHECK_JOBS.map((job) => [
|
||||
job,
|
||||
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
|
||||
])
|
||||
)
|
||||
return {
|
||||
should_run: shouldRun,
|
||||
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
|
||||
...jobs
|
||||
}
|
||||
}
|
||||
|
||||
function jobDetector(job) {
|
||||
switch (job) {
|
||||
case 'git_compatibility':
|
||||
return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
|
||||
case 'xterm_patch_sync':
|
||||
return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
|
||||
case 'shell_contracts':
|
||||
return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
|
||||
case 'orcad_browser':
|
||||
return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
|
||||
case 'cross-version-wire':
|
||||
return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
|
||||
case 'managed_hook_node18':
|
||||
return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
|
||||
case 'package':
|
||||
return (files) => files.some(isLinuxPackagePath)
|
||||
case 'package_windows':
|
||||
return (files) => files.some(isWindowsPackagePath)
|
||||
default:
|
||||
return () => true
|
||||
}
|
||||
}
|
||||
|
||||
function isLinuxPackagePath(file) {
|
||||
return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
|
||||
}
|
||||
|
||||
function isWindowsPackagePath(file) {
|
||||
return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
|
||||
}
|
||||
|
||||
function isProductBundlePath(file, extraPrefixes) {
|
||||
if (isTestFile(file)) {
|
||||
return false
|
||||
}
|
||||
if (file.startsWith('src/')) {
|
||||
return true
|
||||
}
|
||||
return matchesPrefix(file, extraPrefixes)
|
||||
}
|
||||
|
||||
function isTestFile(file) {
|
||||
return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
|
||||
}
|
||||
|
||||
function isDesktopIrrelevantPath(file) {
|
||||
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
|
||||
}
|
||||
|
||||
function isNativeCacheInputPath(file) {
|
||||
return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES)
|
||||
}
|
||||
|
||||
function isGlobalForcePath(file) {
|
||||
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
|
||||
}
|
||||
|
||||
function matchesPrefix(file, prefixes) {
|
||||
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
|
||||
process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
|
||||
const classification = classifyPrJobs(files)
|
||||
for (const [name, value] of Object.entries(classification)) {
|
||||
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
|
||||
import {
|
||||
classifyPrJobs,
|
||||
isDocsOnlyPath,
|
||||
PR_CHECK_JOBS,
|
||||
shouldRunPrChecks
|
||||
} from './pr-code-change-scope.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
|
||||
const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
|
||||
const expensiveJobs = [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
@@ -15,12 +20,34 @@ const expensiveJobs = [
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
]
|
||||
|
||||
const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
|
||||
|
||||
function expectedJobs(overrides, { alwaysOn = true } = {}) {
|
||||
return Object.fromEntries(
|
||||
PR_CHECK_JOBS.map((job) => [
|
||||
job,
|
||||
(alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function expectClassification(files, overrides) {
|
||||
const result = classifyPrJobs(files)
|
||||
const shouldRun = shouldRunPrChecks(files)
|
||||
expect(result.should_run).toBe(shouldRun)
|
||||
expect(result).toMatchObject({
|
||||
should_run: shouldRun,
|
||||
...expectedJobs(overrides, { alwaysOn: shouldRun })
|
||||
})
|
||||
}
|
||||
|
||||
describe('docs-only path classification', () => {
|
||||
it('treats the WeChat README PR files as docs-only', () => {
|
||||
expect(
|
||||
@@ -54,9 +81,170 @@ describe('docs-only path classification', () => {
|
||||
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
|
||||
expect(shouldRunPrChecks([])).toBe(true)
|
||||
})
|
||||
|
||||
it('does not start desktop PR Checks for mobile-only diffs', () => {
|
||||
expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PR Checks docs-only skip wiring', () => {
|
||||
describe('per-job path classification', () => {
|
||||
it('runs every expensive job on an empty diff rather than skipping by accident', () => {
|
||||
const result = classifyPrJobs([])
|
||||
expect(result.should_run).toBe(true)
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
expect(result[job], job).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips every expensive job for docs-only diffs', () => {
|
||||
expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
|
||||
})
|
||||
|
||||
it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
|
||||
expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs Git compatibility only when git capability inputs change', () => {
|
||||
expectClassification(['src/shared/git-capability-cache.ts'], {
|
||||
git_compatibility: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
|
||||
git_compatibility: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs xterm patch sync only when xterm inputs change', () => {
|
||||
expectClassification(['config/patches/xterm-upstream.json'], {
|
||||
xterm_patch_sync: true
|
||||
})
|
||||
expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
|
||||
xterm_patch_sync: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs native package jobs only for the platform that ships the changed native', () => {
|
||||
expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['native/computer-use-linux/runtime.py'], {
|
||||
package: true
|
||||
})
|
||||
expectClassification(['native/computer-use-macos/Package.swift'], {})
|
||||
})
|
||||
|
||||
it('runs shell contracts when live-shell inputs change', () => {
|
||||
expectClassification(['src/main/daemon/shell-ready.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
|
||||
expectClassification(['src/main/shell-templates.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
|
||||
for (const file of [
|
||||
'src/main/orcad/external-chromium-browser-session.ts',
|
||||
'src/main/orcad/external-chromium-command-arguments.ts',
|
||||
'src/main/orcad/external-chromium-tab-registry.ts',
|
||||
'src/main/orcad/external-chromium-tab-projection.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
orcad_browser: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs cross-version wire checks for every working-tree wire module', () => {
|
||||
for (const file of [
|
||||
'src/shared/protocol-version.ts',
|
||||
'src/shared/terminal-stream-protocol.ts',
|
||||
'src/main/runtime/rpc/dispatcher.ts',
|
||||
'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
|
||||
'src/main/runtime/rpc/methods/terminal.ts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
'cross-version-wire': true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
expectClassification(
|
||||
['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
|
||||
{ 'cross-version-wire': true }
|
||||
)
|
||||
})
|
||||
|
||||
it('runs workflow-self-change and lockfile diffs as force-all', () => {
|
||||
const result = classifyPrJobs(['.github/workflows/pr.yml'])
|
||||
expect(result.should_run).toBe(true)
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
expect(result[job], job).toBe(true)
|
||||
}
|
||||
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
|
||||
})
|
||||
|
||||
it('primes native caches only when their immutable inputs change', () => {
|
||||
expect(classifyPrJobs([]).native_cache_changed).toBe(true)
|
||||
expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
|
||||
expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
|
||||
for (const file of [
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'config/patches/node-pty@1.1.0.patch'
|
||||
]) {
|
||||
expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps unit-test-only diffs out of packaging', () => {
|
||||
expectClassification(['src/main/git/git-status.test.ts'], {
|
||||
git_compatibility: true
|
||||
})
|
||||
})
|
||||
|
||||
it('emits GitHub output pairs from the shipped CLI', () => {
|
||||
const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
input: 'config/patches/xterm-upstream.json\n'
|
||||
})
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('should_run=true\n')
|
||||
expect(result.stdout).toContain('xterm_patch_sync=true\n')
|
||||
expect(result.stdout).toContain('git_compatibility=false\n')
|
||||
expect(result.stdout).toContain('package=false\n')
|
||||
expect(result.stdout).toContain('test=true\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PR Checks skip wiring', () => {
|
||||
it('classifies the PR range with a tested script and expands renames', () => {
|
||||
const classify = prWorkflow.jobs.code_paths.steps.find(
|
||||
(step) => step.name === 'Classify changed paths'
|
||||
@@ -65,9 +253,12 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
expect(classify.run).toContain('--no-renames')
|
||||
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
|
||||
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
|
||||
'${{ steps.filter.outputs.should_run }}'
|
||||
)
|
||||
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
|
||||
for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
|
||||
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
|
||||
`\${{ steps.filter.outputs.${jobName} }}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the cheap root-directory guard on docs-only PRs', () => {
|
||||
@@ -75,11 +266,26 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips expensive jobs unless the detector says the PR has code', () => {
|
||||
for (const jobName of expensiveJobs) {
|
||||
it('gates each expensive job on its classifier and cache prerequisite', () => {
|
||||
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
|
||||
expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs.test_native_cache.if).toBe(
|
||||
"needs.code_paths.outputs.native_cache_changed == 'true'"
|
||||
)
|
||||
expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
|
||||
const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
})
|
||||
|
||||
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
|
||||
@@ -89,19 +295,23 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
|
||||
it('lets verify pass skipped jobs the classifier turned off', () => {
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
|
||||
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
|
||||
expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
|
||||
const docsOnlyBranch = verifyStep.run.slice(
|
||||
0,
|
||||
verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
|
||||
)
|
||||
expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
|
||||
expect(docsOnlyBranch).not.toContain('"$result" != "success"')
|
||||
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
|
||||
expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
|
||||
expect(verifyStep.run).toContain('expected skipped')
|
||||
expect(verifyStep.run).toContain('expected success')
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
if (job === 'code_paths' || job === 'root_directory_guard') {
|
||||
continue
|
||||
}
|
||||
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
|
||||
expect(verifyStep.run).toContain(`"$${envVar}"`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
|
||||
import {
|
||||
hasSshSourceChange,
|
||||
PR_E2E_SOURCE_ROUTES,
|
||||
selectPrE2eSpecs,
|
||||
SSH_SOURCE_ROUTE_IDS
|
||||
} from './pr-e2e-source-routing.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
@@ -100,17 +105,19 @@ describe('PR E2E gate contract', () => {
|
||||
// job without adding it to the strict loop fails here instead of silently
|
||||
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
|
||||
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
|
||||
// Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
|
||||
const successMarker = '# Require success when the PR has code-relevant changes'
|
||||
const successLoop = verifyStep.run.slice(
|
||||
verifyStep.run.indexOf(successMarker),
|
||||
verifyStep.run.lastIndexOf('done')
|
||||
)
|
||||
const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
|
||||
expect(successLoop.length).toBeGreaterThan(0)
|
||||
expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
|
||||
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
const envVar = job.toUpperCase()
|
||||
const envVar = job.replaceAll('-', '_').toUpperCase()
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
|
||||
if (job === 'code_paths' || job === 'root_directory_guard') {
|
||||
continue
|
||||
}
|
||||
expect(successLoop).toContain(`"$${envVar}"`)
|
||||
expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,6 +164,18 @@ describe('PR E2E gate contract', () => {
|
||||
expect(sshDockerRunner).toContain("'electron-headful'")
|
||||
})
|
||||
|
||||
it('reuses the composite install action instead of duplicating pnpm setup', () => {
|
||||
const installFor = (jobName) =>
|
||||
e2eWorkflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(installFor('build').with['native-runtime']).toBe('node')
|
||||
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
|
||||
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
|
||||
}
|
||||
})
|
||||
|
||||
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
|
||||
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
|
||||
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
|
||||
@@ -195,6 +214,8 @@ describe('PR E2E gate contract', () => {
|
||||
'src/main/providers/ssh-',
|
||||
'src/main/ipc/pty',
|
||||
'src/relay/',
|
||||
'src/shared/ssh-',
|
||||
'src/renderer/src/store/slices/direct-ssh-',
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-'
|
||||
]
|
||||
for (const authority of sshSourceAuthorities) {
|
||||
@@ -203,10 +224,24 @@ describe('PR E2E gate contract', () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Why named files rather than prefixes: these seams are single modules, and a prefix
|
||||
// here would route their unrelated neighbours.
|
||||
for (const file of [
|
||||
'src/main/runtime/public-ssh-state.ts',
|
||||
'src/renderer/src/startup/ssh-startup-reconnect.ts',
|
||||
'src/renderer/src/store/slices/ssh.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([file]), file).toContain(
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
|
||||
)
|
||||
}
|
||||
|
||||
const mappedSpecs = [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
]
|
||||
@@ -235,6 +270,140 @@ describe('PR E2E gate contract', () => {
|
||||
expect(changedInstall.run).toContain('openssh-client')
|
||||
})
|
||||
|
||||
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
|
||||
// Why by name: none of these carry "ssh", so the SSH authorities above never reach them
|
||||
// — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
|
||||
for (const file of [
|
||||
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
|
||||
'src/main/ipc/remote-workspace-snapshot-normalization.ts',
|
||||
'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
|
||||
'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
|
||||
'src/shared/remote-workspace-session-projection.ts',
|
||||
'src/renderer/src/components/terminal/initial-terminal.ts'
|
||||
]) {
|
||||
const specs = selectPrE2eSpecs([file])
|
||||
expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
|
||||
expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
|
||||
}
|
||||
|
||||
expect(
|
||||
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
|
||||
// The behavioural half of the invariant, and the part that actually matters: an SSH source
|
||||
// edit is recognised as one, through the same routes that select the specs.
|
||||
for (const file of [
|
||||
'src/main/ssh/connection.ts',
|
||||
'src/relay/pty-handler.ts',
|
||||
'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
|
||||
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
|
||||
'src/main/ipc/remote-workspace-snapshot-normalization.ts'
|
||||
]) {
|
||||
expect(hasSshSourceChange([file]), file).toBe(true)
|
||||
}
|
||||
for (const file of [
|
||||
'src/main/git/git-status.ts',
|
||||
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
|
||||
'src/main/ssh/connection.test.ts'
|
||||
]) {
|
||||
expect(hasSshSourceChange([file]), file).toBe(false)
|
||||
}
|
||||
|
||||
// Why: the signal must stay derived from the routes. A route id that no longer exists would
|
||||
// silently narrow it to nothing.
|
||||
for (const id of SSH_SOURCE_ROUTE_IDS) {
|
||||
expect(
|
||||
PR_E2E_SOURCE_ROUTES.map((route) => route.id),
|
||||
id
|
||||
).toContain(id)
|
||||
}
|
||||
|
||||
// Why text and not structure: a job `if:` is only ever available as a string. The strongest
|
||||
// available assertion is that the source signal is its own disjunct, so the lane no longer
|
||||
// depends on a spec name surviving in a route's spec list.
|
||||
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
|
||||
expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
|
||||
|
||||
expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
|
||||
'${{ steps.filter.outputs.ssh_source_changed }}'
|
||||
)
|
||||
expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
|
||||
'${{ needs.e2e-paths.outputs.ssh_source_changed }}'
|
||||
)
|
||||
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
|
||||
expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
|
||||
})
|
||||
|
||||
it('gives every Docker-gated SSH spec a lane that runs it', () => {
|
||||
// Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
|
||||
// that no runner names runs nowhere and still reports green — the silent skip this file
|
||||
// exists to prevent. Asserting reachability rather than a literal keeps that true when
|
||||
// the lanes move.
|
||||
// Why these two are exempt: each needs something CI cannot give it, recorded in
|
||||
// run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
|
||||
const unreachableSpecs = new Set([
|
||||
'tests/e2e/ssh-docker-relay-perf.spec.ts',
|
||||
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
|
||||
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
|
||||
])
|
||||
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
|
||||
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
|
||||
// runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
|
||||
// through the documentation.
|
||||
const stripComments = (text) =>
|
||||
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
const laneRunners = [
|
||||
'run-ssh-docker-e2e.mjs',
|
||||
'run-ssh-docker-watcher-isolation-e2e.mjs',
|
||||
'run-ssh-docker-terminal-parking-e2e.mjs'
|
||||
].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
|
||||
|
||||
// Why a comparison and not the bare name: preview and demo specs cite the flag in a
|
||||
// "how to run me" comment without gating on it. Why a regex rather than one literal: an
|
||||
// equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
|
||||
// and the spec would silently leave the contract.
|
||||
const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
|
||||
const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
|
||||
.filter((file) => file.endsWith('.spec.ts'))
|
||||
.map((file) => `tests/e2e/${file}`)
|
||||
.filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
|
||||
expect(dockerGatedSpecs.length).toBeGreaterThan(0)
|
||||
|
||||
const unclaimed = dockerGatedSpecs.filter(
|
||||
(spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
|
||||
)
|
||||
expect(
|
||||
unclaimed,
|
||||
`Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
|
||||
).toEqual([])
|
||||
|
||||
// Why: an exemption that outlives its spec would quietly excuse a real gap.
|
||||
for (const spec of unreachableSpecs) {
|
||||
expect(dockerGatedSpecs, spec).toContain(spec)
|
||||
// Why also assert absence from every runner: `unreachableSpecs` short-circuits the
|
||||
// unclaimed check above, so a spec could be documented as exempt while a runner still
|
||||
// invokes it -- an exemption that reads as coverage removal but changes nothing, and a
|
||||
// lane that stays red for a reason the file says it excluded.
|
||||
for (const runner of laneRunners) {
|
||||
expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
|
||||
(step) => step.name === 'Run remaining Docker SSH E2E'
|
||||
)
|
||||
expect(laneStep.run).toContain('test:e2e:ssh-docker')
|
||||
// Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
|
||||
expect(
|
||||
e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
|
||||
).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
|
||||
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
|
||||
|
||||
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
],
|
||||
// Why the store/startup/shared additions: the SSH-named authorities stop at the main
|
||||
// process and the pane component, but the reconnect ledgers and retained-payload
|
||||
// admission that decide whether a pane rebinds live in the renderer store.
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
|
||||
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
// Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
|
||||
// no "ssh" in their names, and only the two restore specs gate them. Folding them in
|
||||
// would run the whole SSH terminal list for a tab-tombstone edit.
|
||||
id: 'ssh-workspace-session-restore',
|
||||
specs: [
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
|
||||
return [...specs].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
|
||||
export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
|
||||
|
||||
// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
|
||||
// trigger only because one route happened to list a startup-readiness spec, so pruning that
|
||||
// spec would have silently retired the lane. Two lists that must agree is how that drifted.
|
||||
export function hasSshSourceChange(changedPaths) {
|
||||
return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
|
||||
(route) => changedPaths.some(route.matches)
|
||||
)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
input += chunk
|
||||
}
|
||||
const changedPaths = input.split(/\r?\n/).filter(Boolean)
|
||||
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
|
||||
process.stdout.write(`${JSON.stringify(specs)}\n`)
|
||||
if (process.argv.includes('--ssh-source')) {
|
||||
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
|
||||
} else {
|
||||
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
|
||||
process.stdout.write(`${JSON.stringify(specs)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
|
||||
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
|
||||
const dependencyAction = parse(
|
||||
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
|
||||
)
|
||||
@@ -47,22 +49,35 @@ describe('PR workflow parallelism', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across Node 24 and Node 26', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => index + 1)
|
||||
it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
|
||||
const sharedTest = unitTestWorkflow.jobs.test
|
||||
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = sharedTest.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = workflow.jobs.test.steps.find(
|
||||
const primerInstall = workflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
|
||||
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
|
||||
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
|
||||
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
|
||||
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
|
||||
expect(sharedTest.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
expect(primerInstall.with['native-runtime']).toBe('node')
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
expect(workflow.jobs.test.needs).toContain('test_native_cache')
|
||||
})
|
||||
|
||||
it('runs real-shell coverage once outside the general shards', () => {
|
||||
@@ -208,6 +223,8 @@ describe('PR workflow parallelism', () => {
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
|
||||
expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
||||
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
||||
@@ -232,22 +249,24 @@ describe('PR workflow parallelism', () => {
|
||||
workflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync'
|
||||
]) {
|
||||
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
expect(installFor('test').with['native-runtime']).toBe('node')
|
||||
expect(sharedTestInstall.with['native-runtime']).toBe('node')
|
||||
expect(installFor('package').with['native-runtime']).toBe('electron')
|
||||
expect(installFor('package_windows').with['native-runtime']).toBe('node')
|
||||
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
|
||||
|
||||
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
|
||||
expect(
|
||||
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
|
||||
).toBe("inputs.native-runtime != 'none'")
|
||||
).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
|
||||
const dependencyInstall = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Install dependencies'
|
||||
)
|
||||
@@ -295,14 +314,42 @@ describe('PR workflow parallelism', () => {
|
||||
// overwritten and one after the rebuild would never save a hit.
|
||||
expect(installIndex).toBeLessThan(cacheIndex)
|
||||
expect(cacheIndex).toBeLessThan(prepareIndex)
|
||||
expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(steps[cacheIndex].if).toBe(
|
||||
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
|
||||
)
|
||||
const restoreOnly = steps.find(
|
||||
(step) => step.name === 'Restore compiled native modules without saving'
|
||||
)
|
||||
expect(restoreOnly.if).toBe(
|
||||
"inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
|
||||
)
|
||||
expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
|
||||
// Native artifacts are ABI-bound: a key missing either dimension serves a build
|
||||
// that cannot load, and ensure-native-runtime would recompile it anyway.
|
||||
expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
|
||||
expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
|
||||
expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
||||
// No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
|
||||
expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
|
||||
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
|
||||
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
|
||||
expect(cacheStep.with.key).toContain('${{ runner.os }}')
|
||||
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
|
||||
expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
|
||||
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
|
||||
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
||||
expect(cacheStep.with.key).toContain(
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
||||
)
|
||||
expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
|
||||
expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
|
||||
expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
|
||||
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
|
||||
expect(cacheStep.with.path).toContain('windows-native-registry@')
|
||||
expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
|
||||
expect(cacheStep.with['restore-keys']).toBeUndefined()
|
||||
}
|
||||
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
|
||||
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(cacheScope.run).toContain('/etc/os-release')
|
||||
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
|
||||
'${{ steps.native-cache-scope.outputs.scope }}'
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses TypeScript incremental state across typecheck runs', () => {
|
||||
@@ -346,6 +393,7 @@ describe('PR workflow parallelism', () => {
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
@@ -359,5 +407,7 @@ describe('PR workflow parallelism', () => {
|
||||
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
|
||||
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
|
||||
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
|
||||
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
|
||||
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
|
||||
// load successfully in Electron while missing the patched fd/error handling.
|
||||
// Why: Orca patches node-pty's native Unix spawn path and Windows job-object
|
||||
// exports; upstream prebuilds can load while missing those patches.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const artifactPaths =
|
||||
rebuildPlatform === 'win32'
|
||||
? [
|
||||
resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
|
||||
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
|
||||
resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
|
||||
)
|
||||
]
|
||||
: [
|
||||
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
|
||||
...(osPlatform() === 'darwin'
|
||||
? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
|
||||
: [])
|
||||
]
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
|
||||
if (!onlyModules.includes('node-pty')) {
|
||||
return false
|
||||
}
|
||||
if (rebuildPlatform === 'win32') {
|
||||
return false
|
||||
}
|
||||
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
|
||||
function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(join(buildDir, 'conpty.node'), '')
|
||||
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
|
||||
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
|
||||
return
|
||||
}
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
|
||||
// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
|
||||
// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
|
||||
//
|
||||
// Deliberately absent, and therefore still covered by no CI trigger:
|
||||
// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
|
||||
// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
|
||||
// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
|
||||
// all. Recorded as a real gap, not as coverage living somewhere else.
|
||||
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
|
||||
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
|
||||
// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
|
||||
// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
|
||||
// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
|
||||
// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
|
||||
// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
|
||||
// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
|
||||
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
|
||||
// semantic decisions (which ptyId to capture, which split direction) that change
|
||||
// what the repro measures. Tracked in stablyai/orca#16764.
|
||||
//
|
||||
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
|
||||
// grep-inverts away.
|
||||
//
|
||||
// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
|
||||
// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
|
||||
// isolation is now one spec of many. Renaming it changes the GitHub check name and can
|
||||
// break required-check config, so the name understates the job on purpose.
|
||||
// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
|
||||
// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
|
||||
// has the exact wiring to flip it, and the gate contract asserts the current state.
|
||||
// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
|
||||
// and are not Docker-gated, which puts them outside this file's contract:
|
||||
// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
|
||||
// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
|
||||
// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
|
||||
// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
|
||||
// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
|
||||
// Runner scripts for the first four sit unused in package.json; no workflow calls them.
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-external-image-preview.spec.ts',
|
||||
'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
|
||||
'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
|
||||
'tests/e2e/ssh-skill-installation.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--project',
|
||||
'electron-headful',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(buildStep.run).toBe('pnpm run build:release:parallel')
|
||||
expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
|
||||
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.run).toContain('electron-builder')
|
||||
expect(packageStep.run).toContain('--dir')
|
||||
|
||||
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
|
||||
describe('windows-process-tree node-addon-api gyp path', () => {
|
||||
it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
|
||||
expect(PATCH).toContain(
|
||||
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
|
||||
it('stages headers without a pnpm-sensitive gyp dependency', () => {
|
||||
expect(PATCH).not.toContain('+ "../../node-addon-api')
|
||||
expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
|
||||
expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
|
||||
const buildScript = readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
|
||||
expect(bindingGyp).toContain(ABSOLUTE_GYP)
|
||||
expect(bindingGyp).not.toContain("require('node-addon-api').targets")
|
||||
expect(
|
||||
readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
).toContain(ABSOLUTE_GYP)
|
||||
expect(buildScript).toContain(
|
||||
"for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
|
||||
)
|
||||
expect(buildScript).toContain("import { createRequire } from 'node:module'")
|
||||
expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
|
||||
expect(buildScript).toContain(
|
||||
"createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
|
||||
)
|
||||
expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
|
||||
})
|
||||
|
||||
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
|
||||
const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
|
||||
const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
|
||||
cwd: PACKAGE_DIR,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
|
||||
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
|
||||
|
||||
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const sourceRebuild = job.steps.find(
|
||||
(step) => step.name === 'Rebuild node-pty from patched source'
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const nodeCacheSave = job.steps.find(
|
||||
(step) => step.name === 'Save compiled Node native modules'
|
||||
)
|
||||
const electronCache = job.steps.find(
|
||||
(step) => step.name === 'Restore compiled Electron native modules'
|
||||
)
|
||||
const build = job.steps.find((step) => step.name === 'Build package inputs')
|
||||
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
|
||||
@@ -37,10 +43,22 @@ describe('packaged Windows PTY native capability routing', () => {
|
||||
const verify = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
|
||||
|
||||
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
|
||||
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
|
||||
expect(install.with['native-runtime']).toBe('node')
|
||||
expect(install.with['persist-native-cache']).toBe('false')
|
||||
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
|
||||
expect(nodeCacheSave.with.key).toContain('-node-node')
|
||||
expect(electronCache.with.key).toContain('-electron-node')
|
||||
for (const cache of [nodeCacheSave, electronCache]) {
|
||||
expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
|
||||
expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
|
||||
expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
|
||||
}
|
||||
expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
|
||||
expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
|
||||
expect(build.run).toBe('pnpm run build:release:parallel')
|
||||
expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
|
||||
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
expect(workflow.jobs.verify.needs).toContain('package_windows')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 30m">
|
||||
<title>downloads: 30m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 31m">
|
||||
<title>downloads: 31m</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="90" y="15" fill="#010101" fill-opacity=".3">30m</text>
|
||||
<text x="90" y="14">30m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">31m</text>
|
||||
<text x="90" y="14">31m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
|
||||
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
|
||||
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
|
||||
|
||||
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
- **Android :** [Télécharger l'APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](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.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [APK 0.0.46 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -326,8 +326,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
|
||||
it is true before asserting anything else, so an unpatched binary fails loudly
|
||||
instead of passing every case vacuously. That guard is what caught this.
|
||||
|
||||
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
|
||||
exempts win32, on the premise that the patch is Unix-only. That premise is now
|
||||
false, but lifting the exemption also needs `pnpm rebuild` to force a source
|
||||
build — otherwise the assertion fires and the remedy does not fix it. Left as a
|
||||
follow-up rather than changed blind.
|
||||
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
|
||||
win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
|
||||
so the patched source build actually replaces the upstream prebuild.
|
||||
|
||||
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
|
||||
@@ -75,54 +75,18 @@
|
||||
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/mobile-rich-markdown-editor-html.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/CustomKeyModal.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/pair-scan.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/mock-server.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/terminal/terminal-accessory-keys.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/troubleshoot.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/repro-worktree-startup-stream.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/repro-terminal-colors.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/h/*/files/*.tsx"],
|
||||
"rules": {
|
||||
|
||||
+209
-23
@@ -1,20 +1,35 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import Constants from 'expo-constants'
|
||||
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
|
||||
import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { connectionLogStore } from '../src/transport/connection-log-buffer'
|
||||
import { useHostClient } from '../src/transport/client-context'
|
||||
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
|
||||
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useConnectionPathStatus,
|
||||
useReconnectAttempt
|
||||
} from '../src/transport/client-context-connection-metrics'
|
||||
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from '../src/diagnostics/connection-diagnostics-analysis'
|
||||
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
|
||||
import {
|
||||
readHydratedConnectionLog,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
resolveDiagnosticsHostId,
|
||||
getDiagnosticsSubmissionState,
|
||||
updateDiagnosticsSubmissionState,
|
||||
type DiagnosticsSubmissionStates
|
||||
} from '../src/diagnostics/connection-diagnostics-screen-data'
|
||||
import { useHostStatusGates } from '../src/transport/host-status-gates'
|
||||
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
|
||||
|
||||
// Why: getSnapshot must be referentially stable when there's no data —
|
||||
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
|
||||
// screen also *acquires* the host client — opening it kicks a dial and the
|
||||
// log fills live instead of showing a stale tail.
|
||||
export default function ConnectionLogScreen() {
|
||||
const clientContext = useRpcClientContext()
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ hostId?: string }>()
|
||||
const insets = useSafeAreaInsets()
|
||||
const routeKey = useMemo(() => ({}), [params.hostId])
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [manualSelection, setManualSelection] = useState<{
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey: object
|
||||
} | null>(null)
|
||||
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
|
||||
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
|
||||
return
|
||||
}
|
||||
setHosts(loaded)
|
||||
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
|
||||
const selected = hosts.find((h) => h.id === selectedId) ?? null
|
||||
const { state } = useHostClient(selected?.id)
|
||||
const { client, state } = useHostClient(selected?.id)
|
||||
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
|
||||
hostId: selected?.id,
|
||||
client,
|
||||
connState: state
|
||||
})
|
||||
const reconnectAttempts = useReconnectAttempt(selected?.id)
|
||||
const lastConnectedAt = useLastConnectedAt(selected?.id)
|
||||
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
void readHydratedConnectionLog(connectionLogStore, selectedId)
|
||||
}
|
||||
}, [selectedId])
|
||||
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
|
||||
[selectedId]
|
||||
)
|
||||
const entries = useSyncExternalStore(subscribe, getSnapshot)
|
||||
const diagnosis = selected
|
||||
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
|
||||
: null
|
||||
const incidentId = selected
|
||||
? getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
activePath,
|
||||
pendingPath,
|
||||
entries
|
||||
})
|
||||
: null
|
||||
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
|
||||
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
|
||||
const copied = copiedHostId === selectedId
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
|
||||
appVersion: Constants.expoConfig?.version ?? 'unknown',
|
||||
entries
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
await Clipboard.setStringAsync(report)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
|
||||
setCopiedHostId(selected.id)
|
||||
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
|
||||
}, [selected, liveDesktopAppVersion, clientContext])
|
||||
|
||||
const sendDiagnostics = useCallback(async () => {
|
||||
if (!selected || !submissionKey || submissionState === 'sending') {
|
||||
return
|
||||
}
|
||||
const startedKey = submissionKey
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
|
||||
const appVersion = Constants.expoConfig?.version ?? 'unknown'
|
||||
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const currentIncidentId = getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath,
|
||||
entries: snapshot.entries
|
||||
})
|
||||
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
|
||||
return
|
||||
}
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform,
|
||||
appVersion,
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
|
||||
setSubmissionStates((states) =>
|
||||
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
|
||||
)
|
||||
}, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Connection log</Text>
|
||||
<Text style={styles.heading}>Network diagnostics</Text>
|
||||
</View>
|
||||
|
||||
{hosts.length > 1 && (
|
||||
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
|
||||
onPress={() => setSelectedId(host.id)}
|
||||
onPress={() =>
|
||||
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
|
||||
@@ -121,14 +225,49 @@ export default function ConnectionLogScreen() {
|
||||
) : (
|
||||
<Copy size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{diagnosis && (
|
||||
<View style={styles.diagnosisCard}>
|
||||
<Text style={styles.diagnosisHeading}>What this suggests</Text>
|
||||
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
|
||||
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
|
||||
{diagnosis.reportability === 'orca-relay' && (
|
||||
<>
|
||||
<Text style={styles.privacyHint}>
|
||||
Sends a size-limited redacted report including host name, endpoint, versions,
|
||||
connection state, and events—never terminal contents or credentials.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.sendButton}
|
||||
onPress={() => void sendDiagnostics()}
|
||||
disabled={submissionState === 'sending'}
|
||||
>
|
||||
{submissionState === 'sent' ? (
|
||||
<Check size={14} color={colors.statusGreen} />
|
||||
) : (
|
||||
<Send size={14} color={colors.textPrimary} />
|
||||
)}
|
||||
<Text style={styles.sendButtonText}>
|
||||
{submissionState === 'sending'
|
||||
? 'Sending…'
|
||||
: submissionState === 'sent'
|
||||
? 'Diagnostics sent'
|
||||
: submissionState === 'failed'
|
||||
? 'Retry sending'
|
||||
: 'Send diagnostics to Orca'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{entries.length > 0 ? (
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} />
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
No connection events yet this session. Events appear as the app dials this host.
|
||||
No connection events yet. Events appear as the app dials this host.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
diagnosisCard: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
diagnosisHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
diagnosisText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 18
|
||||
},
|
||||
diagnosisNext: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
privacyHint: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: 11,
|
||||
lineHeight: 15,
|
||||
color: colors.textMuted
|
||||
},
|
||||
sendButton: {
|
||||
marginTop: spacing.md,
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
sendButtonText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
+15
-309
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
@@ -63,14 +63,16 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
|
||||
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
|
||||
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
|
||||
import { leaveHostRoute } from '../../../src/host-route-exit'
|
||||
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
|
||||
import {
|
||||
@@ -1090,6 +1092,17 @@ export function HostScreen({
|
||||
/>
|
||||
)}
|
||||
|
||||
{connState !== 'connected' &&
|
||||
!relayRecovery.pairingRejected &&
|
||||
reconnectAttempts >= 3 &&
|
||||
hostId ? (
|
||||
<HostDiagnosticsLink
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
|
||||
{routeNotice && (
|
||||
<HostRouteNoticeBanner
|
||||
@@ -1410,310 +1423,3 @@ export default function HostWorktreeRoute() {
|
||||
function ListSeparator() {
|
||||
return <View style={styles.separator} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topChrome: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 34,
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sidebarCollapseButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
marginRight: spacing.md
|
||||
},
|
||||
hostNameText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
reconnectButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbar: {
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
embeddedFilterChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
embeddedModeButton: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterChipActive: {
|
||||
borderColor: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
modeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sortLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
toolbarIconDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sectionIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionRepoIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
sectionCount: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginLeft: spacing.lg + 24,
|
||||
marginRight: spacing.lg
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,6 +117,10 @@ import {
|
||||
} from '../../../../src/terminal/terminal-accessory-layout'
|
||||
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
|
||||
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
|
||||
import {
|
||||
createTerminalAccessoryRepeatController,
|
||||
createTerminalAccessoryRepeatSender
|
||||
} from '../../../../src/terminal/terminal-accessory-repeat'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
@@ -2993,16 +2997,22 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
async function handleAccessoryKey(
|
||||
input: ReturnType<typeof createTerminalLiveAccessoryInput>,
|
||||
targetHandle = activeHandleRef.current,
|
||||
isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current
|
||||
) {
|
||||
if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) {
|
||||
return false
|
||||
}
|
||||
const targetHandle = activeHandle
|
||||
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return
|
||||
if (!isDeliveryTargetCurrent()) {
|
||||
return false
|
||||
}
|
||||
await sendTerminalLiveAccessoryRawBytes({
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return accessoryCommit.kind === 'handled'
|
||||
}
|
||||
return sendTerminalLiveAccessoryRawBytes({
|
||||
client: clientRef.current,
|
||||
targetHandle,
|
||||
activeHandle: activeHandleRef.current,
|
||||
@@ -3399,32 +3409,37 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
|
||||
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
// Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
|
||||
const accessoryRepeatRef = useRef(
|
||||
createTerminalAccessoryRepeatController<ReturnType<typeof createTerminalLiveAccessoryInput>>()
|
||||
)
|
||||
// Why: the current callback observes reconnect state while the sender pins the press to its original terminal.
|
||||
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
|
||||
handleAccessoryKeyRef.current = handleAccessoryKey
|
||||
const stopAccessoryRepeat = useCallback(() => {
|
||||
if (repeatTimeoutRef.current) {
|
||||
clearTimeout(repeatTimeoutRef.current)
|
||||
repeatTimeoutRef.current = null
|
||||
}
|
||||
if (repeatIntervalRef.current) {
|
||||
clearInterval(repeatIntervalRef.current)
|
||||
repeatIntervalRef.current = null
|
||||
}
|
||||
accessoryRepeatRef.current.stop()
|
||||
}, [])
|
||||
const startAccessoryRepeat = useCallback(
|
||||
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
|
||||
stopAccessoryRepeat()
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
void handleAccessoryKeyRef.current(input)
|
||||
}, 45)
|
||||
}, 400)
|
||||
// Why: a held repeat must not resume through a replacement client or connection.
|
||||
const targetClient = clientRef.current
|
||||
const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null
|
||||
const isDeliveryTargetCurrent = (targetHandle: string) =>
|
||||
activeHandleRef.current === targetHandle &&
|
||||
targetClient !== null &&
|
||||
clientRef.current === targetClient &&
|
||||
connStateRef.current === 'connected' &&
|
||||
targetClient.getLastConnectedAt() === targetConnectedAt
|
||||
accessoryRepeatRef.current.start(
|
||||
input,
|
||||
createTerminalAccessoryRepeatSender(
|
||||
activeHandleRef.current,
|
||||
isDeliveryTargetCurrent,
|
||||
(nextInput, targetHandle, isTargetCurrent) =>
|
||||
handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent)
|
||||
)
|
||||
)
|
||||
},
|
||||
[stopAccessoryRepeat]
|
||||
[]
|
||||
)
|
||||
const setMobileSessionRootRef = useCallback(
|
||||
(node: View | null): void => {
|
||||
@@ -3440,15 +3455,14 @@ export default function SessionScreen() {
|
||||
clearPendingLiveInputCommit()
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
clearSessionTabActionSheetKeyboardListener()
|
||||
stopAccessoryRepeat()
|
||||
accessoryRepeatRef.current.cancel()
|
||||
},
|
||||
[
|
||||
clearPendingLiveInputCommit,
|
||||
clearDelayedActionTimers,
|
||||
clearSessionTabActionSheetKeyboardListener,
|
||||
clearTerminalCache,
|
||||
clearToastHideTimer,
|
||||
stopAccessoryRepeat
|
||||
clearToastHideTimer
|
||||
]
|
||||
)
|
||||
|
||||
@@ -4848,7 +4862,6 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
const input = createTerminalLiveAccessoryInput(key)
|
||||
void handleAccessoryKey(input)
|
||||
startAccessoryRepeat(input)
|
||||
}}
|
||||
onPressOut={() => {
|
||||
|
||||
+2
-185
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
@@ -19,13 +18,14 @@ import {
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
|
||||
import { useRefreshHostClient } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../src/onboarding/mobile-onboarding-plan'
|
||||
import { pairScanStyles as styles } from '../src/pair-scan-styles'
|
||||
|
||||
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
|
||||
// route surfaces as a real error with the log visible instead of a
|
||||
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
stepBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Why: holds the layout slot while the camera is unmounted during
|
||||
// paste, so the bottom action button doesn't snap up to fill the
|
||||
// empty space.
|
||||
cameraPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.camera
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
reticleFrame: {
|
||||
position: 'relative'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
subtitle: {
|
||||
maxWidth: 310,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
logSlot: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
pasteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
pasteButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
pasteButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
errorActions: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
|
||||
onPress={() => router.push('/connection-log')}
|
||||
>
|
||||
<ScrollText size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
|
||||
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
|
||||
</Pressable>
|
||||
|
||||
{checks.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
export type TerminalColorSnapshot = {
|
||||
label: string
|
||||
handle: string
|
||||
cols: number | null
|
||||
rows: number | null
|
||||
serialized: string
|
||||
lines: string
|
||||
}
|
||||
|
||||
function countMatches(value: string, pattern: RegExp): number {
|
||||
return value.match(pattern)?.length ?? 0
|
||||
}
|
||||
|
||||
export function summarizeTerminalColorSnapshot(
|
||||
snapshot: TerminalColorSnapshot
|
||||
): Record<string, string | number | null> {
|
||||
const data = snapshot.serialized
|
||||
return {
|
||||
label: snapshot.label,
|
||||
handle: snapshot.handle,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
serializedBytes: Buffer.byteLength(data),
|
||||
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
|
||||
sgrColor: countMatches(
|
||||
data,
|
||||
new RegExp(
|
||||
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
|
||||
'g'
|
||||
)
|
||||
),
|
||||
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
|
||||
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
|
||||
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
|
||||
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
|
||||
const dir = join(process.cwd(), 'terminal-color-repro')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const snapshot of snapshots) {
|
||||
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
|
||||
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
|
||||
writeFileSync(
|
||||
join(dir, `${base}.escaped.txt`),
|
||||
JSON.stringify(snapshot.serialized || snapshot.lines)
|
||||
)
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, 'summary.json'),
|
||||
JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
|
||||
)
|
||||
return dir
|
||||
}
|
||||
@@ -10,10 +10,13 @@
|
||||
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
|
||||
* pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
saveTerminalColorSnapshots,
|
||||
summarizeTerminalColorSnapshot,
|
||||
type TerminalColorSnapshot
|
||||
} from './repro-terminal-color-output'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const token = process.argv[2]
|
||||
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
|
||||
const worktreeSelector = process.argv[4]
|
||||
const explicitHandleA = process.argv[5]
|
||||
const explicitHandleB = process.argv[6]
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
@@ -37,15 +39,6 @@ type PendingRequest = {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type Snapshot = {
|
||||
label: string
|
||||
handle: string
|
||||
cols: number | null
|
||||
rows: number | null
|
||||
serialized: string
|
||||
lines: string
|
||||
}
|
||||
|
||||
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]'
|
||||
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise<strin
|
||||
return handle
|
||||
}
|
||||
|
||||
async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise<Snapshot> {
|
||||
async function captureSnapshot(
|
||||
ws: WebSocket,
|
||||
label: string,
|
||||
handle: string
|
||||
): Promise<TerminalColorSnapshot> {
|
||||
const id = nextId()
|
||||
const snapshot = await new Promise<Snapshot>((resolve, reject) => {
|
||||
const snapshot = await new Promise<TerminalColorSnapshot>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
streamListeners.delete(id)
|
||||
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function countMatches(value: string, pattern: RegExp): number {
|
||||
return value.match(pattern)?.length ?? 0
|
||||
}
|
||||
|
||||
function summarize(snapshot: Snapshot): Record<string, string | number | null> {
|
||||
const data = snapshot.serialized
|
||||
return {
|
||||
label: snapshot.label,
|
||||
handle: snapshot.handle,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
serializedBytes: Buffer.byteLength(data),
|
||||
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
|
||||
sgrColor: countMatches(
|
||||
data,
|
||||
new RegExp(
|
||||
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
|
||||
'g'
|
||||
)
|
||||
),
|
||||
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
|
||||
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
|
||||
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
|
||||
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshots(snapshots: Snapshot[]): string {
|
||||
const dir = join(process.cwd(), 'terminal-color-repro')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const snapshot of snapshots) {
|
||||
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
|
||||
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
|
||||
writeFileSync(
|
||||
join(dir, `${base}.escaped.txt`),
|
||||
JSON.stringify(snapshot.serialized || snapshot.lines)
|
||||
)
|
||||
}
|
||||
writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise<void> {
|
||||
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
|
||||
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
|
||||
const snapshots = [firstA, firstB, secondA]
|
||||
const dir = saveSnapshots(snapshots)
|
||||
const dir = saveTerminalColorSnapshots(snapshots)
|
||||
|
||||
console.table(snapshots.map(summarize))
|
||||
console.table(snapshots.map(summarizeTerminalColorSnapshot))
|
||||
console.log(`saved: ${dir}`)
|
||||
if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
|
||||
if (
|
||||
summarizeTerminalColorSnapshot(firstA).sgrColor !==
|
||||
summarizeTerminalColorSnapshot(secondA).sgrColor
|
||||
) {
|
||||
console.log(
|
||||
'color SGR count changed between A snapshots: serialization/state changed on desktop'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
export type StartupTerminalCapture = {
|
||||
handle: string
|
||||
title: string | null
|
||||
scrollback: Record<string, unknown> | null
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return (
|
||||
value
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '\n')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreview(value: string): string {
|
||||
return stripAnsi(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function summarizeStartupCapture(capture: StartupTerminalCapture): Record<string, unknown> {
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
const live = capture.chunks.join('')
|
||||
const serializedPreview = normalizePreview(serialized)
|
||||
const livePreview = normalizePreview(live)
|
||||
return {
|
||||
handle: capture.handle,
|
||||
title: capture.title,
|
||||
cols: capture.scrollback?.cols ?? null,
|
||||
rows: capture.scrollback?.rows ?? null,
|
||||
serializedBytes: Buffer.byteLength(serialized),
|
||||
liveBytes: Buffer.byteLength(live),
|
||||
liveChunks: capture.chunks.length,
|
||||
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
livePreviewContainedInSerialized:
|
||||
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
|
||||
serializedPreview,
|
||||
livePreview
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStartupCaptures(
|
||||
captures: StartupTerminalCapture[],
|
||||
worktreeName: string
|
||||
): string {
|
||||
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const capture of captures) {
|
||||
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
|
||||
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
|
||||
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, 'summary.json'),
|
||||
JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
|
||||
)
|
||||
return dir
|
||||
}
|
||||
@@ -4,10 +4,15 @@
|
||||
* Usage:
|
||||
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
saveStartupCaptures,
|
||||
summarizeStartupCapture,
|
||||
type StartupTerminalCapture
|
||||
} from './repro-worktree-startup-output'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const USER_DATA =
|
||||
@@ -15,7 +20,6 @@ const USER_DATA =
|
||||
const repoSelector = process.argv[2]
|
||||
const worktreeName = process.argv[3]
|
||||
const startupCommand = process.argv[4] || 'claude'
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
@@ -36,13 +40,6 @@ type TerminalInfo = {
|
||||
title: string | null
|
||||
}
|
||||
|
||||
type Capture = {
|
||||
handle: string
|
||||
title: string | null
|
||||
scrollback: Record<string, unknown> | null
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
if (!repoSelector || !worktreeName) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]'
|
||||
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
|
||||
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return (
|
||||
value
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '\n')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreview(value: string): string {
|
||||
return stripAnsi(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<TerminalInfo[]> {
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<Term
|
||||
throw new Error('Timed out waiting for startup terminals')
|
||||
}
|
||||
|
||||
async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
|
||||
async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise<void> {
|
||||
const id = nextId()
|
||||
streamListeners.set(id, (result) => {
|
||||
if (result.type === 'scrollback') {
|
||||
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function summarize(capture: Capture): Record<string, unknown> {
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
const live = capture.chunks.join('')
|
||||
const serializedPreview = normalizePreview(serialized)
|
||||
const livePreview = normalizePreview(live)
|
||||
return {
|
||||
handle: capture.handle,
|
||||
title: capture.title,
|
||||
cols: capture.scrollback?.cols ?? null,
|
||||
rows: capture.scrollback?.rows ?? null,
|
||||
serializedBytes: Buffer.byteLength(serialized),
|
||||
liveBytes: Buffer.byteLength(live),
|
||||
liveChunks: capture.chunks.length,
|
||||
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
livePreviewContainedInSerialized:
|
||||
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
|
||||
serializedPreview,
|
||||
livePreview
|
||||
}
|
||||
}
|
||||
|
||||
function save(captures: Capture[], worktreeName: string): string {
|
||||
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const capture of captures) {
|
||||
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
|
||||
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
|
||||
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
|
||||
}
|
||||
writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise<void> {
|
||||
for (const capture of captures) {
|
||||
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
|
||||
}
|
||||
const dir = save(captures, worktreeName)
|
||||
console.table(captures.map(summarize))
|
||||
const dir = saveStartupCaptures(captures, worktreeName)
|
||||
console.table(captures.map(summarizeStartupCapture))
|
||||
console.log(`saved: ${dir}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/* 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,
|
||||
@@ -10,7 +7,6 @@ import {
|
||||
PanResponder,
|
||||
PixelRatio,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
@@ -24,7 +20,7 @@ import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
|
||||
buildMobileBrowserScreencastRequest,
|
||||
@@ -47,11 +43,32 @@ import {
|
||||
computeBrowserTouchClickRadiusCss,
|
||||
mapScreenToBrowserPoint,
|
||||
readLocalTouchPoint,
|
||||
type BrowserFrameGeometry,
|
||||
type BrowserPoint,
|
||||
type BrowserTouchLayout,
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import {
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
assertRpcOk,
|
||||
browserErrorMessage,
|
||||
browserFrameMetadataEqual,
|
||||
buttonColor,
|
||||
cacheBrowserFrame,
|
||||
clearCachedBrowserFramesForWorktree,
|
||||
createBrowserFrameDataUri,
|
||||
createPinchGesture,
|
||||
getCachedBrowserFrame,
|
||||
makeBrowserFrameCacheKey,
|
||||
peekCachedBrowserFrame,
|
||||
shouldSurfaceBrowserError,
|
||||
updateBrowserImageSource,
|
||||
updateBrowserLayerVisibility,
|
||||
updatePinchZoom,
|
||||
type FrameLayer,
|
||||
type PinchGesture
|
||||
} from './mobile-browser-frame-state'
|
||||
import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
|
||||
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
|
||||
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
|
||||
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
|
||||
@@ -79,15 +96,6 @@ type MobileBrowserPaneProps = {
|
||||
onToast: (message: string, durationMs?: number) => void
|
||||
}
|
||||
|
||||
type FrameLayer = 0 | 1
|
||||
|
||||
type PinchGesture = {
|
||||
distance: number
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
}
|
||||
|
||||
type PanGesture = {
|
||||
x: number
|
||||
y: number
|
||||
@@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22
|
||||
const LONG_PRESS_MS = 550
|
||||
const WHEEL_INTERVAL_MS = 70
|
||||
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 BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
|
||||
type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
type BrowserPageParams = {
|
||||
worktree: string
|
||||
@@ -1268,350 +1266,3 @@ export function MobileBrowserPane({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
viewMode: MobileBrowserViewMode
|
||||
): string | null {
|
||||
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
|
||||
): void {
|
||||
for (const [index, layer] of layers.entries()) {
|
||||
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
function updateBrowserImageSource(image: Image | null, uri: string): void {
|
||||
// Why: browser frames are large strings; mutating only the native Image
|
||||
// source avoids re-rendering the whole tab view for every streamed frame.
|
||||
const source = [{ uri }]
|
||||
image?.setNativeProps({ source, src: source })
|
||||
}
|
||||
|
||||
function assertRpcOk(
|
||||
response: RpcSuccess | RpcFailure,
|
||||
fallbackMessage: string
|
||||
): asserts response is RpcSuccess {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
function browserFrameMetadataEqual(
|
||||
a: BrowserScreencastFrameMetadata | null,
|
||||
b: BrowserScreencastFrameMetadata
|
||||
): boolean {
|
||||
return (
|
||||
a?.deviceWidth === b.deviceWidth &&
|
||||
a?.deviceHeight === b.deviceHeight &&
|
||||
a?.pageScaleFactor === b.pageScaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
function browserErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
function shouldSurfaceBrowserError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
// Why: selector_not_found can be emitted by in-flight page automation while
|
||||
// the browser is still usable; replacing the frame with it feels like a crash.
|
||||
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
|
||||
}
|
||||
|
||||
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
|
||||
const touches = event.nativeEvent.touches
|
||||
if (!touches || touches.length < 2) {
|
||||
return null
|
||||
}
|
||||
const a = readLocalTouchPoint(touches[0])
|
||||
const b = readLocalTouchPoint(touches[1])
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
function createPinchGesture(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
zoom: BrowserZoomState
|
||||
): PinchGesture | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const distance = pointDistance(pair.a, pair.b)
|
||||
if (distance < 8) {
|
||||
return null
|
||||
}
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
|
||||
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
|
||||
return {
|
||||
distance,
|
||||
scale: zoom.scale,
|
||||
anchorX: (centerX - frameCenterX) / zoom.scale,
|
||||
anchorY: (centerY - frameCenterY) / zoom.scale
|
||||
}
|
||||
}
|
||||
|
||||
function updatePinchZoom(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
pinch: PinchGesture
|
||||
): BrowserZoomState | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const nextScale = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
|
||||
)
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
|
||||
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
|
||||
return clampBrowserZoomState(
|
||||
{
|
||||
scale: nextScale,
|
||||
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
|
||||
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
|
||||
},
|
||||
geometry,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
browserImageHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImageFill: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
browserImageLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserImageLayerHidden: {
|
||||
opacity: 0
|
||||
},
|
||||
browserZoomOffset: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserFrameBox: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImage: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.2)'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
dialogOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.5)'
|
||||
},
|
||||
dialogCard: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.lg
|
||||
},
|
||||
dialogTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
dialogActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
dialogButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dialogButtonPrimary: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
dialogButtonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
dialogButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogButtonPrimaryText: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
keyboardDock: {
|
||||
zIndex: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs + 2
|
||||
},
|
||||
keyboardInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import type { GestureResponderEvent, Image, View } from 'react-native'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
clampBrowserZoomState,
|
||||
readLocalTouchPoint,
|
||||
type BrowserFrameGeometry,
|
||||
type BrowserPoint,
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import type { MobileBrowserViewMode } from './browser-screencast-request'
|
||||
|
||||
export type FrameLayer = 0 | 1
|
||||
export type PinchGesture = {
|
||||
distance: number
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
}
|
||||
export type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
export const MIN_ZOOM = 1
|
||||
export const MAX_ZOOM = 3.5
|
||||
const BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
export function buttonColor(enabled: boolean): string {
|
||||
return enabled ? colors.textSecondary : colors.textMuted
|
||||
}
|
||||
|
||||
export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
|
||||
export function makeBrowserFrameCacheKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null,
|
||||
viewMode: MobileBrowserViewMode
|
||||
): string | null {
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
|
||||
}
|
||||
|
||||
export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of browserFrameCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
browserFrameCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBrowserLayerVisibility(
|
||||
layers: [View | null, View | null],
|
||||
visible: FrameLayer
|
||||
): void {
|
||||
for (const [index, layer] of layers.entries()) {
|
||||
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBrowserImageSource(image: Image | null, uri: string): void {
|
||||
// Why: browser frames are large strings; mutating only the native Image
|
||||
// source avoids re-rendering the whole tab view for every streamed frame.
|
||||
const source = [{ uri }]
|
||||
image?.setNativeProps({ source, src: source })
|
||||
}
|
||||
|
||||
export function assertRpcOk(
|
||||
response: RpcSuccess | RpcFailure,
|
||||
fallbackMessage: string
|
||||
): asserts response is RpcSuccess {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
export function browserFrameMetadataEqual(
|
||||
a: BrowserScreencastFrameMetadata | null,
|
||||
b: BrowserScreencastFrameMetadata
|
||||
): boolean {
|
||||
return (
|
||||
a?.deviceWidth === b.deviceWidth &&
|
||||
a?.deviceHeight === b.deviceHeight &&
|
||||
a?.pageScaleFactor === b.pageScaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
export function browserErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
export function shouldSurfaceBrowserError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
// Why: selector_not_found can be emitted by in-flight page automation while
|
||||
// the browser is still usable; replacing the frame with it feels like a crash.
|
||||
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
|
||||
}
|
||||
|
||||
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
|
||||
const touches = event.nativeEvent.touches
|
||||
if (!touches || touches.length < 2) {
|
||||
return null
|
||||
}
|
||||
const a = readLocalTouchPoint(touches[0])
|
||||
const b = readLocalTouchPoint(touches[1])
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
export function createPinchGesture(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
zoom: BrowserZoomState
|
||||
): PinchGesture | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const distance = pointDistance(pair.a, pair.b)
|
||||
if (distance < 8) {
|
||||
return null
|
||||
}
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
|
||||
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
|
||||
return {
|
||||
distance,
|
||||
scale: zoom.scale,
|
||||
anchorX: (centerX - frameCenterX) / zoom.scale,
|
||||
anchorY: (centerY - frameCenterY) / zoom.scale
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePinchZoom(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
pinch: PinchGesture
|
||||
): BrowserZoomState | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const nextScale = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
|
||||
)
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
|
||||
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
|
||||
return clampBrowserZoomState(
|
||||
{
|
||||
scale: nextScale,
|
||||
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
|
||||
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
|
||||
},
|
||||
geometry,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const mobileBrowserPaneStyles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
browserImageHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImageFill: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
browserImageLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserImageLayerHidden: {
|
||||
opacity: 0
|
||||
},
|
||||
browserZoomOffset: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserFrameBox: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImage: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.2)'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
dialogOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.5)'
|
||||
},
|
||||
dialogCard: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.lg
|
||||
},
|
||||
dialogTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
dialogActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
dialogButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dialogButtonPrimary: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
dialogButtonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
dialogButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogButtonPrimaryText: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
keyboardDock: {
|
||||
zIndex: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs + 2
|
||||
},
|
||||
keyboardInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
import { ConnectionLog } from './ConnectionLog'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
const entries: ConnectionLogEntry[] = [
|
||||
{ id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
|
||||
]
|
||||
|
||||
const duplicateEntries: ConnectionLogEntry[] = [
|
||||
{ id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
|
||||
{ id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
|
||||
]
|
||||
|
||||
describe('ConnectionLog', () => {
|
||||
type RenderedNode = { props: { style?: unknown } }
|
||||
type Renderer = {
|
||||
root: {
|
||||
findAllByType: (type: unknown) => RenderedNode[]
|
||||
findByType: (type: unknown) => RenderedNode
|
||||
}
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function renderLog(fillAvailableHeight = false): Renderer {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(ConnectionLog, { entries, fillAvailableHeight })
|
||||
) as unknown as Renderer
|
||||
})
|
||||
return renderer as Renderer
|
||||
}
|
||||
|
||||
it('keeps the compact height in pairing flows', () => {
|
||||
const instance = renderLog()
|
||||
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
|
||||
|
||||
expect(containerStyles).toContainEqual({ maxHeight: 240 })
|
||||
})
|
||||
|
||||
it('fills the available diagnostics viewport', () => {
|
||||
const instance = renderLog(true)
|
||||
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
|
||||
const scroll = instance.root.findByType('ScrollView')
|
||||
|
||||
expect(containerStyles).toContainEqual({ flex: 1 })
|
||||
expect(scroll.props.style).toEqual({ flex: 1 })
|
||||
})
|
||||
|
||||
it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(ConnectionLog, { entries: duplicateEntries })
|
||||
) as unknown as Renderer
|
||||
})
|
||||
|
||||
expect(
|
||||
consoleError.mock.calls.some(([message]) =>
|
||||
String(message).includes('Encountered two children with the same key')
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
// Tag printed before the first entry so it's clear what's being logged
|
||||
// (e.g. 'Pairing' vs 'Reconnect').
|
||||
title?: string
|
||||
fillAvailableHeight?: boolean
|
||||
}
|
||||
|
||||
const LEVEL_COLOR: Record<ConnectionLogEntry['level'], string> = {
|
||||
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
|
||||
return `+${Math.round(elapsed)}s`
|
||||
}
|
||||
|
||||
export function ConnectionLog({ entries, title }: Props) {
|
||||
export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
|
||||
const scrollRef = useRef<ScrollView | null>(null)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
const baseTs = entries[0]!.ts
|
||||
const keyOccurrences = new Map<string, number>()
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
fillAvailableHeight ? styles.fillContainer : styles.boundedContainer
|
||||
]}
|
||||
>
|
||||
{title && <Text style={styles.title}>{title}</Text>}
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
style={fillAvailableHeight ? styles.fillScroll : styles.boundedScroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<View key={entry.id} style={styles.row}>
|
||||
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
|
||||
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{LEVEL_GLYPH[entry.level]}
|
||||
</Text>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{entry.message}
|
||||
{entries.map((entry) => {
|
||||
const occurrence = keyOccurrences.get(entry.id) ?? 0
|
||||
keyOccurrences.set(entry.id, occurrence + 1)
|
||||
const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
|
||||
return (
|
||||
<View key={renderKey} style={styles.row}>
|
||||
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
|
||||
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{LEVEL_GLYPH[entry.level]}
|
||||
</Text>
|
||||
{entry.detail && (
|
||||
<Text style={styles.detail} numberOfLines={2}>
|
||||
{entry.detail}
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{entry.message}
|
||||
</Text>
|
||||
)}
|
||||
{entry.detail && (
|
||||
<Text style={styles.detail} numberOfLines={2}>
|
||||
{entry.detail}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
maxHeight: 240,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
boundedContainer: {
|
||||
maxHeight: 240
|
||||
},
|
||||
fillContainer: {
|
||||
flex: 1
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily,
|
||||
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 1,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
scroll: {
|
||||
boundedScroll: {
|
||||
maxHeight: 200
|
||||
},
|
||||
fillScroll: {
|
||||
flex: 1
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 6
|
||||
},
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const customKeyModalStyles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
backSpacer: {
|
||||
width: 30
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 1
|
||||
},
|
||||
rowHint: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
shortcutForm: {
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
preview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.lg + spacing.xs,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
previewKeycapRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
previewPlus: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keycapModifier: {
|
||||
minWidth: 0
|
||||
},
|
||||
keycapWarn: {
|
||||
borderColor: colors.statusAmber
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: '600'
|
||||
},
|
||||
keycapTextWarn: {
|
||||
color: colors.statusAmber
|
||||
},
|
||||
keycapModifierText: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: spacing.sm,
|
||||
paddingLeft: 2
|
||||
},
|
||||
mods: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
chip: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
chipSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
chipText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '500'
|
||||
},
|
||||
chipTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
chipGlyph: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
chipGlyphSelected: {
|
||||
color: 'rgba(10,10,10,0.5)'
|
||||
},
|
||||
keyInput: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center'
|
||||
},
|
||||
moreLink: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center'
|
||||
},
|
||||
moreLinkPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
moreLinkText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
textDecorationLine: 'underline'
|
||||
},
|
||||
specialKeysForm: {
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.md
|
||||
},
|
||||
specialGroup: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
specialGroupTitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
paddingLeft: 2,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
keyGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: -spacing.xs / 2
|
||||
},
|
||||
keyCellWrap: {
|
||||
paddingHorizontal: spacing.xs / 2,
|
||||
paddingVertical: spacing.xs / 2
|
||||
},
|
||||
keyCell: {
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keyCellPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
keyCellSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
keyCellText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
keyCellTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
macroForm: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
fieldInput: {
|
||||
backgroundColor: colors.bgBase,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: 15,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButtonTextDisabled: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
|
||||
import { View, Text, Pressable, TextInput, 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 { colors } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import {
|
||||
buildTerminalShortcutKey,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type TerminalShortcutModifier,
|
||||
type TerminalShortcutSpecialKey
|
||||
} from '../terminal/terminal-accessory-keys'
|
||||
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
|
||||
|
||||
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
|
||||
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
backSpacer: {
|
||||
width: 30
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 1
|
||||
},
|
||||
rowHint: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
shortcutForm: {
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
preview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.lg + spacing.xs,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
previewKeycapRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
previewPlus: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keycapModifier: {
|
||||
minWidth: 0
|
||||
},
|
||||
keycapWarn: {
|
||||
borderColor: colors.statusAmber
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: '600'
|
||||
},
|
||||
keycapTextWarn: {
|
||||
color: colors.statusAmber
|
||||
},
|
||||
keycapModifierText: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: spacing.sm,
|
||||
paddingLeft: 2
|
||||
},
|
||||
mods: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
chip: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
chipSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
chipText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '500'
|
||||
},
|
||||
chipTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
chipGlyph: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
chipGlyphSelected: {
|
||||
color: 'rgba(10,10,10,0.5)'
|
||||
},
|
||||
keyInput: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center'
|
||||
},
|
||||
moreLink: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center'
|
||||
},
|
||||
moreLinkPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
moreLinkText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
textDecorationLine: 'underline'
|
||||
},
|
||||
specialKeysForm: {
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.md
|
||||
},
|
||||
specialGroup: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
specialGroupTitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
paddingLeft: 2,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
keyGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: -spacing.xs / 2
|
||||
},
|
||||
keyCellWrap: {
|
||||
paddingHorizontal: spacing.xs / 2,
|
||||
paddingVertical: spacing.xs / 2
|
||||
},
|
||||
keyCell: {
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keyCellPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
keyCellSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
keyCellText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
keyCellTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
macroForm: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
fieldInput: {
|
||||
backgroundColor: colors.bgBase,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: 15,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButtonTextDisabled: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.link}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View network diagnostics"
|
||||
>
|
||||
<Text style={styles.text}>View network diagnostics</Text>
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
link: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
text: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
|
||||
';',
|
||||
' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
|
||||
' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
|
||||
' }',
|
||||
' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
|
||||
' html, body {',
|
||||
' width: 100%;',
|
||||
' min-height: 100%;',
|
||||
' margin: 0;',
|
||||
' background: var(--editor-surface);',
|
||||
' color: var(--foreground);',
|
||||
' font-family: var(--font-sans);',
|
||||
' overscroll-behavior: contain;',
|
||||
' }',
|
||||
' body { overflow: auto; }',
|
||||
' #editor {',
|
||||
' min-height: 100vh;',
|
||||
' padding: 18px 16px 112px;',
|
||||
' outline: none;',
|
||||
' font-size: 14px;',
|
||||
' line-height: 1.7;',
|
||||
' word-wrap: break-word;',
|
||||
' overflow-wrap: anywhere;',
|
||||
' caret-color: var(--foreground);',
|
||||
' }',
|
||||
' #editor[contenteditable="false"] {',
|
||||
' opacity: 0.78;',
|
||||
' }',
|
||||
' #editor:empty::before,',
|
||||
' #editor p.is-empty:first-child::before {',
|
||||
' content: attr(data-placeholder);',
|
||||
' color: var(--muted-foreground);',
|
||||
' pointer-events: none;',
|
||||
' }',
|
||||
' #editor > :first-child { margin-top: 0; }',
|
||||
' h1, h2, h3, h4, h5, h6 {',
|
||||
' margin: 1.5em 0 0.5em;',
|
||||
' font-weight: 600;',
|
||||
' line-height: 1.3;',
|
||||
' letter-spacing: 0;',
|
||||
' }',
|
||||
' h1 { font-size: 1.85em; font-weight: 700; }',
|
||||
' h2 { font-size: 1.4em; }',
|
||||
' h3 { font-size: 1.15em; }',
|
||||
' p, ul, ol, blockquote { margin: 0.75em 0; }',
|
||||
' ul, ol { padding-left: 1.5em; }',
|
||||
' ul { list-style: disc; }',
|
||||
' ol { list-style: decimal; }',
|
||||
' li { margin: 0.15em 0; }',
|
||||
' li > p { margin: 0; }',
|
||||
' ul[data-type="taskList"] {',
|
||||
' padding-left: 0;',
|
||||
' list-style: none;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li {',
|
||||
' display: flex;',
|
||||
' align-items: flex-start;',
|
||||
' gap: 6px;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li > label {',
|
||||
' flex-shrink: 0;',
|
||||
' display: flex;',
|
||||
' align-items: center;',
|
||||
' height: 1.55em;',
|
||||
' user-select: none;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"] {',
|
||||
' appearance: none;',
|
||||
' width: 16px;',
|
||||
' height: 16px;',
|
||||
' margin: 0;',
|
||||
' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
|
||||
' border-radius: 4px;',
|
||||
' background: transparent;',
|
||||
' position: relative;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:checked {',
|
||||
' background: var(--primary);',
|
||||
' border-color: var(--primary);',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
|
||||
' content: "";',
|
||||
' position: absolute;',
|
||||
' left: 4px;',
|
||||
' top: 1px;',
|
||||
' width: 5px;',
|
||||
' height: 9px;',
|
||||
' border: solid var(--primary-foreground);',
|
||||
' border-width: 0 2px 2px 0;',
|
||||
' transform: rotate(45deg);',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
|
||||
' opacity: 0.65;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li > div {',
|
||||
' flex: 1;',
|
||||
' min-width: 0;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li[data-checked="true"] > div {',
|
||||
' text-decoration: line-through;',
|
||||
' color: var(--muted-foreground);',
|
||||
' }',
|
||||
' blockquote {',
|
||||
' padding: 0.5em 1em;',
|
||||
' border-left: 3px solid var(--border);',
|
||||
' border-radius: 0 6px 6px 0;',
|
||||
' color: var(--muted-foreground);',
|
||||
' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
|
||||
' }',
|
||||
' table {',
|
||||
' width: 100%;',
|
||||
' margin: 1em 0;',
|
||||
' border-collapse: collapse;',
|
||||
' font-size: 0.95em;',
|
||||
' }',
|
||||
' th, td {',
|
||||
' padding: 8px 14px;',
|
||||
' border: 1px solid var(--border);',
|
||||
' text-align: left;',
|
||||
' vertical-align: top;',
|
||||
' }',
|
||||
' th {',
|
||||
' font-weight: 600;',
|
||||
' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
|
||||
' }',
|
||||
' tr:nth-child(odd) td {',
|
||||
' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
|
||||
' }',
|
||||
' code {',
|
||||
' padding: 0.2em 0.4em;',
|
||||
' border-radius: 5px;',
|
||||
' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
|
||||
' font-size: 0.88em;',
|
||||
' font-family: var(--font-mono);',
|
||||
' }',
|
||||
' pre {',
|
||||
' margin: 0.75em 0;',
|
||||
' padding: 14px 18px;',
|
||||
' border-radius: 8px;',
|
||||
' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
|
||||
' overflow-x: auto;',
|
||||
' line-height: 1.55;',
|
||||
' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
|
||||
' font-family: var(--font-mono);',
|
||||
' white-space: pre-wrap;',
|
||||
' }',
|
||||
' pre::before {',
|
||||
' content: attr(data-language);',
|
||||
' display: block;',
|
||||
' min-height: 13px;',
|
||||
' margin-bottom: 4px;',
|
||||
' color: var(--muted-foreground);',
|
||||
' font-size: 11px;',
|
||||
' text-transform: uppercase;',
|
||||
' }',
|
||||
' pre code {',
|
||||
' padding: 0;',
|
||||
' border-radius: 0;',
|
||||
' background: transparent;',
|
||||
' font-size: 0.92em;',
|
||||
' white-space: pre-wrap;',
|
||||
' }',
|
||||
' hr {',
|
||||
' margin: 1.5em 0;',
|
||||
' border: none;',
|
||||
' border-top: 1px solid var(--border);',
|
||||
' }',
|
||||
' a {',
|
||||
' color: var(--accent-link);',
|
||||
' text-decoration: underline;',
|
||||
' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
|
||||
' text-underline-offset: 2px;',
|
||||
' }',
|
||||
' img {',
|
||||
' display: block;',
|
||||
' max-width: 100%;',
|
||||
' margin: 0.75em 0;',
|
||||
' border-radius: 8px;',
|
||||
' }',
|
||||
' </style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>',
|
||||
' <script>',
|
||||
' (function () {',
|
||||
" var editor = document.getElementById('editor');",
|
||||
" var lastMarkdown = '';",
|
||||
' var inputTimer = null;',
|
||||
' var documentGeneration = 0;',
|
||||
' var editable = true;',
|
||||
' var suppressInput = false;',
|
||||
'',
|
||||
' function post(message) {',
|
||||
' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));',
|
||||
' }',
|
||||
'',
|
||||
' function decodeMarkdownEntities(value) {',
|
||||
' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {',
|
||||
' var lower = String(entity).toLowerCase();',
|
||||
" if (lower === 'amp') return '&';",
|
||||
" if (lower === 'lt') return '<';",
|
||||
" if (lower === 'gt') return '>';",
|
||||
" if (lower === 'quot') return '\"';",
|
||||
" if (lower === 'apos') return \"'\";",
|
||||
" if (lower.indexOf('#x') === 0) {",
|
||||
' var hex = Number.parseInt(lower.slice(2), 16);',
|
||||
' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;',
|
||||
' }',
|
||||
" if (lower.indexOf('#') === 0) {",
|
||||
' var code = Number.parseInt(lower.slice(1), 10);',
|
||||
' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;',
|
||||
' }',
|
||||
' return match;',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function escapeHtml(value) {',
|
||||
' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {',
|
||||
" return ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[char];",
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function escapeAttr(value) {',
|
||||
" return escapeHtml(value).replace(/\\n/g, ' ');",
|
||||
' }',
|
||||
'',
|
||||
' function isSafeUrl(value) {',
|
||||
" var trimmed = String(value || '').trim();",
|
||||
' return !/^javascript:/i.test(trimmed);',
|
||||
' }',
|
||||
'',
|
||||
' function splitTableRow(line) {',
|
||||
" return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {",
|
||||
' return cell.trim();',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function isTableSeparator(line) {',
|
||||
' var cells = splitTableRow(line);',
|
||||
' return cells.length > 0 && cells.every(function (cell) {',
|
||||
' return /^:?-{3,}:?$/.test(cell);',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function renderInline(text) {',
|
||||
" var output = '';",
|
||||
' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;',
|
||||
' var lastIndex = 0;',
|
||||
' var match;',
|
||||
' while ((match = pattern.exec(text))) {',
|
||||
' output += escapeHtml(text.slice(lastIndex, match.index));',
|
||||
' var token = match[0];',
|
||||
' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);',
|
||||
' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);',
|
||||
' if (image && isSafeUrl(image[2])) {',
|
||||
" output += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
|
||||
' } else if (link && isSafeUrl(link[2])) {',
|
||||
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
|
||||
' } else if (/^https?:\\/\\//i.test(token)) {',
|
||||
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
|
||||
" } else if (token.indexOf('`') === 0) {",
|
||||
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
|
||||
" } else if (token.indexOf('~~') === 0) {",
|
||||
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
|
||||
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
|
||||
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
|
||||
' } else {',
|
||||
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
|
||||
' }',
|
||||
' lastIndex = pattern.lastIndex;',
|
||||
' }',
|
||||
' output += escapeHtml(text.slice(lastIndex));',
|
||||
' return output;',
|
||||
' }',
|
||||
'',
|
||||
' function isBlockStart(line) {',
|
||||
' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);'
|
||||
].join('\n')
|
||||
@@ -0,0 +1,278 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY = [
|
||||
' }',
|
||||
'',
|
||||
' function indentationWidth(value) {',
|
||||
" return String(value || '').replace(/\\t/g, ' ').length;",
|
||||
' }',
|
||||
'',
|
||||
' function parseListLine(line) {',
|
||||
" var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);",
|
||||
' if (!match) return null;',
|
||||
" var rawText = match[3] || '';",
|
||||
' var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);',
|
||||
' return {',
|
||||
" indent: indentationWidth(match[1] || ''),",
|
||||
" ordered: /^\\d/.test(match[2] || ''),",
|
||||
" orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,",
|
||||
" task: task ? task[1].toLowerCase() === 'x' : null,",
|
||||
' text: task ? task[2] : rawText,',
|
||||
' children: []',
|
||||
' };',
|
||||
' }',
|
||||
'',
|
||||
' function listKind(item) {',
|
||||
" if (item.task !== null) return 'task';",
|
||||
" return item.ordered ? 'ol' : 'ul';",
|
||||
' }',
|
||||
'',
|
||||
' function parseListTree(lines, startIndex) {',
|
||||
' var root = { indent: -1, children: [] };',
|
||||
' var stack = [root];',
|
||||
' var index = startIndex;',
|
||||
' while (index < lines.length) {',
|
||||
" var item = parseListLine(lines[index] || '');",
|
||||
' if (!item) break;',
|
||||
' while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {',
|
||||
' stack.pop();',
|
||||
' }',
|
||||
' stack[stack.length - 1].children.push(item);',
|
||||
' stack.push(item);',
|
||||
' index += 1;',
|
||||
' }',
|
||||
' return { items: root.children, nextIndex: index };',
|
||||
' }',
|
||||
'',
|
||||
' function renderListItems(items) {',
|
||||
' var html = [];',
|
||||
' var index = 0;',
|
||||
' while (index < items.length) {',
|
||||
' var kind = listKind(items[index]);',
|
||||
' var group = [];',
|
||||
' while (index < items.length && listKind(items[index]) === kind) {',
|
||||
' group.push(items[index]);',
|
||||
' index += 1;',
|
||||
' }',
|
||||
" var tag = kind === 'ol' ? 'ol' : 'ul';",
|
||||
" var attrs = kind === 'task' ? ' data-type=\"taskList\"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start=\"' + group[0].orderedNumber + '\"' : '';",
|
||||
" html.push('<' + tag + attrs + '>' + group.map(function (item) {",
|
||||
" var children = item.children.length ? renderListItems(item.children) : '';",
|
||||
" if (kind === 'task') {",
|
||||
' var checked = item.task === true;',
|
||||
" return '<li data-checked=\"' + String(checked) + '\"><label contenteditable=\"false\"><input type=\"checkbox\" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';",
|
||||
' }',
|
||||
" var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value=\"' + item.orderedNumber + '\" data-list-number=\"' + item.orderedNumber + '\"' : '';",
|
||||
" return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';",
|
||||
" }).join('') + '</' + tag + '>');",
|
||||
' }',
|
||||
" return html.join('');",
|
||||
' }',
|
||||
'',
|
||||
' function markdownToHtml(markdown) {',
|
||||
" var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');",
|
||||
' var html = [];',
|
||||
' var index = 0;',
|
||||
' while (index < lines.length) {',
|
||||
" var line = lines[index] || '';",
|
||||
' if (!line.trim()) {',
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
' var fence = line.match(/^\\```([^\\s`]*)\\s*$/);',
|
||||
' if (fence) {',
|
||||
' index += 1;',
|
||||
' var code = [];',
|
||||
" while (index < lines.length && !/^\\```\\s*$/.test(lines[index] || '')) {",
|
||||
" code.push(lines[index] || '');",
|
||||
' index += 1;',
|
||||
' }',
|
||||
' if (index < lines.length) index += 1;',
|
||||
" html.push('<pre data-language=\"' + escapeAttr(fence[1] || '') + '\"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {',
|
||||
" html.push('<hr />');",
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
" if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {",
|
||||
' var headers = splitTableRow(line);',
|
||||
' index += 2;',
|
||||
' var rows = [];',
|
||||
" while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {",
|
||||
" rows.push(splitTableRow(lines[index] || ''));",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {",
|
||||
" return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';",
|
||||
" }).join('') + '</tbody></table>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' var heading = line.match(/^(#{1,6})\\s+(.+)$/);',
|
||||
' if (heading) {',
|
||||
" html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');",
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^>\\s?/.test(line)) {',
|
||||
' var quote = [];',
|
||||
" while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {",
|
||||
" quote.push((lines[index] || '').replace(/^>\\s?/, ''));",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {',
|
||||
' var list = parseListTree(lines, index);',
|
||||
' html.push(renderListItems(list.items));',
|
||||
' index = list.nextIndex;',
|
||||
' continue;',
|
||||
' }',
|
||||
' var paragraph = [];',
|
||||
" while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {",
|
||||
" paragraph.push(lines[index] || '');",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');",
|
||||
' }',
|
||||
" return html.join('\\n') || '<p class=\"is-empty\"><br /></p>';",
|
||||
' }',
|
||||
'',
|
||||
' function textContent(node) {',
|
||||
" return (node.textContent || '').replace(/\\u00a0/g, ' ');",
|
||||
' }',
|
||||
'',
|
||||
' function inlineMarkdown(node) {',
|
||||
" if (!node) return '';",
|
||||
' if (node.nodeType === Node.TEXT_NODE) return textContent(node);',
|
||||
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
|
||||
' var el = node;',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" if (tag === 'br') return '\\n';",
|
||||
" if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';",
|
||||
" if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';",
|
||||
" if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';",
|
||||
" if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '`' + textContent(el) + '`';",
|
||||
" if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';",
|
||||
" if (tag === 'img') return ' || '') + ')';",
|
||||
" if (tag === 'label') return '';",
|
||||
' return inlineChildren(el);',
|
||||
' }',
|
||||
'',
|
||||
' function inlineChildren(el) {',
|
||||
" return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');",
|
||||
' }',
|
||||
'',
|
||||
' function listItemText(li) {',
|
||||
' var clone = li.cloneNode(true);',
|
||||
" Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });",
|
||||
" Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });",
|
||||
' return inlineChildren(clone).trim();',
|
||||
' }',
|
||||
'',
|
||||
' function directNestedLists(li) {',
|
||||
" return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {",
|
||||
" return list.closest('li') === li;",
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function listMarkdown(el, depth) {',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" var isTask = el.getAttribute('data-type') === 'taskList';",
|
||||
" var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;",
|
||||
' if (!Number.isFinite(orderedStart)) orderedStart = 1;',
|
||||
" var indent = ' '.repeat(depth);",
|
||||
' return Array.prototype.map.call(el.children, function (li, index) {',
|
||||
" if (li.tagName.toLowerCase() !== 'li') return '';",
|
||||
' var marker;',
|
||||
' if (isTask) {',
|
||||
' var input = li.querySelector(\'input[type="checkbox"]\');',
|
||||
" marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';",
|
||||
' } else {',
|
||||
' var listNumber =',
|
||||
' li.getAttribute &&',
|
||||
" (li.getAttribute('data-list-number') || li.getAttribute('value'));",
|
||||
" marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';",
|
||||
' }',
|
||||
' var line = indent + marker + listItemText(li);',
|
||||
' var nested = directNestedLists(li).map(function (list) {',
|
||||
' return listMarkdown(list, depth + 1);',
|
||||
" }).filter(Boolean).join('\\n');",
|
||||
" return nested ? line + '\\n' + nested : line;",
|
||||
" }).filter(Boolean).join('\\n');",
|
||||
' }',
|
||||
'',
|
||||
' function blockMarkdown(node) {',
|
||||
' if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();',
|
||||
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
|
||||
' var el = node;',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();",
|
||||
" if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();",
|
||||
" if (tag === 'blockquote') {",
|
||||
" return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');",
|
||||
' }',
|
||||
" if (tag === 'pre') {",
|
||||
" var lang = el.getAttribute('data-language') || '';",
|
||||
" var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');",
|
||||
" return '```' + lang + '\\n' + code + '\\n```';",
|
||||
' }',
|
||||
" if (tag === 'ul' || tag === 'ol') {",
|
||||
' return listMarkdown(el, 0);',
|
||||
' }',
|
||||
" if (tag === 'table') {",
|
||||
" var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));",
|
||||
" if (rows.length === 0) return '';",
|
||||
' var cellsFor = function (row) {',
|
||||
' return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });',
|
||||
' };',
|
||||
' var headers = cellsFor(rows[0]);',
|
||||
' var bodyRows = rows.slice(1).map(cellsFor);',
|
||||
" return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');",
|
||||
' }',
|
||||
" if (tag === 'hr') return '---';",
|
||||
" if (tag === 'img') return inlineMarkdown(el);",
|
||||
' return inlineChildren(el).trim();',
|
||||
' }',
|
||||
'',
|
||||
' function currentMarkdown() {',
|
||||
' return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {',
|
||||
' return block.trim().length > 0;',
|
||||
" }).join('\\n\\n').trimEnd();",
|
||||
' }',
|
||||
'',
|
||||
' function syncTaskCheckboxesDisabled() {',
|
||||
' Array.prototype.forEach.call(editor.querySelectorAll(\'input[type="checkbox"]\'), function (input) {',
|
||||
' input.disabled = !editable;',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function emitChange() {',
|
||||
' if (suppressInput || !editable) return;',
|
||||
' window.clearTimeout(inputTimer);',
|
||||
' var pendingGeneration = documentGeneration;',
|
||||
' lastMarkdown = currentMarkdown();',
|
||||
" post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });",
|
||||
' }',
|
||||
'',
|
||||
' function setMarkdown(markdown, generation) {',
|
||||
' window.clearTimeout(inputTimer);',
|
||||
' documentGeneration = Number(generation) || 0;',
|
||||
' suppressInput = true;',
|
||||
" // Why: replacing innerHTML detaches the remembered caret's nodes.",
|
||||
' savedSelectionRange = null;',
|
||||
' selectionDroppedOnBlur = false;',
|
||||
" lastMarkdown = String(markdown || '');",
|
||||
' editor.innerHTML = markdownToHtml(lastMarkdown);',
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' suppressInput = false;',
|
||||
' }',
|
||||
'',
|
||||
' function setEditable(nextEditable) {',
|
||||
' editable = Boolean(nextEditable);',
|
||||
" editor.setAttribute('contenteditable', editable ? 'true' : 'false');",
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' }',
|
||||
'',
|
||||
''
|
||||
].join('\n')
|
||||
@@ -0,0 +1,93 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [
|
||||
'',
|
||||
' function runCommand(command) {',
|
||||
" if (!editable || editor.getAttribute('contenteditable') !== 'true') return;",
|
||||
' restoreSelectionOrEnd();',
|
||||
" if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');",
|
||||
" else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');",
|
||||
" else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');",
|
||||
" else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');",
|
||||
" else if (command === 'bold') document.execCommand('bold');",
|
||||
" else if (command === 'italic') document.execCommand('italic');",
|
||||
" else if (command === 'strike') document.execCommand('strikeThrough');",
|
||||
" else if (command === 'bulletList') document.execCommand('insertUnorderedList');",
|
||||
" else if (command === 'orderedList') document.execCommand('insertOrderedList');",
|
||||
" else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');",
|
||||
" else if (command === 'inlineCode') wrapSelection('code');",
|
||||
" else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=\"\"><code>code</code></pre><p><br></p>');",
|
||||
' else if (command === \'taskList\') document.execCommand(\'insertHTML\', false, \'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>\');',
|
||||
" else if (command === 'link') {",
|
||||
" var href = window.prompt('Link URL');",
|
||||
" if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);",
|
||||
" } else if (command === 'image') {",
|
||||
" var src = window.prompt('Image URL');",
|
||||
" if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);",
|
||||
' }',
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' emitChange();',
|
||||
' }',
|
||||
'',
|
||||
" editor.addEventListener('input', function () {",
|
||||
' selectionDroppedOnBlur = false;',
|
||||
' if (editable) emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('change', function (event) {",
|
||||
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
|
||||
' if (input) {',
|
||||
' if (!editable) {',
|
||||
' event.preventDefault();',
|
||||
' return;',
|
||||
' }',
|
||||
" var li = input.closest('li');",
|
||||
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
|
||||
' }',
|
||||
' if (editable) emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('click', function (event) {",
|
||||
" var link = event.target && event.target.closest && event.target.closest('a[href]');",
|
||||
' if (link) {',
|
||||
' event.preventDefault();',
|
||||
" post({ type: 'openLink', url: link.getAttribute('href') || '' });",
|
||||
' return;',
|
||||
' }',
|
||||
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
|
||||
' if (!input) {',
|
||||
' if (!editable) return;',
|
||||
' // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.',
|
||||
' var uneditable = event.target && event.target.closest && event.target.closest(\'[contenteditable="false"]\');',
|
||||
' if (uneditable && uneditable !== editor) return;',
|
||||
' selectionDroppedOnBlur = false;',
|
||||
' if (document.activeElement === editor) return;',
|
||||
' // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.',
|
||||
' var caret = caretRangeAtPoint(event.clientX, event.clientY);',
|
||||
' focusEditor();',
|
||||
' if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);',
|
||||
' return;',
|
||||
' }',
|
||||
' if (!editable) {',
|
||||
' event.preventDefault();',
|
||||
' return;',
|
||||
' }',
|
||||
" var li = input.closest('li');",
|
||||
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
|
||||
' emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('keydown', function (event) {",
|
||||
" if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {",
|
||||
' event.preventDefault();',
|
||||
" runCommand('bold');",
|
||||
' }',
|
||||
' });',
|
||||
'',
|
||||
' window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END = [
|
||||
'',
|
||||
" post({ type: 'ready' });",
|
||||
' })();',
|
||||
' </script>',
|
||||
'</body>',
|
||||
'</html>'
|
||||
].join('\n')
|
||||
@@ -2,6 +2,12 @@ import { colors } from '../theme/mobile-theme'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary'
|
||||
import {
|
||||
MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS,
|
||||
MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END
|
||||
} from './mobile-rich-markdown-editor-document-suffix'
|
||||
|
||||
export function escapeInjectedJavaScriptString(value: string): string {
|
||||
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
|
||||
@@ -24,644 +30,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
--border: ${colors.borderSubtle};
|
||||
--primary: ${colors.textPrimary};
|
||||
--primary-foreground: ${colors.bgBase};
|
||||
--accent-link: ${colors.accentBlue};
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background: var(--editor-surface);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
body { overflow: auto; }
|
||||
#editor {
|
||||
min-height: 100vh;
|
||||
padding: 18px 16px 112px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
#editor[contenteditable="false"] {
|
||||
opacity: 0.78;
|
||||
}
|
||||
#editor:empty::before,
|
||||
#editor p.is-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
#editor > :first-child { margin-top: 0; }
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 1.5em 0 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
h1 { font-size: 1.85em; font-weight: 700; }
|
||||
h2 { font-size: 1.4em; }
|
||||
h3 { font-size: 1.15em; }
|
||||
p, ul, ol, blockquote { margin: 0.75em 0; }
|
||||
ul, ol { padding-left: 1.5em; }
|
||||
ul { list-style: disc; }
|
||||
ol { list-style: decimal; }
|
||||
li { margin: 0.15em 0; }
|
||||
li > p { margin: 0; }
|
||||
ul[data-type="taskList"] {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
ul[data-type="taskList"] > li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
ul[data-type="taskList"] > li > label {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.55em;
|
||||
user-select: none;
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:checked {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--primary-foreground);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
ul[data-type="taskList"] > li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
ul[data-type="taskList"] > li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
blockquote {
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: var(--muted-foreground);
|
||||
background: color-mix(in srgb, var(--foreground) 2%, transparent);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
tr:nth-child(odd) td {
|
||||
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
|
||||
}
|
||||
code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
font-size: 0.88em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
pre {
|
||||
margin: 0.75em 0;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
overflow-x: auto;
|
||||
line-height: 1.55;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
pre::before {
|
||||
content: attr(data-language);
|
||||
display: block;
|
||||
min-height: 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
pre code {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 0.92em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
hr {
|
||||
margin: 1.5em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-link);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0.75em 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastMarkdown = '';
|
||||
var inputTimer = null;
|
||||
var documentGeneration = 0;
|
||||
var editable = true;
|
||||
var suppressInput = false;
|
||||
|
||||
function post(message) {
|
||||
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function decodeMarkdownEntities(value) {
|
||||
return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {
|
||||
var lower = String(entity).toLowerCase();
|
||||
if (lower === 'amp') return '&';
|
||||
if (lower === 'lt') return '<';
|
||||
if (lower === 'gt') return '>';
|
||||
if (lower === 'quot') return '"';
|
||||
if (lower === 'apos') return "'";
|
||||
if (lower.indexOf('#x') === 0) {
|
||||
var hex = Number.parseInt(lower.slice(2), 16);
|
||||
return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;
|
||||
}
|
||||
if (lower.indexOf('#') === 0) {
|
||||
var code = Number.parseInt(lower.slice(1), 10);
|
||||
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return decodeMarkdownEntities(value).replace(/[&<>"']/g, function (char) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char];
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/\\n/g, ' ');
|
||||
}
|
||||
|
||||
function isSafeUrl(value) {
|
||||
var trimmed = String(value || '').trim();
|
||||
return !/^javascript:/i.test(trimmed);
|
||||
}
|
||||
|
||||
function splitTableRow(line) {
|
||||
return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {
|
||||
return cell.trim();
|
||||
});
|
||||
}
|
||||
|
||||
function isTableSeparator(line) {
|
||||
var cells = splitTableRow(line);
|
||||
return cells.length > 0 && cells.every(function (cell) {
|
||||
return /^:?-{3,}:?$/.test(cell);
|
||||
});
|
||||
}
|
||||
|
||||
function renderInline(text) {
|
||||
var output = '';
|
||||
var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|\`[^\`]+\`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;
|
||||
var lastIndex = 0;
|
||||
var match;
|
||||
while ((match = pattern.exec(text))) {
|
||||
output += escapeHtml(text.slice(lastIndex, match.index));
|
||||
var token = match[0];
|
||||
var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);
|
||||
var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);
|
||||
if (image && isSafeUrl(image[2])) {
|
||||
output += '<img src="' + escapeAttr(image[2]) + '" alt="' + escapeAttr(image[1] || '') + '" />';
|
||||
} else if (link && isSafeUrl(link[2])) {
|
||||
output += '<a href="' + escapeAttr(link[2]) + '">' + renderInline(link[1]) + '</a>';
|
||||
} else if (/^https?:\\/\\//i.test(token)) {
|
||||
output += '<a href="' + escapeAttr(token) + '">' + escapeHtml(token) + '</a>';
|
||||
} else if (token.indexOf('\`') === 0) {
|
||||
output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';
|
||||
} else if (token.indexOf('~~') === 0) {
|
||||
output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';
|
||||
} else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {
|
||||
output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';
|
||||
} else {
|
||||
output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';
|
||||
}
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
output += escapeHtml(text.slice(lastIndex));
|
||||
return output;
|
||||
}
|
||||
|
||||
function isBlockStart(line) {
|
||||
return /^(\`\`\`|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);
|
||||
}
|
||||
|
||||
function indentationWidth(value) {
|
||||
return String(value || '').replace(/\\t/g, ' ').length;
|
||||
}
|
||||
|
||||
function parseListLine(line) {
|
||||
var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
var rawText = match[3] || '';
|
||||
var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);
|
||||
return {
|
||||
indent: indentationWidth(match[1] || ''),
|
||||
ordered: /^\\d/.test(match[2] || ''),
|
||||
orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,
|
||||
task: task ? task[1].toLowerCase() === 'x' : null,
|
||||
text: task ? task[2] : rawText,
|
||||
children: []
|
||||
};
|
||||
}
|
||||
|
||||
function listKind(item) {
|
||||
if (item.task !== null) return 'task';
|
||||
return item.ordered ? 'ol' : 'ul';
|
||||
}
|
||||
|
||||
function parseListTree(lines, startIndex) {
|
||||
var root = { indent: -1, children: [] };
|
||||
var stack = [root];
|
||||
var index = startIndex;
|
||||
while (index < lines.length) {
|
||||
var item = parseListLine(lines[index] || '');
|
||||
if (!item) break;
|
||||
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
|
||||
stack.pop();
|
||||
}
|
||||
stack[stack.length - 1].children.push(item);
|
||||
stack.push(item);
|
||||
index += 1;
|
||||
}
|
||||
return { items: root.children, nextIndex: index };
|
||||
}
|
||||
|
||||
function renderListItems(items) {
|
||||
var html = [];
|
||||
var index = 0;
|
||||
while (index < items.length) {
|
||||
var kind = listKind(items[index]);
|
||||
var group = [];
|
||||
while (index < items.length && listKind(items[index]) === kind) {
|
||||
group.push(items[index]);
|
||||
index += 1;
|
||||
}
|
||||
var tag = kind === 'ol' ? 'ol' : 'ul';
|
||||
var attrs = kind === 'task' ? ' data-type="taskList"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start="' + group[0].orderedNumber + '"' : '';
|
||||
html.push('<' + tag + attrs + '>' + group.map(function (item) {
|
||||
var children = item.children.length ? renderListItems(item.children) : '';
|
||||
if (kind === 'task') {
|
||||
var checked = item.task === true;
|
||||
return '<li data-checked="' + String(checked) + '"><label contenteditable="false"><input type="checkbox" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';
|
||||
}
|
||||
var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value="' + item.orderedNumber + '" data-list-number="' + item.orderedNumber + '"' : '';
|
||||
return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';
|
||||
}).join('') + '</' + tag + '>');
|
||||
}
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown) {
|
||||
var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');
|
||||
var html = [];
|
||||
var index = 0;
|
||||
while (index < lines.length) {
|
||||
var line = lines[index] || '';
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
var fence = line.match(/^\\\`\`\`([^\\s\`]*)\\s*$/);
|
||||
if (fence) {
|
||||
index += 1;
|
||||
var code = [];
|
||||
while (index < lines.length && !/^\\\`\`\`\\s*$/.test(lines[index] || '')) {
|
||||
code.push(lines[index] || '');
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
html.push('<pre data-language="' + escapeAttr(fence[1] || '') + '"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {
|
||||
html.push('<hr />');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {
|
||||
var headers = splitTableRow(line);
|
||||
index += 2;
|
||||
var rows = [];
|
||||
while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {
|
||||
rows.push(splitTableRow(lines[index] || ''));
|
||||
index += 1;
|
||||
}
|
||||
html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {
|
||||
return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';
|
||||
}).join('') + '</tbody></table>');
|
||||
continue;
|
||||
}
|
||||
var heading = line.match(/^(#{1,6})\\s+(.+)$/);
|
||||
if (heading) {
|
||||
html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>\\s?/.test(line)) {
|
||||
var quote = [];
|
||||
while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {
|
||||
quote.push((lines[index] || '').replace(/^>\\s?/, ''));
|
||||
index += 1;
|
||||
}
|
||||
html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');
|
||||
continue;
|
||||
}
|
||||
if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {
|
||||
var list = parseListTree(lines, index);
|
||||
html.push(renderListItems(list.items));
|
||||
index = list.nextIndex;
|
||||
continue;
|
||||
}
|
||||
var paragraph = [];
|
||||
while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {
|
||||
paragraph.push(lines[index] || '');
|
||||
index += 1;
|
||||
}
|
||||
html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');
|
||||
}
|
||||
return html.join('\\n') || '<p class="is-empty"><br /></p>';
|
||||
}
|
||||
|
||||
function textContent(node) {
|
||||
return (node.textContent || '').replace(/\\u00a0/g, ' ');
|
||||
}
|
||||
|
||||
function inlineMarkdown(node) {
|
||||
if (!node) return '';
|
||||
if (node.nodeType === Node.TEXT_NODE) return textContent(node);
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
var el = node;
|
||||
var tag = el.tagName.toLowerCase();
|
||||
if (tag === 'br') return '\\n';
|
||||
if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';
|
||||
if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';
|
||||
if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';
|
||||
if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '\`' + textContent(el) + '\`';
|
||||
if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';
|
||||
if (tag === 'img') return ' || '') + ')';
|
||||
if (tag === 'label') return '';
|
||||
return inlineChildren(el);
|
||||
}
|
||||
|
||||
function inlineChildren(el) {
|
||||
return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');
|
||||
}
|
||||
|
||||
function listItemText(li) {
|
||||
var clone = li.cloneNode(true);
|
||||
Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });
|
||||
Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });
|
||||
return inlineChildren(clone).trim();
|
||||
}
|
||||
|
||||
function directNestedLists(li) {
|
||||
return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {
|
||||
return list.closest('li') === li;
|
||||
});
|
||||
}
|
||||
|
||||
function listMarkdown(el, depth) {
|
||||
var tag = el.tagName.toLowerCase();
|
||||
var isTask = el.getAttribute('data-type') === 'taskList';
|
||||
var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;
|
||||
if (!Number.isFinite(orderedStart)) orderedStart = 1;
|
||||
var indent = ' '.repeat(depth);
|
||||
return Array.prototype.map.call(el.children, function (li, index) {
|
||||
if (li.tagName.toLowerCase() !== 'li') return '';
|
||||
var marker;
|
||||
if (isTask) {
|
||||
var input = li.querySelector('input[type="checkbox"]');
|
||||
marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';
|
||||
} else {
|
||||
var listNumber =
|
||||
li.getAttribute &&
|
||||
(li.getAttribute('data-list-number') || li.getAttribute('value'));
|
||||
marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';
|
||||
}
|
||||
var line = indent + marker + listItemText(li);
|
||||
var nested = directNestedLists(li).map(function (list) {
|
||||
return listMarkdown(list, depth + 1);
|
||||
}).filter(Boolean).join('\\n');
|
||||
return nested ? line + '\\n' + nested : line;
|
||||
}).filter(Boolean).join('\\n');
|
||||
}
|
||||
|
||||
function blockMarkdown(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
var el = node;
|
||||
var tag = el.tagName.toLowerCase();
|
||||
if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();
|
||||
if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();
|
||||
if (tag === 'blockquote') {
|
||||
return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');
|
||||
}
|
||||
if (tag === 'pre') {
|
||||
var lang = el.getAttribute('data-language') || '';
|
||||
var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');
|
||||
return '\`\`\`' + lang + '\\n' + code + '\\n\`\`\`';
|
||||
}
|
||||
if (tag === 'ul' || tag === 'ol') {
|
||||
return listMarkdown(el, 0);
|
||||
}
|
||||
if (tag === 'table') {
|
||||
var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));
|
||||
if (rows.length === 0) return '';
|
||||
var cellsFor = function (row) {
|
||||
return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });
|
||||
};
|
||||
var headers = cellsFor(rows[0]);
|
||||
var bodyRows = rows.slice(1).map(cellsFor);
|
||||
return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');
|
||||
}
|
||||
if (tag === 'hr') return '---';
|
||||
if (tag === 'img') return inlineMarkdown(el);
|
||||
return inlineChildren(el).trim();
|
||||
}
|
||||
|
||||
function currentMarkdown() {
|
||||
return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {
|
||||
return block.trim().length > 0;
|
||||
}).join('\\n\\n').trimEnd();
|
||||
}
|
||||
|
||||
function syncTaskCheckboxesDisabled() {
|
||||
Array.prototype.forEach.call(editor.querySelectorAll('input[type="checkbox"]'), function (input) {
|
||||
input.disabled = !editable;
|
||||
});
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
if (suppressInput || !editable) return;
|
||||
window.clearTimeout(inputTimer);
|
||||
var pendingGeneration = documentGeneration;
|
||||
lastMarkdown = currentMarkdown();
|
||||
post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });
|
||||
}
|
||||
|
||||
function setMarkdown(markdown, generation) {
|
||||
window.clearTimeout(inputTimer);
|
||||
documentGeneration = Number(generation) || 0;
|
||||
suppressInput = true;
|
||||
// Why: replacing innerHTML detaches the remembered caret's nodes.
|
||||
savedSelectionRange = null;
|
||||
selectionDroppedOnBlur = false;
|
||||
lastMarkdown = String(markdown || '');
|
||||
editor.innerHTML = markdownToHtml(lastMarkdown);
|
||||
syncTaskCheckboxesDisabled();
|
||||
suppressInput = false;
|
||||
}
|
||||
|
||||
function setEditable(nextEditable) {
|
||||
editable = Boolean(nextEditable);
|
||||
editor.setAttribute('contenteditable', editable ? 'true' : 'false');
|
||||
syncTaskCheckboxesDisabled();
|
||||
}
|
||||
|
||||
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
|
||||
function runCommand(command) {
|
||||
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
|
||||
restoreSelectionOrEnd();
|
||||
if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');
|
||||
else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');
|
||||
else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');
|
||||
else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');
|
||||
else if (command === 'bold') document.execCommand('bold');
|
||||
else if (command === 'italic') document.execCommand('italic');
|
||||
else if (command === 'strike') document.execCommand('strikeThrough');
|
||||
else if (command === 'bulletList') document.execCommand('insertUnorderedList');
|
||||
else if (command === 'orderedList') document.execCommand('insertOrderedList');
|
||||
else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');
|
||||
else if (command === 'inlineCode') wrapSelection('code');
|
||||
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>code</code></pre><p><br></p>');
|
||||
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>');
|
||||
else if (command === 'link') {
|
||||
var href = window.prompt('Link URL');
|
||||
if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);
|
||||
} else if (command === 'image') {
|
||||
var src = window.prompt('Image URL');
|
||||
if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);
|
||||
}
|
||||
syncTaskCheckboxesDisabled();
|
||||
emitChange();
|
||||
}
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
selectionDroppedOnBlur = false;
|
||||
if (editable) emitChange();
|
||||
});
|
||||
editor.addEventListener('change', function (event) {
|
||||
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
|
||||
if (input) {
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
var li = input.closest('li');
|
||||
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
|
||||
}
|
||||
if (editable) emitChange();
|
||||
});
|
||||
editor.addEventListener('click', function (event) {
|
||||
var link = event.target && event.target.closest && event.target.closest('a[href]');
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
post({ type: 'openLink', url: link.getAttribute('href') || '' });
|
||||
return;
|
||||
}
|
||||
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
|
||||
if (!input) {
|
||||
if (!editable) return;
|
||||
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
|
||||
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
|
||||
if (uneditable && uneditable !== editor) return;
|
||||
selectionDroppedOnBlur = false;
|
||||
if (document.activeElement === editor) return;
|
||||
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
|
||||
var caret = caretRangeAtPoint(event.clientX, event.clientY);
|
||||
focusEditor();
|
||||
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
|
||||
return;
|
||||
}
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
var li = input.closest('li');
|
||||
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
|
||||
emitChange();
|
||||
});
|
||||
editor.addEventListener('keydown', function (event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {
|
||||
event.preventDefault();
|
||||
runCommand('bold');
|
||||
}
|
||||
});
|
||||
|
||||
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
|
||||
post({ type: 'ready' });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
--accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY}
|
||||
${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy'
|
||||
import {
|
||||
pickPreferredNewWorktreeAgent,
|
||||
type NewWorktreeAgentOption,
|
||||
@@ -54,7 +55,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
runSetup: boolean
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
|
||||
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
|
||||
setError: Dispatch<SetStateAction<string>>
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
@@ -163,7 +164,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
workspaceName: trimmedName || undefined,
|
||||
note: trimmedNote,
|
||||
nameIsAutoManaged: args.composer.isNameAutoManaged,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
: await createBlankWorkspace({
|
||||
client,
|
||||
@@ -173,7 +174,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
createdWithAgentId,
|
||||
comment: trimmedNote,
|
||||
setupDecision,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
if ('error' in result) {
|
||||
args.setError(result.error)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from './connection-diagnostics-analysis'
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
|
||||
function event(message: string, detail?: string): ConnectionLogEntry {
|
||||
return { id: message, ts: 1, level: 'error', message, detail }
|
||||
}
|
||||
|
||||
describe('diagnoseConnection', () => {
|
||||
it('reports the current healthy path instead of a historical failure', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'connected',
|
||||
activePath: 'relay',
|
||||
entries: [event('WebSocket connect timeout')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Connection is healthy via Relay.',
|
||||
nextStep: 'No action needed.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes an invalid Relay credential from a transient outage', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Relay rejected the saved resume credential.',
|
||||
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies the direct Tailscale timeout while Relay recovery is pending', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('WebSocket connect timeout')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.',
|
||||
nextStep: 'Relay recovery is in progress; keep Orca open while it retries.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies an authenticated Relay liveness failure', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Relay stopped answering authenticated health checks.',
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: 'orca-relay'
|
||||
})
|
||||
})
|
||||
|
||||
it('separates an active Relay close from a failed Relay dial', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'),
|
||||
code: 'relay-session-failed',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The active Relay session closed unexpectedly.',
|
||||
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
|
||||
reportability: 'orca-relay'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks authenticated Relay liveness failures as safe to send', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
}).reportability
|
||||
).toBe('orca-relay')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['direct timeout', 'connect-timeout', 'tailscale'],
|
||||
['handshake timeout', 'handshake-timeout', 'relay'],
|
||||
['invalid credential', 'relay director resolve failed (401)', 'relay'],
|
||||
['ambiguous recovery', 'retry scheduled', 'relay']
|
||||
] as const)('does not offer submission for %s', (_name, message, path) => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [{ ...event(message), path }]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('does not offer submission for a bounded Relay director outage', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('classifies the newest failure instead of an older persisted 401', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
event('Relay: relay dial failed', 'relay director resolve failed (401)'),
|
||||
{ ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' }
|
||||
]
|
||||
}).likelyCause
|
||||
).toBe('The saved Tailscale endpoint did not answer before the connection timeout.')
|
||||
})
|
||||
|
||||
it('requires structured Relay evidence before offering submission', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [event('active relay session failed')]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('ignores failures from before the current app resume boundary', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'connecting',
|
||||
activePath: 'lan',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
},
|
||||
{
|
||||
...event('App returned to foreground'),
|
||||
id: 'resume',
|
||||
ts: 2,
|
||||
code: 'app-resumed'
|
||||
},
|
||||
{ ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' }
|
||||
]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('uses a structured direct liveness path ahead of the current active path', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Connection health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'lan'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The connected host stopped answering authenticated health checks.',
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('keys reportability to the current structured incident', () => {
|
||||
const args = {
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting' as const,
|
||||
activePath: 'relay' as const,
|
||||
entries: [
|
||||
{ ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const },
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
id: 'current-relay-failure',
|
||||
ts: 2,
|
||||
code: 'liveness-timeout' as const,
|
||||
path: 'relay' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure')
|
||||
expect(
|
||||
getReportableConnectionIncidentId({
|
||||
...args,
|
||||
entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionState,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from '../transport/types'
|
||||
|
||||
export type ConnectionDiagnosis = {
|
||||
likelyCause: string
|
||||
nextStep: string
|
||||
reportability: 'none' | 'orca-relay'
|
||||
}
|
||||
|
||||
type DiagnoseConnectionArgs = {
|
||||
endpoint: string
|
||||
state: ConnectionState
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
}
|
||||
|
||||
export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis {
|
||||
if (args.state === 'connected') {
|
||||
return {
|
||||
likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`,
|
||||
nextStep: 'No action needed.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
const failure = findCurrentDiagnosticFailure(args.entries)
|
||||
const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : ''
|
||||
|
||||
if (/relay director resolve failed \(401\)/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'Relay rejected the saved resume credential.',
|
||||
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/relay director resolve failed \(503\)/i.test(evidence)) {
|
||||
const retryMs = parseRetryDelayMs(evidence)
|
||||
return {
|
||||
likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`,
|
||||
nextStep: 'Keep Orca open; recovery should retry automatically.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) {
|
||||
const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay'
|
||||
const structuredDirectLiveness =
|
||||
failure?.code === 'liveness-timeout' &&
|
||||
(failure.path === 'lan' || failure.path === 'tailscale')
|
||||
const path =
|
||||
relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay')
|
||||
? 'Relay'
|
||||
: 'The connected host'
|
||||
return {
|
||||
likelyCause: `${path} stopped answering authenticated health checks.`,
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: relayLiveness ? 'orca-relay' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/relay-session-failed|active relay session failed/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The active Relay session closed unexpectedly.',
|
||||
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
|
||||
reportability:
|
||||
failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The desktop rejected this device during authentication.',
|
||||
nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/connect-timeout|websocket connect timeout/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: isTailscaleEndpoint(args.endpoint)
|
||||
? 'The saved Tailscale endpoint did not answer before the connection timeout.'
|
||||
: 'The saved direct endpoint did not answer before the connection timeout.',
|
||||
nextStep:
|
||||
args.pendingPath === 'relay'
|
||||
? 'Relay recovery is in progress; keep Orca open while it retries.'
|
||||
: 'Check the local/VPN network and confirm the desktop is awake.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/handshake-timeout|handshake timeout/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.',
|
||||
nextStep: 'Confirm the desktop is running a compatible Orca version and retry.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (args.pendingPath === 'relay') {
|
||||
return {
|
||||
likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.',
|
||||
nextStep: 'Keep this page open while the next recovery event is recorded.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
likelyCause: 'No single failure cause can be determined from the recorded events.',
|
||||
nextStep: 'Run diagnostics and copy the report again after the next connection attempt.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null {
|
||||
if (diagnoseConnection(args).reportability !== 'orca-relay') {
|
||||
return null
|
||||
}
|
||||
return findCurrentDiagnosticFailure(args.entries)?.id ?? null
|
||||
}
|
||||
|
||||
function findCurrentDiagnosticFailure(
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
): ConnectionLogEntry | undefined {
|
||||
const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary)
|
||||
return entries
|
||||
.slice(boundaryIndex + 1)
|
||||
.toReversed()
|
||||
.find(isDiagnosticFailure)
|
||||
}
|
||||
|
||||
function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean {
|
||||
return (
|
||||
entry.code === 'client-session-started' ||
|
||||
entry.code === 'app-resumed' ||
|
||||
entry.code === 'network-changed' ||
|
||||
entry.code === 'relay-connected' ||
|
||||
entry.code === 'direct-connected' ||
|
||||
entry.message === 'Authenticated'
|
||||
)
|
||||
}
|
||||
|
||||
function isDiagnosticFailure(entry: ConnectionLogEntry): boolean {
|
||||
const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}`
|
||||
return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test(
|
||||
evidence
|
||||
)
|
||||
}
|
||||
|
||||
function parseRetryDelayMs(evidence: string): number | null {
|
||||
const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function formatDelay(ms: number): string {
|
||||
return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`
|
||||
}
|
||||
|
||||
function formatPath(path: MobileConnectionDiagnosticPath): string {
|
||||
if (path === 'relay') {
|
||||
return 'Relay'
|
||||
}
|
||||
return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct'
|
||||
}
|
||||
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: NOW - 5 * 60_000,
|
||||
platform: 'ios 26.5.1',
|
||||
appVersion: '0.0.29',
|
||||
desktopAppVersion: '1.4.191',
|
||||
entries: [
|
||||
{
|
||||
id: 'log-1',
|
||||
@@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
})
|
||||
|
||||
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
|
||||
expect(report).toContain('Host Orca version: 1.4.191')
|
||||
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
|
||||
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
|
||||
expect(report).toContain('(5m 0s ago)')
|
||||
@@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
})
|
||||
|
||||
expect(report).toContain('Endpoint: 192.168.1.50:6768')
|
||||
expect(report).toContain('Host Orca version: unknown')
|
||||
expect(report).not.toContain('(Tailscale)')
|
||||
expect(report).toContain('Last connected: never this session')
|
||||
expect(report).toContain('No connection events recorded this session.')
|
||||
expect(report).toContain('No connection events recorded.')
|
||||
})
|
||||
|
||||
it('explains the most likely cause and redacts credentials before copying', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 3',
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 4,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.46',
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
id: 'relay-failure',
|
||||
ts: NOW - 5_000,
|
||||
level: 'error',
|
||||
message: 'Relay: relay dial failed',
|
||||
detail:
|
||||
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain(
|
||||
'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.'
|
||||
)
|
||||
expect(report).toContain('Path: active=tailscale; recovery=relay')
|
||||
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
|
||||
expect(report).toContain('resumeToken=[redacted]')
|
||||
expect(report).not.toContain('secret-resume-token')
|
||||
})
|
||||
|
||||
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 4',
|
||||
endpoint: 'not-a-url?token=endpoint-secret',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 1,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
entries: [
|
||||
{
|
||||
id: 'json-secret',
|
||||
ts: NOW,
|
||||
level: 'error',
|
||||
message: 'Relay failed',
|
||||
detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}'
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain('Endpoint: invalid endpoint')
|
||||
expect(report).not.toContain('endpoint-secret')
|
||||
expect(report).not.toContain('json-secret')
|
||||
expect(report).not.toContain('bearer-secret')
|
||||
})
|
||||
|
||||
it('bounds a single event line before submission while preserving its identity', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 5',
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 1,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
entries: [
|
||||
{
|
||||
id: 'oversized',
|
||||
ts: NOW,
|
||||
level: 'error',
|
||||
message: `newest oversized ${'😀'.repeat(2_000)}`
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain('newest oversized')
|
||||
expect(report).toContain('[truncated]')
|
||||
expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual(
|
||||
2048
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionState,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from '../transport/types'
|
||||
import { normalizeHostAppVersion } from '../transport/host-app-version-store'
|
||||
import { formatEndpoint } from './host-reachability'
|
||||
import { diagnoseConnection } from './connection-diagnostics-analysis'
|
||||
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
|
||||
|
||||
const MAX_EVENT_LINE_BYTES = 2 * 1024
|
||||
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
|
||||
|
||||
// Why: one shareable text blob answering everything we historically had to
|
||||
// ask reporters one message at a time (endpoint type, state, attempt count,
|
||||
@@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: {
|
||||
lastConnectedAt: number | null
|
||||
platform: string
|
||||
appVersion: string
|
||||
desktopAppVersion?: string | null
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
nowMs?: number
|
||||
}): string {
|
||||
const now = args.nowMs ?? Date.now()
|
||||
const entries = args.entries.map(redactConnectionLogEntry)
|
||||
const diagnosis = diagnoseConnection({
|
||||
endpoint: args.endpoint,
|
||||
state: args.state,
|
||||
activePath: args.activePath,
|
||||
pendingPath: args.pendingPath,
|
||||
entries
|
||||
})
|
||||
const lines: string[] = []
|
||||
lines.push('Orca Mobile connection diagnostics')
|
||||
lines.push(`Generated: ${new Date(now).toISOString()}`)
|
||||
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
|
||||
lines.push(`Host: ${args.hostName}`)
|
||||
const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
|
||||
lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
|
||||
lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
|
||||
lines.push(
|
||||
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
|
||||
)
|
||||
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
|
||||
if (args.activePath) {
|
||||
lines.push(
|
||||
`Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}`
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
args.lastConnectedAt == null
|
||||
? 'Last connected: never this session'
|
||||
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
|
||||
)
|
||||
lines.push('')
|
||||
if (args.entries.length === 0) {
|
||||
lines.push('No connection events recorded this session.')
|
||||
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
|
||||
lines.push(`Next step: ${diagnosis.nextStep}`)
|
||||
lines.push('')
|
||||
if (entries.length === 0) {
|
||||
lines.push('No connection events recorded.')
|
||||
} else {
|
||||
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
|
||||
for (const entry of args.entries) {
|
||||
lines.push(`Recent connection history (${entries.length} events, oldest first):`)
|
||||
for (const entry of entries) {
|
||||
const detail = entry.detail ? ` — ${entry.detail}` : ''
|
||||
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
|
||||
const evidence = [entry.code, entry.path].filter(Boolean).join(' · ')
|
||||
lines.push(
|
||||
truncateUtf8WithMarker(
|
||||
`${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`,
|
||||
MAX_EVENT_LINE_BYTES,
|
||||
EVENT_TRUNCATION_MARKER
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string {
|
||||
if (new TextEncoder().encode(value).byteLength <= maxBytes) {
|
||||
return value
|
||||
}
|
||||
const markerBytes = new TextEncoder().encode(marker).byteLength
|
||||
const characters: string[] = []
|
||||
let bytes = 0
|
||||
for (const character of value) {
|
||||
const characterBytes = new TextEncoder().encode(character).byteLength
|
||||
if (bytes + characterBytes + markerBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
characters.push(character)
|
||||
bytes += characterBytes
|
||||
}
|
||||
return `${characters.join('')}${marker}`
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const seconds = Math.max(0, Math.round(ms / 1000))
|
||||
if (seconds < 60) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getDiagnosticsSubmissionState,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
readHydratedConnectionLog,
|
||||
resolveDiagnosticsHostId,
|
||||
selectDiagnosticsHostId,
|
||||
updateDiagnosticsSubmissionState
|
||||
} from './connection-diagnostics-screen-data'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
|
||||
|
||||
const host = (id: string): HostProfile => ({
|
||||
id,
|
||||
name: id,
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
lastConnected: 0
|
||||
})
|
||||
|
||||
describe('connection diagnostics screen data', () => {
|
||||
it('prefers a valid route host when navigation changes', () => {
|
||||
expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b')
|
||||
expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a')
|
||||
})
|
||||
|
||||
it('applies route changes synchronously without discarding a chip choice for the same route', () => {
|
||||
const hosts = [host('a'), host('b')]
|
||||
expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a')
|
||||
})
|
||||
|
||||
it('does not revive a stale manual choice when routing away and back', () => {
|
||||
const hosts = [host('a'), host('b'), host('c')]
|
||||
const firstRouteA = {}
|
||||
const routeC = {}
|
||||
const secondRouteA = {}
|
||||
const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA }
|
||||
|
||||
expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps async submission completion scoped to its host incident', () => {
|
||||
let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending')
|
||||
states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending')
|
||||
states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent')
|
||||
|
||||
expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent')
|
||||
expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending')
|
||||
})
|
||||
|
||||
it('waits for hydration and then reads the refreshed log', async () => {
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
const store = {
|
||||
hydrate: vi.fn(async () => {
|
||||
entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' })
|
||||
}),
|
||||
get: vi.fn(() => entries)
|
||||
}
|
||||
|
||||
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
|
||||
expect(store.get).toHaveBeenCalledAfter(store.hydrate)
|
||||
})
|
||||
|
||||
it('retries one transient hydration failure before reading', async () => {
|
||||
const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }]
|
||||
const store = {
|
||||
hydrate: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockResolvedValueOnce(undefined),
|
||||
get: vi.fn(() => entries)
|
||||
}
|
||||
|
||||
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
|
||||
expect(store.hydrate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reads connection metadata after hydration completes', async () => {
|
||||
let state: 'connecting' | 'reconnecting' = 'connecting'
|
||||
const store = {
|
||||
hydrate: vi.fn(async () => {
|
||||
state = 'reconnecting'
|
||||
}),
|
||||
get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }])
|
||||
}
|
||||
const context = {
|
||||
getState: vi.fn(() => state),
|
||||
getReconnectAttempt: vi.fn(() => 2),
|
||||
getLastConnectedAt: vi.fn(() => 1),
|
||||
getActivePath: vi.fn(() => 'relay' as const),
|
||||
getPendingPath: vi.fn(() => null)
|
||||
}
|
||||
|
||||
await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 2,
|
||||
lastConnectedAt: 1,
|
||||
activePath: 'relay',
|
||||
pendingPath: null,
|
||||
entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }]
|
||||
})
|
||||
expect(context.getState).toHaveBeenCalledAfter(store.hydrate)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ConnectionLogStore } from '../transport/connection-log-buffer'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
|
||||
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
|
||||
|
||||
export type DiagnosticsHostSelection = {
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey?: object
|
||||
}
|
||||
|
||||
export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
|
||||
export type DiagnosticsSubmissionStates = Readonly<Record<string, DiagnosticsSubmissionState>>
|
||||
|
||||
export function selectDiagnosticsHostId(
|
||||
hosts: readonly HostProfile[],
|
||||
requestedHostId: string | undefined,
|
||||
previousHostId: string | null
|
||||
): string | null {
|
||||
if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) {
|
||||
return requestedHostId
|
||||
}
|
||||
if (previousHostId && hosts.some((host) => host.id === previousHostId)) {
|
||||
return previousHostId
|
||||
}
|
||||
return hosts[0]?.id ?? null
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsHostId(
|
||||
hosts: readonly HostProfile[],
|
||||
requestedHostId: string | undefined,
|
||||
manualSelection: DiagnosticsHostSelection | null,
|
||||
routeKey?: object
|
||||
): string | null {
|
||||
const selected = manualSelection
|
||||
if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
|
||||
const manualHostId = selected.hostId
|
||||
if (hosts.some((host) => host.id === manualHostId)) {
|
||||
return manualHostId
|
||||
}
|
||||
}
|
||||
return selectDiagnosticsHostId(hosts, requestedHostId, null)
|
||||
}
|
||||
|
||||
export function getDiagnosticsSubmissionState(
|
||||
states: DiagnosticsSubmissionStates,
|
||||
key: string | null
|
||||
): DiagnosticsSubmissionState | 'idle' {
|
||||
return key ? (states[key] ?? 'idle') : 'idle'
|
||||
}
|
||||
|
||||
export function updateDiagnosticsSubmissionState(
|
||||
states: DiagnosticsSubmissionStates,
|
||||
key: string,
|
||||
state: DiagnosticsSubmissionState | null
|
||||
): DiagnosticsSubmissionStates {
|
||||
const next = { ...states }
|
||||
if (state) {
|
||||
next[key] = state
|
||||
} else {
|
||||
delete next[key]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export async function readHydratedConnectionLog(
|
||||
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
|
||||
hostId: string
|
||||
): Promise<readonly ConnectionLogEntry[]> {
|
||||
try {
|
||||
await store.hydrate(hostId)
|
||||
} catch {
|
||||
await store.hydrate(hostId).catch(() => {})
|
||||
}
|
||||
return store.get(hostId)
|
||||
}
|
||||
|
||||
export async function readConnectionDiagnosticsSnapshot(
|
||||
context: Pick<
|
||||
RpcClientContextValue,
|
||||
'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath'
|
||||
>,
|
||||
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
|
||||
hostId: string
|
||||
) {
|
||||
const entries = await readHydratedConnectionLog(store, hostId)
|
||||
return {
|
||||
state: context.getState(hostId),
|
||||
reconnectAttempts: context.getReconnectAttempt(hostId),
|
||||
lastConnectedAt: context.getLastConnectedAt(hostId),
|
||||
activePath: context.getActivePath(hostId),
|
||||
pendingPath: context.getPendingPath(hostId),
|
||||
entries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
boundConnectionDiagnosticsReport,
|
||||
submitConnectionDiagnostics
|
||||
} from './connection-diagnostics-submission'
|
||||
|
||||
describe('submitConnectionDiagnostics', () => {
|
||||
it('sends a bounded report through the diagnostics lane', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
const result = await submitConnectionDiagnostics(
|
||||
{ report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' },
|
||||
fetchImpl
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://www.onorca.dev/v1/feedback',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
const request = fetchImpl.mock.calls[0]?.[1]
|
||||
const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string }
|
||||
expect(body.submissionType).toBe('connection_diagnostics')
|
||||
expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024)
|
||||
})
|
||||
|
||||
it('returns a safe failure for a rejected response', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }))
|
||||
await expect(
|
||||
submitConnectionDiagnostics(
|
||||
{ report: 'report', appVersion: '0.0.47', platform: 'android' },
|
||||
fetchImpl
|
||||
)
|
||||
).resolves.toEqual({ ok: false, error: 'status 503' })
|
||||
})
|
||||
|
||||
it('preserves the newest complete events when bounding a UTF-8 report', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (3 events, oldest first):',
|
||||
`oldest ${'😀'.repeat(20)}`,
|
||||
`middle ${'😀'.repeat(20)}`,
|
||||
`newest ${'😀'.repeat(20)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 180)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
|
||||
expect(bounded).toContain('newest')
|
||||
expect(bounded).not.toContain('oldest')
|
||||
expect(bounded).toMatch(/older omitted/)
|
||||
})
|
||||
|
||||
it('retains a bounded form of the newest event when that event exceeds the budget', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (2 events, oldest first):',
|
||||
'old event',
|
||||
`newest oversized ${'😀'.repeat(1_000)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 220)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
|
||||
expect(bounded).toContain('newest oversized')
|
||||
expect(bounded).toContain('[truncated]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback'
|
||||
const SUBMISSION_TIMEOUT_MS = 10_000
|
||||
const MAX_SUBMISSION_BYTES = 64 * 1024
|
||||
|
||||
export type ConnectionDiagnosticsSubmission = {
|
||||
report: string
|
||||
appVersion: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
/** Sends only the already-redacted, bounded report after an explicit user tap. */
|
||||
export async function submitConnectionDiagnostics(
|
||||
submission: ConnectionDiagnosticsSubmission,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ConnectionDiagnosticsSubmissionResult> {
|
||||
const report = boundConnectionDiagnosticsReport(submission.report)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
feedback: report,
|
||||
submissionType: 'connection_diagnostics',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
appVersion: submission.appVersion,
|
||||
platform: `mobile-${submission.platform}`,
|
||||
osRelease: 'unknown',
|
||||
arch: 'unknown'
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: controller.signal.aborted
|
||||
? 'request timed out'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'request failed'
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function boundConnectionDiagnosticsReport(
|
||||
report: string,
|
||||
maxBytes: number = MAX_SUBMISSION_BYTES
|
||||
): string {
|
||||
if (utf8Bytes(report) <= maxBytes) {
|
||||
return report
|
||||
}
|
||||
const lines = report.split('\n')
|
||||
const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history ('))
|
||||
if (historyIndex === -1) {
|
||||
return truncateUtf8(report, maxBytes)
|
||||
}
|
||||
const header = lines.slice(0, historyIndex)
|
||||
const events = lines.slice(historyIndex + 1)
|
||||
let kept: string[] = []
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const candidateEvents = [events[index]!, ...kept]
|
||||
const candidate = formatBoundedReport(header, candidateEvents, events.length)
|
||||
if (utf8Bytes(candidate) > maxBytes) {
|
||||
break
|
||||
}
|
||||
kept = candidateEvents
|
||||
}
|
||||
if (kept.length === 0 && events.length > 0) {
|
||||
return formatReportWithTruncatedNewestEvent(header, events, maxBytes)
|
||||
}
|
||||
return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes)
|
||||
}
|
||||
|
||||
function formatReportWithTruncatedNewestEvent(
|
||||
header: string[],
|
||||
events: string[],
|
||||
maxBytes: number
|
||||
): string {
|
||||
const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):`
|
||||
const prefix = [...header, history].join('\n') + '\n'
|
||||
const availableBytes = maxBytes - utf8Bytes(prefix)
|
||||
if (availableBytes <= 0) {
|
||||
return truncateUtf8(prefix, maxBytes)
|
||||
}
|
||||
const marker = ' … [truncated]'
|
||||
const markerBytes = utf8Bytes(marker)
|
||||
if (availableBytes <= markerBytes) {
|
||||
return truncateUtf8(prefix, maxBytes)
|
||||
}
|
||||
return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}`
|
||||
}
|
||||
|
||||
function formatBoundedReport(header: string[], events: string[], totalEvents: number): string {
|
||||
const omitted = totalEvents - events.length
|
||||
return [
|
||||
...header,
|
||||
`Recent connection history (${events.length} newest events; ${omitted} older omitted):`,
|
||||
...events
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const characters: string[] = []
|
||||
let bytes = 0
|
||||
for (const character of value) {
|
||||
const characterBytes = utf8Bytes(character)
|
||||
if (bytes + characterBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
characters.push(character)
|
||||
bytes += characterBytes
|
||||
}
|
||||
return characters.join('')
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
|
||||
const SECRET_JSON_ASSIGNMENT =
|
||||
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi
|
||||
const SECRET_QUOTED_VALUE_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi
|
||||
const SECRET_UNTERMINATED_JSON_ASSIGNMENT =
|
||||
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi
|
||||
const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi
|
||||
const SECRET_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi
|
||||
const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^&#\s]+/gi
|
||||
const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi
|
||||
|
||||
export function redactConnectionLogText(value: string): string {
|
||||
return value
|
||||
.replace(
|
||||
SECRET_JSON_ASSIGNMENT,
|
||||
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
|
||||
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}`
|
||||
)
|
||||
.replace(
|
||||
SECRET_QUOTED_VALUE_ASSIGNMENT,
|
||||
(_match, label: string, separator: string, valueQuote: string) =>
|
||||
`${label}${separator}${valueQuote}[redacted]${valueQuote}`
|
||||
)
|
||||
.replace(
|
||||
SECRET_UNTERMINATED_JSON_ASSIGNMENT,
|
||||
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
|
||||
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]`
|
||||
)
|
||||
.replace(
|
||||
SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT,
|
||||
(_match, label: string, separator: string, valueQuote: string) =>
|
||||
`${label}${separator}${valueQuote}[redacted]`
|
||||
)
|
||||
.replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => {
|
||||
return `${label}${separator}[redacted]`
|
||||
})
|
||||
.replace(SECRET_QUERY, '$1[redacted]')
|
||||
.replace(URL_USERINFO, '$1[redacted]@')
|
||||
}
|
||||
|
||||
export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry {
|
||||
return {
|
||||
...entry,
|
||||
message: redactConnectionLogText(entry.message),
|
||||
...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
|
||||
describe('unreachableHostDetail', () => {
|
||||
it('points at Tailscale for tailnet CGNAT endpoints', () => {
|
||||
@@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => {
|
||||
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
|
||||
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
|
||||
})
|
||||
|
||||
it('does not echo malformed endpoints that may contain credentials', () => {
|
||||
expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('testHostReachability', () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
|
||||
const url = new URL(endpoint)
|
||||
return url.host
|
||||
} catch {
|
||||
return endpoint
|
||||
return 'invalid endpoint'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ export function MobileHomeScreen() {
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: disconnectHostClient,
|
||||
onDiagnostics: (hostId) =>
|
||||
data.router.push({ pathname: '/connection-log', params: { hostId } }),
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
|
||||
import type { ConnectionState, HostProfile } from './transport/types'
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Activity: vi.fn(),
|
||||
Edit3: vi.fn(),
|
||||
PowerOff: vi.fn(),
|
||||
RefreshCw: vi.fn()
|
||||
@@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
|
||||
onDismiss: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onDiagnostics: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
}
|
||||
@@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
|
||||
}
|
||||
|
||||
describe('getHostListActionSheetActions', () => {
|
||||
// Why: both open a second drawer. Presenting one while this sheet's native
|
||||
// Why: these navigate or open a second drawer. Presenting while this sheet's native
|
||||
// Modal is still up freezes the whole screen on iOS — issue #8791.
|
||||
it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
|
||||
const { actions } = build()
|
||||
expect(actions.find((action) => action.label === label)).toMatchObject({
|
||||
closeBeforePress: true
|
||||
})
|
||||
})
|
||||
it.each(['Network diagnostics', 'Edit host', 'Remove'])(
|
||||
'defers %s until the action sheet has closed',
|
||||
(label) => {
|
||||
const { actions } = build()
|
||||
expect(actions.find((action) => action.label === label)).toMatchObject({
|
||||
closeBeforePress: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the in-place actions undeferred so they fire on tap', () => {
|
||||
const { actions, spies } = build()
|
||||
@@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => {
|
||||
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
|
||||
})
|
||||
|
||||
it('routes Network diagnostics with the selected host', () => {
|
||||
const { actions, spies } = build()
|
||||
actions.find((action) => action.label === 'Network diagnostics')?.onPress()
|
||||
expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id)
|
||||
})
|
||||
|
||||
it('offers Disconnect only while the socket is live', () => {
|
||||
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
|
||||
'Reconnect',
|
||||
'Disconnect',
|
||||
'Network diagnostics',
|
||||
'Edit host',
|
||||
'Remove'
|
||||
])
|
||||
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
|
||||
'Connect',
|
||||
'Network diagnostics',
|
||||
'Edit host',
|
||||
'Remove'
|
||||
])
|
||||
@@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => {
|
||||
onDismiss: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onDiagnostics: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
|
||||
import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
|
||||
import type { ActionSheetAction } from './components/ActionSheetModal'
|
||||
import type { ConnectionState, HostProfile } from './transport/types'
|
||||
|
||||
/** Builds the home-screen host long-press menu. Edit and Remove open a second
|
||||
* drawer, so both must defer until this sheet's native Modal has unmounted —
|
||||
/** Builds the home-screen host long-press menu. Navigation and second drawers
|
||||
* defer until this sheet's native Modal has unmounted —
|
||||
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
|
||||
export function getHostListActionSheetActions(args: {
|
||||
host: HostProfile | null
|
||||
@@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: {
|
||||
onDismiss: () => void
|
||||
onReconnect: (hostId: string) => void
|
||||
onDisconnect: (hostId: string) => void
|
||||
onDiagnostics: (hostId: string) => void
|
||||
onEdit: (hostId: string) => void
|
||||
onRemove: (host: HostProfile) => void
|
||||
}): ActionSheetAction[] {
|
||||
@@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: {
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Network diagnostics',
|
||||
icon: Activity,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
args.onDiagnostics(host.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Edit host',
|
||||
icon: Edit3,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const hostScreenPrimaryStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topChrome: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 34,
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sidebarCollapseButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
marginRight: spacing.md
|
||||
},
|
||||
hostNameText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
reconnectButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbar: {
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
embeddedFilterChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
embeddedModeButton: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterChipActive: {
|
||||
borderColor: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
modeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sortLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
toolbarIconDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const hostScreenSecondaryStyles = StyleSheet.create({
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sectionIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionRepoIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
sectionCount: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginLeft: spacing.lg + 24,
|
||||
marginRight: spacing.lg
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { hostScreenPrimaryStyles } from './host-screen-primary-styles'
|
||||
import { hostScreenSecondaryStyles } from './host-screen-secondary-styles'
|
||||
|
||||
export const hostScreenStyles = {
|
||||
...hostScreenPrimaryStyles,
|
||||
...hostScreenSecondaryStyles
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
export const pairScanStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
stepBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Why: holds the layout slot while the camera is unmounted during
|
||||
// paste, so the bottom action button doesn't snap up to fill the
|
||||
// empty space.
|
||||
cameraPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.camera
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
reticleFrame: {
|
||||
position: 'relative'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
subtitle: {
|
||||
maxWidth: 310,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
logSlot: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
pasteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
pasteButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
pasteButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
errorActions: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createBlankWorkspace } from './blank-workspace-create'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
|
||||
|
||||
type Call = { method: string; params: unknown }
|
||||
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
|
||||
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
|
||||
return {
|
||||
sendRequest: async (method: string, params?: unknown) => {
|
||||
@@ -35,7 +40,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' })
|
||||
@@ -70,7 +75,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: true,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true })
|
||||
@@ -90,7 +95,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: 'spike',
|
||||
setupDecision: 'run',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
const params = calls[0]?.params as Record<string, unknown>
|
||||
@@ -122,7 +127,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' })
|
||||
@@ -148,7 +153,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' })
|
||||
@@ -167,7 +172,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'skip',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ error: 'SSH connection is not available' })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
|
||||
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
|
||||
import {
|
||||
agentLaunchCreateFields,
|
||||
type WorkspaceCreateSetupDecision
|
||||
@@ -19,13 +20,13 @@ export async function createBlankWorkspace(args: {
|
||||
/** True when `baseName` is a generated creature name rather than one the user typed; only then
|
||||
* may the host retire it. */
|
||||
nameWasGenerated: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
return createWorktreeWithNameRetry({
|
||||
client: args.client,
|
||||
baseName: args.baseName,
|
||||
nameWasGenerated: args.nameWasGenerated,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (name) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${args.repoId}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createWorkspaceFromComposerSource } from './source-workspace-create'
|
||||
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
|
||||
|
||||
type Call = { method: string; params: Record<string, unknown> }
|
||||
|
||||
@@ -24,6 +25,9 @@ function fakeClient(handle: (method: string, call: number) => unknown, calls: Ca
|
||||
}
|
||||
|
||||
const agent = { choice: 'blank' as const }
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
targetRepoId: 'repo-1',
|
||||
@@ -31,7 +35,7 @@ const baseArgs = {
|
||||
agent,
|
||||
workspaceName: undefined,
|
||||
note: undefined,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
}
|
||||
|
||||
describe('createWorkspaceFromComposerSource', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WorkspaceCreateTaskItem
|
||||
} from './workspace-create-params'
|
||||
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
|
||||
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
|
||||
|
||||
// The agent bundle the modal resolved: `choice` drives launch resolution — the
|
||||
// host applies the agent's launch args (permission flags) and shell quoting.
|
||||
@@ -29,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = {
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromComposerSource(
|
||||
@@ -92,7 +93,7 @@ async function createWorkItemWorkspace(args: {
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
const item = selection.item
|
||||
@@ -137,7 +138,7 @@ async function createWorkItemWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (name) => ({ ...params, name })
|
||||
})
|
||||
}
|
||||
@@ -150,7 +151,7 @@ async function createBranchWorkspace(args: {
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
|
||||
@@ -174,7 +175,7 @@ async function createBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
maxAttempts: 1,
|
||||
buildParams: (name) =>
|
||||
applyCommon({
|
||||
@@ -196,7 +197,7 @@ async function createBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (candidate) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
@@ -220,7 +221,7 @@ async function createNewBranchWorkspace(args: {
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, note } = args
|
||||
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
|
||||
@@ -232,7 +233,7 @@ async function createNewBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: selection.branchName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (candidate) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
|
||||
@@ -2,8 +2,21 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
|
||||
import {
|
||||
WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient {
|
||||
type StatusOutcome =
|
||||
| 'cutover'
|
||||
| 'error'
|
||||
| string[]
|
||||
| {
|
||||
capabilities?: string[]
|
||||
worktreeCreateIdempotency?: unknown
|
||||
}
|
||||
|
||||
function statusClient(outcomes: StatusOutcome[]): RpcClient {
|
||||
let call = 0
|
||||
return {
|
||||
sendRequest: async () => {
|
||||
@@ -15,10 +28,13 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien
|
||||
if (outcome === 'error') {
|
||||
throw new Error('offline')
|
||||
}
|
||||
const result = Array.isArray(outcome)
|
||||
? { capabilities: outcome, hostPlatform: 'darwin' }
|
||||
: { ...outcome, hostPlatform: 'darwin' }
|
||||
return {
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { capabilities: outcome, hostPlatform: 'darwin' },
|
||||
result,
|
||||
_meta: { runtimeId: 'r' }
|
||||
}
|
||||
}
|
||||
@@ -29,23 +45,123 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
|
||||
it('reads task and idempotent-create support from status.get', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([['mobile.tasks.v1', 'worktree.create-idempotency.v1']])
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: true,
|
||||
idempotentWorktreeCreateSupported: true,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries the safe status probe after a connection cutover', async () => {
|
||||
it('uses the bounded fallback for an old idempotent host without an advertisement', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']]))
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when a valid idempotency container omits dedupeTtlMs', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient(['cutover', ['worktree.create-idempotency.v1']])
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: {}
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: true,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'null', value: null },
|
||||
{ label: 'string', value: 'nonsense' },
|
||||
{ label: 'number', value: 20_000 },
|
||||
{ label: 'empty array', value: [] }
|
||||
])(
|
||||
'fails closed for a malformed idempotency container ($label)',
|
||||
async ({ value: worktreeCreateIdempotency }) => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('clamps an over-large host advertisement to the client fallback ceiling', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 600_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['60000', -1, Number.NaN, 60_000.5, {}])(
|
||||
'fails closed for malformed host advertisement %j',
|
||||
async (advertisedDedupeTtlMs) => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: advertisedDedupeTtlMs }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('retries the safe status probe after a connection cutover', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
'cutover',
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
@@ -53,7 +169,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
|
||||
it('fails closed when capability detection is unavailable', async () => {
|
||||
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
hostPlatform: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
|
||||
import {
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
|
||||
resolveWorktreeCreateIdempotencySupport,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
|
||||
// replay an ambiguous create unless the host advertises idempotency support.
|
||||
@@ -14,13 +19,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5
|
||||
|
||||
export type NewWorktreeRuntimeCapabilities = {
|
||||
tasksSupported: boolean
|
||||
idempotentWorktreeCreateSupported: boolean
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
hostPlatform: null
|
||||
}
|
||||
|
||||
@@ -35,13 +40,28 @@ export async function readNewWorktreeRuntimeCapabilities(
|
||||
if (!response.ok) {
|
||||
return UNSUPPORTED_CAPABILITIES
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { capabilities?: string[] }
|
||||
const result = (response as RpcSuccess).result as {
|
||||
capabilities?: string[]
|
||||
worktreeCreateIdempotency?: unknown
|
||||
}
|
||||
const capabilities = result.capabilities ?? []
|
||||
const supportsIdempotency = capabilities.includes(
|
||||
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
|
||||
)
|
||||
const advertisedIdempotency = result.worktreeCreateIdempotency
|
||||
return {
|
||||
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
|
||||
idempotentWorktreeCreateSupported: capabilities.includes(
|
||||
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
|
||||
),
|
||||
worktreeCreateIdempotency: supportsIdempotency
|
||||
? advertisedIdempotency === undefined
|
||||
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
|
||||
: advertisedIdempotency !== null &&
|
||||
typeof advertisedIdempotency === 'object' &&
|
||||
!Array.isArray(advertisedIdempotency)
|
||||
? resolveWorktreeCreateIdempotencySupport(
|
||||
(advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs
|
||||
)
|
||||
: { dedupeTtlMs: 0 }
|
||||
: false,
|
||||
hostPlatform: readMobileRuntimeHostPlatform(result)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -58,7 +78,7 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
): {
|
||||
tasksSupported: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
|
||||
} {
|
||||
const [tasksSupported, setTasksSupported] = useState(false)
|
||||
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
|
||||
@@ -97,7 +117,7 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
}, [client, enabled, getCapabilities, setTasksSupported])
|
||||
|
||||
const getWorktreeCreateCutoverSupport = useCallback(
|
||||
() => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
|
||||
() => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency),
|
||||
[getCapabilities]
|
||||
)
|
||||
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Why: old idempotent hosts omit policy, so preserve today's effective replay window.
|
||||
export const WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS = 60_000
|
||||
|
||||
// Why: a client must not trust a host advertisement beyond its tested ceiling.
|
||||
export const WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS = 60_000
|
||||
|
||||
// Why: the replay still has to reach the host before its dedupe record expires.
|
||||
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
|
||||
|
||||
export type WorktreeCreateIdempotencySupport = {
|
||||
dedupeTtlMs: number
|
||||
}
|
||||
|
||||
export type WorktreeCreateIdempotencyProbe =
|
||||
| WorktreeCreateIdempotencySupport
|
||||
| false
|
||||
| Promise<WorktreeCreateIdempotencySupport | false>
|
||||
|
||||
export function resolveWorktreeCreateIdempotencySupport(
|
||||
advertisedDedupeTtlMs: unknown
|
||||
): WorktreeCreateIdempotencySupport {
|
||||
if (advertisedDedupeTtlMs === undefined) {
|
||||
return { dedupeTtlMs: 0 }
|
||||
}
|
||||
if (
|
||||
typeof advertisedDedupeTtlMs !== 'number' ||
|
||||
!Number.isSafeInteger(advertisedDedupeTtlMs) ||
|
||||
advertisedDedupeTtlMs < 0
|
||||
) {
|
||||
// Truthy support preserves immediate cutover retries while disabling ambiguous replay.
|
||||
return { dedupeTtlMs: 0 }
|
||||
}
|
||||
return {
|
||||
dedupeTtlMs: Math.min(advertisedDedupeTtlMs, WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS)
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeCreateReplayWindowMs(support: WorktreeCreateIdempotencySupport): number {
|
||||
return Math.max(0, support.dedupeTtlMs - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS)
|
||||
}
|
||||
@@ -3,16 +3,24 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import {
|
||||
getWorktreeCreateReplayWindowMs,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
import {
|
||||
createWorktreeWithNameRetry,
|
||||
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS,
|
||||
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS
|
||||
} from './worktree-create-retry'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
|
||||
type Attempt = { method: string; params: Record<string, unknown> }
|
||||
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
const LEGACY_HOST_REPLAY_WINDOW_MS = getWorktreeCreateReplayWindowMs(IDEMPOTENT_CREATE_SUPPORT)
|
||||
|
||||
// Drives the transport state a replay has to wait on. Production couples the two:
|
||||
// a socket close rejects the pending frame AND drops the client off 'connected'.
|
||||
function connectionController(): {
|
||||
@@ -106,21 +114,21 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
it('waits for capability detection before sending a create', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const client = scriptedClient([{ id: 'wt-ready' }], attempts)
|
||||
let resolveSupport!: (supported: boolean) => void
|
||||
const support = new Promise<boolean>((resolve) => {
|
||||
let resolveSupport!: (supported: WorktreeCreateIdempotencySupport) => void
|
||||
const support = new Promise<WorktreeCreateIdempotencySupport>((resolve) => {
|
||||
resolveSupport = resolve
|
||||
})
|
||||
const pending = createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'puffin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: support,
|
||||
worktreeCreateIdempotency: support,
|
||||
mintMutationId: () => 'key-ready'
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(attempts).toHaveLength(0)
|
||||
resolveSupport(true)
|
||||
resolveSupport(IDEMPOTENT_CREATE_SUPPORT)
|
||||
|
||||
await expect(pending).resolves.toEqual({ worktreeId: 'wt-ready', name: 'puffin' })
|
||||
expect(attempts).toHaveLength(1)
|
||||
@@ -134,7 +142,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'otter',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-1'
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-1', name: 'otter' })
|
||||
@@ -152,7 +160,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'seal',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-mig'
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-2', name: 'seal' })
|
||||
@@ -172,7 +180,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'crab',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-x'
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
@@ -190,7 +198,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'eel',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-t'
|
||||
})
|
||||
).rejects.toThrow('Request timed out')
|
||||
@@ -208,7 +216,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'topic',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => `key-${(n += 1)}`
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-3', name: 'topic-2' })
|
||||
@@ -231,7 +239,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
baseName: 'nautilus-2',
|
||||
nameWasGenerated: true,
|
||||
buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }),
|
||||
supportsIdempotentCutoverRetry: false
|
||||
worktreeCreateIdempotency: false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' })
|
||||
@@ -246,7 +254,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'ray',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
mintMutationId: () => 'must-not-be-used'
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
@@ -273,7 +281,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-drop'
|
||||
})
|
||||
|
||||
@@ -301,7 +309,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
mintMutationId: () => 'must-not-be-used'
|
||||
})
|
||||
).rejects.toThrow('Connection interrupted')
|
||||
@@ -309,6 +317,32 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
expect(attempts[0]!.params.clientMutationId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables ambiguous replay for a malformed advertisement', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const connection = connectionController()
|
||||
const client = scriptedClient(
|
||||
[
|
||||
{
|
||||
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
|
||||
dropsConnection: true
|
||||
}
|
||||
],
|
||||
attempts,
|
||||
connection
|
||||
)
|
||||
await expect(
|
||||
createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
mintMutationId: () => 'key-malformed'
|
||||
})
|
||||
).rejects.toThrow('Connection interrupted')
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(attempts[0]!.params.clientMutationId).toBe('key-malformed')
|
||||
})
|
||||
|
||||
it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -331,21 +365,21 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'barnacle',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-budget'
|
||||
}).catch((error: unknown) => {
|
||||
settledAt = Date.now()
|
||||
throw error
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS)
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
|
||||
await settled
|
||||
// Initial attempt + 2 replays.
|
||||
expect(attempts).toHaveLength(3)
|
||||
// The budget is a count, but what keeps a replay reconciling instead of building
|
||||
// a second worktree is wall clock: every attempt has to land while the host still
|
||||
// holds the record, with the watchdog's detection latency already deducted.
|
||||
expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
expect(settledAt - startedAt).toBeLessThanOrEqual(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -373,7 +407,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'anemone',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-stuck'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
@@ -388,6 +422,41 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
// so vitest's default 5s real-time budget is reachable on a loaded runner.
|
||||
}, 30_000)
|
||||
|
||||
it('stops replaying when the host-advertised dedupe record has expired', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const attempts: Attempt[] = []
|
||||
const connection = connectionController()
|
||||
const client = scriptedClient(
|
||||
[
|
||||
{
|
||||
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
|
||||
dropsConnection: true,
|
||||
takesMs: 12_000,
|
||||
reconnectsAfterMs: 1_000
|
||||
},
|
||||
{ id: 'wt-duplicate' }
|
||||
],
|
||||
attempts,
|
||||
connection
|
||||
)
|
||||
|
||||
const pending = createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'mussel',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
|
||||
mintMutationId: () => 'key-short-ttl'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await settled
|
||||
expect(attempts).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -415,7 +484,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'cuttlefish',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-expired'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Request timed out')
|
||||
@@ -464,19 +533,19 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'nautilus',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-clamped'
|
||||
}).catch((error: unknown) => {
|
||||
settledAt = Date.now()
|
||||
throw error
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
await settled
|
||||
expect(attempts).toHaveLength(2)
|
||||
// The window is anchored at the FIRST ambiguity, so the second wait gets only the
|
||||
// remainder — it must not restart a full wait and carry the replay past the record.
|
||||
expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
expect(settledAt - startedAt).toBe(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -509,7 +578,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-stale'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
@@ -551,7 +620,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'kelp',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-long'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(250_000)
|
||||
@@ -606,7 +675,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-anchored'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection lost')
|
||||
@@ -638,7 +707,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-unmarked'
|
||||
})
|
||||
).rejects.toThrow('Socket closed before send')
|
||||
@@ -649,11 +718,10 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
// The window is measured from a lower bound on when the host could have resolved, so
|
||||
// it has to leave the record room for the replay to still be in flight. Widening it
|
||||
// to the whole TTL would let the last replay land on a record that just expired.
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0)
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS)
|
||||
// A single wait must not be able to consume the window on its own.
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan(
|
||||
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
)
|
||||
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeGreaterThan(0)
|
||||
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
|
||||
// A reconnect wait is clamped when a short advertisement leaves less time.
|
||||
const shortReplayWindowMs = getWorktreeCreateReplayWindowMs({ dedupeTtlMs: 20_000 })
|
||||
expect(Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, shortReplayWindowMs)).toBe(10_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
getGeneratedWorktreeCreateRetryCandidate,
|
||||
isRetryableWorktreeCreateConflict,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_MS
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
import {
|
||||
getWorktreeCreateReplayWindowMs,
|
||||
type WorktreeCreateIdempotencyProbe,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
// Why: server-side collision checks (branch already exists locally / on a remote
|
||||
// / already has PR #N) can fire even after a pre-flight basename dedupe —
|
||||
@@ -36,17 +40,6 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
|
||||
// finished the worktree. Replay on the same clientMutationId instead.
|
||||
const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2
|
||||
|
||||
// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS
|
||||
// after it resolves; a replay that lands later misses it and the host's suffix loop
|
||||
// builds a SECOND worktree — and for folder workspaces a second one with the SAME name
|
||||
// and no collision check at all. So a replay is bounded by how stale our knowledge of the
|
||||
// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while
|
||||
// the app is backgrounded, so any ceiling derived from timer intervals — a watchdog probe
|
||||
// budget, a request timeout — can be arbitrarily wrong across a background cycle, which is
|
||||
// exactly when a create sits ambiguous for minutes.
|
||||
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
|
||||
export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS =
|
||||
WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS
|
||||
// Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it.
|
||||
export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000
|
||||
|
||||
@@ -55,7 +48,7 @@ export type CreateWorktreeWithNameRetryArgs = {
|
||||
baseName: string
|
||||
nameWasGenerated?: boolean
|
||||
buildParams: (name: string) => Record<string, unknown>
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
maxAttempts?: number
|
||||
// Injected in tests; production mints a fresh idempotency key per candidate.
|
||||
mintMutationId?: () => string
|
||||
@@ -72,7 +65,7 @@ export async function createWorktreeWithNameRetry(
|
||||
const { client, baseName, buildParams } = args
|
||||
// Why: creating before status.get settles would silently disable safe replay
|
||||
// during the exact slow-network window this path is meant to recover from.
|
||||
const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry
|
||||
const worktreeCreateIdempotency = await args.worktreeCreateIdempotency
|
||||
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
|
||||
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
|
||||
let lastError: string | null = null
|
||||
@@ -84,14 +77,10 @@ export async function createWorktreeWithNameRetry(
|
||||
// Why: older hosts strip unknown fields, so only stamp and replay when the
|
||||
// host advertises idempotency. One key per candidate makes cutover retries
|
||||
// safe while a name-collision bump remains a genuinely new create.
|
||||
const params = supportsIdempotentCutoverRetry
|
||||
const params = worktreeCreateIdempotency
|
||||
? { ...candidateParams, clientMutationId: mintMutationId() }
|
||||
: candidateParams
|
||||
const response = await sendWorktreeCreateResilient(
|
||||
client,
|
||||
params,
|
||||
supportsIdempotentCutoverRetry
|
||||
)
|
||||
const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency)
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
||||
return { worktreeId: result.worktree.id, name: candidateName }
|
||||
@@ -112,7 +101,7 @@ export async function createWorktreeWithNameRetry(
|
||||
async function sendWorktreeCreateResilient(
|
||||
client: RpcClient,
|
||||
params: Record<string, unknown>,
|
||||
supportsIdempotentCutoverRetry: boolean
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
|
||||
): Promise<RpcResponse> {
|
||||
let migrationRetry = 0
|
||||
let ambiguousRetry = 0
|
||||
@@ -124,7 +113,7 @@ async function sendWorktreeCreateResilient(
|
||||
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
||||
})
|
||||
} catch (error) {
|
||||
if (!supportsIdempotentCutoverRetry) {
|
||||
if (!worktreeCreateIdempotency) {
|
||||
throw error
|
||||
}
|
||||
if (isLogicalClientCutoverError(error)) {
|
||||
@@ -152,7 +141,7 @@ async function sendWorktreeCreateResilient(
|
||||
}
|
||||
// Computed once: a later ambiguity reads a fresher lastInboundAt from the
|
||||
// replacement session, which would push the deadline past the record it respects.
|
||||
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt)
|
||||
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt, worktreeCreateIdempotency)
|
||||
const remainingWindowMs = replayDeadlineAt - Date.now()
|
||||
if (remainingWindowMs <= 0) {
|
||||
throw error
|
||||
@@ -179,10 +168,14 @@ async function sendWorktreeCreateResilient(
|
||||
// so it stays honest across a suspension in a way a timer budget cannot; the send is the
|
||||
// fallback, and a sound floor either way, because the host cannot resolve a create it has
|
||||
// not yet received.
|
||||
function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number {
|
||||
function resolveReplayDeadline(
|
||||
client: RpcClient,
|
||||
firstSentAt: number,
|
||||
support: WorktreeCreateIdempotencySupport
|
||||
): number {
|
||||
const lastInboundAt = client.getLastInboundAt?.() ?? null
|
||||
const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt
|
||||
return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
return anchor + getWorktreeCreateReplayWindowMs(support)
|
||||
}
|
||||
|
||||
function defaultWorktreeCreateMutationId(): string {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
SPECIAL_KEY_LABELS,
|
||||
TERMINAL_ACCESSORY_KEY_DEFINITIONS,
|
||||
TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
|
||||
} from './terminal-key-definitions'
|
||||
|
||||
export type TerminalAccessoryKey = {
|
||||
id: string
|
||||
label: string
|
||||
@@ -75,66 +81,6 @@ const CTRL_PRINTABLE_BYTES: Record<string, string> = {
|
||||
'?': '\x7f'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
escape: 'Esc',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: '⌫',
|
||||
delete: 'Del',
|
||||
insert: 'Ins',
|
||||
arrowUp: '↑',
|
||||
arrowDown: '↓',
|
||||
arrowLeft: '←',
|
||||
arrowRight: '→',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'PgUp',
|
||||
pageDown: 'PgDn',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
|
||||
escape: 'Escape',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Forward delete',
|
||||
insert: 'Insert',
|
||||
arrowUp: 'Arrow up',
|
||||
arrowDown: 'Arrow down',
|
||||
arrowLeft: 'Arrow left',
|
||||
arrowRight: 'Arrow right',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'Page up',
|
||||
pageDown: 'Page down',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const CSI_FINAL_SPECIAL_KEYS: Record<string, string> = {
|
||||
arrowUp: 'A',
|
||||
arrowDown: 'B',
|
||||
@@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record<string, number> = {
|
||||
f12: 24
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
|
||||
'escape',
|
||||
'tab',
|
||||
'enter',
|
||||
'backspace',
|
||||
'delete',
|
||||
'insert',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight',
|
||||
'home',
|
||||
'end',
|
||||
'pageUp',
|
||||
'pageDown',
|
||||
'space',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12'
|
||||
].map((id) => ({
|
||||
id,
|
||||
label: SPECIAL_KEY_LABELS[id]!,
|
||||
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
|
||||
}))
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
|
||||
|
||||
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
|
||||
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
|
||||
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
|
||||
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
|
||||
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
|
||||
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
|
||||
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Del',
|
||||
bytes: '\x1b[3~',
|
||||
accessibilityLabel: 'Forward delete',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
|
||||
{
|
||||
id: 'arrowDown',
|
||||
label: '↓',
|
||||
bytes: '\x1b[B',
|
||||
accessibilityLabel: 'Arrow Down',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowLeft',
|
||||
label: '←',
|
||||
bytes: '\x1b[D',
|
||||
accessibilityLabel: 'Arrow Left',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowRight',
|
||||
label: '→',
|
||||
bytes: '\x1b[C',
|
||||
accessibilityLabel: 'Arrow Right',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
|
||||
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
|
||||
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
|
||||
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
|
||||
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
]
|
||||
export const TERMINAL_ACCESSORY_KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS
|
||||
|
||||
export function buildTerminalShortcutKey(
|
||||
binding: TerminalShortcutBinding
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createTerminalAccessoryRepeatController,
|
||||
createTerminalAccessoryRepeatSender,
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
|
||||
} from './terminal-accessory-repeat'
|
||||
|
||||
function deferred() {
|
||||
let resolve: () => void = () => {}
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('terminal accessory repeat', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('keeps one send in flight and preserves repeat order under host latency', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return (sent.length === 1 ? first.promise : second.promise).then(() => true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
expect(sent).toEqual(['down'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500)
|
||||
expect(sent).toEqual(['down'])
|
||||
|
||||
first.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sent).toEqual(['down', 'down'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500)
|
||||
expect(sent).toEqual(['down', 'down'])
|
||||
|
||||
second.resolve()
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
expect(sent).toEqual(['down', 'down', 'down'])
|
||||
})
|
||||
|
||||
it('does not schedule another repeat after release while a send is pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred()
|
||||
const send = vi.fn(() => pending.promise.then(() => true))
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
repeat.stop()
|
||||
pending.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispatches distinct taps without waiting for prior acknowledgements', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = Array.from({ length: 5 }, () => deferred())
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return pending[sent.length - 1]!.promise.then(() => true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
for (let index = 0; index < pending.length; index += 1) {
|
||||
repeat.start(`tap-${index}`, send)
|
||||
repeat.stop()
|
||||
}
|
||||
|
||||
expect(sent).toEqual(['tap-0', 'tap-1', 'tap-2', 'tap-3', 'tap-4'])
|
||||
|
||||
pending.forEach(({ resolve }) => resolve())
|
||||
await vi.runAllTimersAsync()
|
||||
expect(send).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('cancels repeats without dropping a distinct tap already dispatched by the user', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = deferred()
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
repeat.stop()
|
||||
repeat.start('up', send)
|
||||
repeat.cancel()
|
||||
|
||||
first.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sent).toEqual(['down', 'up'])
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops repeating when the transport rejects a send', async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn(() => Promise.reject(new Error('disconnected')))
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops repeating when the send is dropped before reaching the transport', async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn(async () => false)
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops sending when the terminal active at press time is no longer active', async () => {
|
||||
let activeTerminal = 'terminal-a'
|
||||
const sendToTerminal = vi.fn(async () => true)
|
||||
const send = createTerminalAccessoryRepeatSender(
|
||||
activeTerminal,
|
||||
(targetHandle) => activeTerminal === targetHandle,
|
||||
sendToTerminal
|
||||
)
|
||||
|
||||
await expect(send('down')).resolves.toBe(true)
|
||||
activeTerminal = 'terminal-b'
|
||||
await expect(send('down')).resolves.toBe(false)
|
||||
|
||||
expect(sendToTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function))
|
||||
})
|
||||
|
||||
it('provides a live target guard for async send preflight', async () => {
|
||||
let targetCurrent = true
|
||||
const preflight = deferred()
|
||||
const send = createTerminalAccessoryRepeatSender(
|
||||
'terminal-a',
|
||||
() => targetCurrent,
|
||||
async (_input, _targetHandle, isDeliveryTargetCurrent) => {
|
||||
await preflight.promise
|
||||
return isDeliveryTargetCurrent()
|
||||
}
|
||||
)
|
||||
|
||||
const result = send('down')
|
||||
targetCurrent = false
|
||||
preflight.resolve()
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('stops held repeats when the press-time connection is no longer current', async () => {
|
||||
vi.useFakeTimers()
|
||||
let connectionGeneration = 1
|
||||
const first = deferred()
|
||||
const sent: string[] = []
|
||||
const sendToTerminal = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
|
||||
})
|
||||
const createSender = () => {
|
||||
const pressedConnectionGeneration = connectionGeneration
|
||||
return createTerminalAccessoryRepeatSender(
|
||||
'terminal-a',
|
||||
() => connectionGeneration === pressedConnectionGeneration,
|
||||
sendToTerminal
|
||||
)
|
||||
}
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', createSender())
|
||||
|
||||
connectionGeneration = 2
|
||||
first.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sent).toEqual(['down'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400
|
||||
export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
|
||||
|
||||
type TerminalAccessoryRepeatSender<TInput> = (input: TInput) => Promise<boolean>
|
||||
type TerminalAccessoryRepeatSendToTerminal<TInput> = (
|
||||
input: TInput,
|
||||
targetHandle: string,
|
||||
isDeliveryTargetCurrent: () => boolean
|
||||
) => Promise<boolean>
|
||||
|
||||
export function createTerminalAccessoryRepeatSender<TInput>(
|
||||
targetHandle: string | null,
|
||||
isDeliveryTargetCurrent: (targetHandle: string) => boolean,
|
||||
sendToTerminal: TerminalAccessoryRepeatSendToTerminal<TInput>
|
||||
): TerminalAccessoryRepeatSender<TInput> {
|
||||
return (input) => {
|
||||
if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle))
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminalAccessoryRepeatController<TInput>() {
|
||||
let generation = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const stop = () => {
|
||||
generation += 1
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = stop
|
||||
|
||||
const start = (input: TInput, send: TerminalAccessoryRepeatSender<TInput>) => {
|
||||
stop()
|
||||
const activeGeneration = generation
|
||||
const pressedAt = Date.now()
|
||||
|
||||
const schedule = (delayMs: number) => {
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
if (generation !== activeGeneration) {
|
||||
return
|
||||
}
|
||||
void send(input).then(
|
||||
(sent) => {
|
||||
if (sent && generation === activeGeneration) {
|
||||
schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
void send(input).then(
|
||||
(sent) => {
|
||||
if (sent && generation === activeGeneration) {
|
||||
schedule(
|
||||
Math.min(
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt))
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
return { cancel, start, stop }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys'
|
||||
|
||||
export const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
escape: 'Esc',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: '⌫',
|
||||
delete: 'Del',
|
||||
insert: 'Ins',
|
||||
arrowUp: '↑',
|
||||
arrowDown: '↓',
|
||||
arrowLeft: '←',
|
||||
arrowRight: '→',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'PgUp',
|
||||
pageDown: 'PgDn',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
|
||||
escape: 'Escape',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Forward delete',
|
||||
insert: 'Insert',
|
||||
arrowUp: 'Arrow up',
|
||||
arrowDown: 'Arrow down',
|
||||
arrowLeft: 'Arrow left',
|
||||
arrowRight: 'Arrow right',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'Page up',
|
||||
pageDown: 'Page down',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS: TerminalShortcutSpecialKey[] = [
|
||||
'escape',
|
||||
'tab',
|
||||
'enter',
|
||||
'backspace',
|
||||
'delete',
|
||||
'insert',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight',
|
||||
'home',
|
||||
'end',
|
||||
'pageUp',
|
||||
'pageDown',
|
||||
'space',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12'
|
||||
].map((id) => ({
|
||||
id,
|
||||
label: SPECIAL_KEY_LABELS[id]!,
|
||||
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
|
||||
}))
|
||||
|
||||
export const TERMINAL_ACCESSORY_KEY_DEFINITIONS: TerminalAccessoryKey[] = [
|
||||
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
|
||||
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
|
||||
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
|
||||
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
|
||||
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
|
||||
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Del',
|
||||
bytes: '\x1b[3~',
|
||||
accessibilityLabel: 'Forward delete',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
|
||||
{
|
||||
id: 'arrowDown',
|
||||
label: '↓',
|
||||
bytes: '\x1b[B',
|
||||
accessibilityLabel: 'Arrow Down',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowLeft',
|
||||
label: '←',
|
||||
bytes: '\x1b[D',
|
||||
accessibilityLabel: 'Arrow Left',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowRight',
|
||||
label: '→',
|
||||
bytes: '\x1b[C',
|
||||
accessibilityLabel: 'Arrow Right',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
|
||||
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
|
||||
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
|
||||
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
|
||||
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
]
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
function captureClient(result: Promise<unknown> = Promise.resolve({ ok: true })) {
|
||||
function captureClient(
|
||||
result: Promise<unknown> = Promise.resolve({
|
||||
id: 'send',
|
||||
ok: true,
|
||||
result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
})
|
||||
) {
|
||||
const sendRequest = vi.fn(() => result)
|
||||
return { client: { sendRequest } as unknown as Pick<RpcClient, 'sendRequest'>, sendRequest }
|
||||
}
|
||||
@@ -20,7 +28,7 @@ describe('terminal live accessory raw send', () => {
|
||||
it('sends raw bytes now-or-never with the device presence tag', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
@@ -33,7 +41,9 @@ describe('terminal live accessory raw send', () => {
|
||||
it('drops the bytes instead of sending while disconnected', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -41,16 +51,28 @@ describe('terminal live accessory raw send', () => {
|
||||
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows a rejected send so accessory taps never surface transport errors', async () => {
|
||||
it('reports a rejected send without surfacing transport errors', async () => {
|
||||
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
|
||||
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
|
||||
).resolves.toBeUndefined()
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('reports a fulfilled RPC failure as a failed send', async () => {
|
||||
const response: RpcResponse = {
|
||||
id: 'send',
|
||||
ok: false,
|
||||
error: { code: 'terminal_error', message: 'failed' },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
}
|
||||
const { client } = captureClient(Promise.resolve(response))
|
||||
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
|
||||
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
|
||||
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
@@ -15,7 +16,7 @@ type TerminalLiveAccessoryRawSendArgs = {
|
||||
|
||||
export async function sendTerminalLiveAccessoryRawBytes(
|
||||
args: TerminalLiveAccessoryRawSendArgs
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
// Why: async IME flushing can outlive the original terminal selection.
|
||||
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle: args.targetHandle,
|
||||
@@ -23,9 +24,9 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
activeSessionTabType: args.activeSessionTabType
|
||||
})
|
||||
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
|
||||
return
|
||||
return false
|
||||
}
|
||||
await args.client
|
||||
return args.client
|
||||
.sendRequest(
|
||||
'terminal.send',
|
||||
buildTerminalSendParams({
|
||||
@@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
}),
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function createDeferredBoolean(): DeferredBoolean {
|
||||
}
|
||||
|
||||
type AccessoryInputCommitHarnessOptions = {
|
||||
readonly composing?: boolean
|
||||
readonly heldText?: string
|
||||
readonly sentText?: string
|
||||
readonly pendingHandle?: string | null
|
||||
@@ -46,6 +47,7 @@ type AccessoryInputCommitHarness = {
|
||||
}
|
||||
|
||||
function createAccessoryInputCommitHarness({
|
||||
composing,
|
||||
heldText = '',
|
||||
sentText = '',
|
||||
pendingHandle = null,
|
||||
@@ -55,6 +57,7 @@ function createAccessoryInputCommitHarness({
|
||||
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
|
||||
const activeHandle = 'terminal-a'
|
||||
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
|
||||
const liveInputComposingRef: RefObject<boolean | undefined> = { current: composing }
|
||||
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
|
||||
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
|
||||
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
||||
@@ -66,7 +69,9 @@ function createAccessoryInputCommitHarness({
|
||||
return sendResult
|
||||
}
|
||||
}
|
||||
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
|
||||
const applyLiveInputMirror = vi.fn(
|
||||
async (_handle: string, _fieldText: string, _composing?: boolean) => true
|
||||
)
|
||||
const clearPendingLiveInputCommit = vi.fn(() => {})
|
||||
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
|
||||
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
|
||||
@@ -82,6 +87,7 @@ function createAccessoryInputCommitHarness({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
@@ -165,6 +171,20 @@ describe('terminal live accessory input commit hook', () => {
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
|
||||
it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => {
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
pendingHandle: 'terminal-a',
|
||||
sendResult: false
|
||||
})
|
||||
|
||||
const result = await harness.commit({ bytes: '\x1b' })
|
||||
|
||||
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
|
||||
expect(harness.sent).toEqual(['\x1b'])
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
|
||||
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
|
||||
@@ -178,10 +198,11 @@ describe('terminal live accessory input commit hook', () => {
|
||||
expect(harness.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
|
||||
it('Given accessory backspace with reported composition When committed Then keeps the edited preedit held', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
composing: true,
|
||||
heldText: 'ni hao',
|
||||
sentText: '',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
@@ -190,14 +211,31 @@ describe('terminal live accessory input commit hook', () => {
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'ni ha', true)
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
expect(harness.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given accessory backspace with an unreported hold When committed Then preserves the Android fallback', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한글',
|
||||
sentText: '',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '한', undefined)
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
|
||||
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
composing: false,
|
||||
heldText: '',
|
||||
sentText: 'ab',
|
||||
pendingHandle: 'terminal-a'
|
||||
@@ -207,7 +245,7 @@ describe('terminal live accessory input commit hook', () => {
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false)
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,10 +21,15 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
|
||||
type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
|
||||
readonly applyLiveInputMirror: (
|
||||
handle: string,
|
||||
fieldText: string,
|
||||
composing?: boolean
|
||||
) => Promise<boolean>
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
readonly liveInputComposingRef: RefObject<boolean | undefined>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
@@ -40,6 +45,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
@@ -81,15 +87,21 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
// field is edited here and the mirror diff syncs the PTY echo.
|
||||
setLiveInputCapture(editedText)
|
||||
liveInputRef.current?.setNativeProps({ text: editedText })
|
||||
applyLiveInputMirror(activeHandle, editedText)
|
||||
return { kind: 'handled' }
|
||||
// Preserve undefined so Android's heuristic hold still settles on its timer.
|
||||
const sent = await applyLiveInputMirror(
|
||||
activeHandle,
|
||||
editedText,
|
||||
liveInputComposingRef.current
|
||||
)
|
||||
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
|
||||
}
|
||||
case 'commit-held-then-send':
|
||||
await sendTerminalLiveControlAfterPendingFlush(
|
||||
case 'commit-held-then-send': {
|
||||
const sent = await sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return { kind: 'handled' }
|
||||
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
|
||||
}
|
||||
default:
|
||||
decision satisfies never
|
||||
return { kind: 'handled' }
|
||||
@@ -101,6 +113,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
|
||||
@@ -161,6 +161,49 @@ describe('terminal live input commit hook', () => {
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
changeLiveInput(handlers, 'ni hao', true)
|
||||
|
||||
// When
|
||||
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2)
|
||||
changeLiveInput(handlers, '你好', false)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好']))
|
||||
})
|
||||
|
||||
it('Given an Android heuristic hold When accessory Backspace edits it Then the remaining text still settles', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
changeLiveInput(handlers, '한글')
|
||||
|
||||
// When
|
||||
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
changeLiveInput(handlers, 'a', false)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a']))
|
||||
|
||||
const result = await handlers.handleLiveInputAccessoryBytes({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace'
|
||||
})
|
||||
|
||||
expect(sent).toEqual(['a', '\x7f'])
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
|
||||
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -68,6 +68,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
waitForPendingLiveInputFlush
|
||||
@@ -132,7 +133,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
// that differs from the native field text, so the controlled capture must
|
||||
// echo the field verbatim; only the PTY mirror sees normalized text.
|
||||
setLiveInputCapture(nativeEvent.text)
|
||||
applyLiveInputMirror(
|
||||
void applyLiveInputMirror(
|
||||
activeHandle,
|
||||
normalizeTerminalTextInput(nativeEvent.text),
|
||||
nativeEvent.isComposing
|
||||
@@ -196,6 +197,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
|
||||
@@ -30,10 +30,15 @@ type RunTerminalLiveMirrorStep = (
|
||||
) => Promise<boolean>
|
||||
|
||||
type TerminalLivePendingInputFlush = {
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
|
||||
readonly applyLiveInputMirror: (
|
||||
handle: string,
|
||||
fieldText: string,
|
||||
composing?: boolean
|
||||
) => Promise<boolean>
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
readonly liveInputComposingRef: RefObject<boolean | undefined>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly sentLiveInputTextRef: RefObject<string>
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
@@ -50,6 +55,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
|
||||
const heldLiveInputTextRef = useRef('')
|
||||
const liveInputComposingRef = useRef<boolean | undefined>(undefined)
|
||||
const sentLiveInputTextRef = useRef('')
|
||||
const pendingLiveInputHandleRef = useRef<string | null>(null)
|
||||
const runMirrorStepRef = useRef<RunTerminalLiveMirrorStep>(async () => false)
|
||||
@@ -65,6 +71,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
clearHeldCommitTimer()
|
||||
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
|
||||
heldLiveInputTextRef.current = ''
|
||||
liveInputComposingRef.current = undefined
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
}, [clearHeldCommitTimer])
|
||||
@@ -106,6 +113,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
})
|
||||
sentLiveInputTextRef.current = step.nextSentText
|
||||
heldLiveInputTextRef.current = step.heldText
|
||||
liveInputComposingRef.current = composing
|
||||
pendingLiveInputHandleRef.current =
|
||||
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
|
||||
|
||||
@@ -149,9 +157,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
}, [runMirrorStep])
|
||||
|
||||
const applyLiveInputMirror = useCallback(
|
||||
(handle: string, fieldText: string, composing?: boolean): void => {
|
||||
void runMirrorStep(handle, fieldText, false, composing)
|
||||
},
|
||||
(handle: string, fieldText: string, composing?: boolean): Promise<boolean> =>
|
||||
runMirrorStep(handle, fieldText, false, composing),
|
||||
[runMirrorStep]
|
||||
)
|
||||
|
||||
@@ -187,6 +194,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
heldCommitTimerRef.current = null
|
||||
}
|
||||
heldLiveInputTextRef.current = ''
|
||||
liveInputComposingRef.current = undefined
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
|
||||
@@ -198,6 +206,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
waitForPendingLiveInputFlush
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user