mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
Merge origin/main (1bdbc23648) into settle-split part 2
Main's #28394 (chat scratch folders expire after 30 days unused) named the idle sweep's two dispatch checks, `hasOpenDispatch` and `providerHoldsDispatch`, so its new `runWhileExecutionIdle` veto can reuse them. That crossed this part's `store` reads handed to the same sweep: the sweep now gets this part's `store` plus main's named checks. The veto keeps main's own reads.
This commit is contained in:
@@ -54,7 +54,7 @@ runs:
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
NODE_VERSION: ${{ inputs.node-version }}
|
||||
NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }}
|
||||
NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }}
|
||||
run: |
|
||||
scope="${ImageOS:-$RUNNER_OS}"
|
||||
if [ -r /etc/os-release ]; then
|
||||
@@ -120,4 +120,9 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Windows ] && [ "$NATIVE_RUNTIME" = node ]; then
|
||||
node config/scripts/initialize-windows-native-toolchain.mjs
|
||||
else
|
||||
node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
|
||||
fi
|
||||
|
||||
@@ -8,6 +8,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
warm-linux-arm:
|
||||
if: github.event_name != 'pull_request'
|
||||
concurrency:
|
||||
group: ci-cache-warmup-linux-arm-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
@@ -54,6 +55,7 @@ jobs:
|
||||
run: node config/scripts/ensure-native-runtime.mjs --check-only
|
||||
|
||||
- name: Cache Windows CLI launcher
|
||||
if: runner.arch == 'X64'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
@@ -65,6 +67,7 @@ jobs:
|
||||
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'native/windows-agent-hook/**', 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/build-windows-agent-hook.mjs', 'config/scripts/windows-rust-executable-build.mjs', 'resources/build/icon.ico', 'package.json') }}
|
||||
|
||||
- name: Build the Windows native executables
|
||||
if: runner.arch == 'X64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||||
@@ -76,7 +79,14 @@ jobs:
|
||||
node config/scripts/build-windows-cli-launcher.mjs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Warm the Windows ARM server launcher
|
||||
if: runner.arch == 'ARM64'
|
||||
uses: ./.github/actions/prepare-windows-cli-launcher
|
||||
with:
|
||||
arches: arm64
|
||||
|
||||
warm-linux-package-fixtures:
|
||||
if: github.event_name != 'pull_request'
|
||||
concurrency:
|
||||
group: ci-cache-warmup-linux-package-fixtures-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/restore-pnpm-verification/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-windows-cli-launcher/**'
|
||||
- '.github/actions/prepare-git-compatibility/**'
|
||||
- '.github/actions/prepare-linux-package-fixture/**'
|
||||
- 'config/docker/headless-serve-shutdown/**'
|
||||
@@ -21,6 +22,8 @@ on:
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/initialize-windows-native-toolchain.mjs'
|
||||
- 'config/scripts/capture-windows-native-environment.cmd'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/node-pty-job-ownership.cjs'
|
||||
- 'config/scripts/windows-pe-machine.cjs'
|
||||
@@ -42,7 +45,6 @@ on:
|
||||
- 'resources/build/icon.ico'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/ci-cache-warmup.yml'
|
||||
- '.github/workflows/ci-cache-warmup-platforms.yml'
|
||||
- '.github/workflows/ci-cache-warmup-platform-jobs.yml'
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ on:
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/restore-pnpm-verification/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-windows-cli-launcher/**'
|
||||
- '.github/actions/prepare-git-compatibility/**'
|
||||
- '.github/actions/prepare-headless-compiler/**'
|
||||
- 'config/scripts/headless-detector-compiler-cache.mjs'
|
||||
@@ -30,6 +31,8 @@ on:
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/initialize-windows-native-toolchain.mjs'
|
||||
- 'config/scripts/capture-windows-native-environment.cmd'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/node-pty-job-ownership.cjs'
|
||||
- 'config/scripts/windows-pe-machine.cjs'
|
||||
@@ -47,8 +50,6 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/ci-cache-warmup.yml'
|
||||
- '.github/workflows/ci-cache-warmup-platform-jobs.yml'
|
||||
- '.github/workflows/ci-cache-warmup-platforms.yml'
|
||||
- '.github/actions/prepare-headless-compiler/**'
|
||||
- 'config/scripts/headless-detector-compiler-cache.mjs'
|
||||
- '.github/actions/prepare-wayland-clipboard/**'
|
||||
|
||||
@@ -62,12 +62,23 @@ on:
|
||||
description: Comma-separated cell ids from src/main/ssh/ssh-windows-host-cells.ts; empty runs the default set (orcad-cli-* cells run only when named).
|
||||
required: false
|
||||
default: ''
|
||||
preparation_mode:
|
||||
description: Compare inbox capability preparation overlapping builds or running after them.
|
||||
type: choice
|
||||
options: [overlap, serial]
|
||||
required: false
|
||||
default: overlap
|
||||
arm_inbox_only:
|
||||
description: Run only the native ARM inbox lane for a preparation timing comparison.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }}
|
||||
group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only && format('-{0}', inputs.preparation_mode || 'overlap') || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -77,21 +88,10 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-2022
|
||||
server: inbox
|
||||
- arch: x64
|
||||
runner: windows-2022
|
||||
server: preview
|
||||
archive: OpenSSH-Win64.zip
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
server: inbox
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
server: preview
|
||||
archive: OpenSSH-ARM64.zip
|
||||
include: >-
|
||||
${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only &&
|
||||
'[{"arch":"arm64","runner":"windows-11-arm","server":"inbox"}]' ||
|
||||
'[{"arch":"x64","runner":"windows-2022","server":"inbox"},{"arch":"x64","runner":"windows-2022","server":"preview","archive":"OpenSSH-Win64.zip"},{"arch":"arm64","runner":"windows-11-arm","server":"inbox"},{"arch":"arm64","runner":"windows-11-arm","server":"preview","archive":"OpenSSH-ARM64.zip"}]') }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# Installing the inbox capability alone can take several minutes on a fresh image; the CLI matrix
|
||||
# cells launch the e2e app several times each.
|
||||
@@ -99,6 +99,7 @@ jobs:
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
ORCA_ISOLATED_SSH_CI: '1'
|
||||
INBOX_PREPARATION_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.preparation_mode == 'serial' && 'serial' || 'overlap' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -127,11 +128,11 @@ jobs:
|
||||
background: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){
|
||||
if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64' -and $env:INBOX_PREPARATION_MODE -eq 'overlap'){
|
||||
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
|
||||
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
|
||||
. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1
|
||||
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json')
|
||||
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE
|
||||
}
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: dependencies
|
||||
@@ -164,6 +165,14 @@ jobs:
|
||||
pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}"
|
||||
node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}"
|
||||
- wait: inbox-capability
|
||||
- name: Prepare the Windows inbox SSH capability after the builds
|
||||
if: ${{ matrix.server == 'inbox' && matrix.arch == 'arm64' && env.INBOX_PREPARATION_MODE == 'serial' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
|
||||
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
|
||||
. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1
|
||||
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE
|
||||
- name: Run the Windows host cells against a private ${{ matrix.server }} sshd
|
||||
shell: pwsh
|
||||
timeout-minutes: ${{ contains(github.event.inputs.cells || '', 'orcad-cli') && 130 || 50 }}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Windows native preparation comparison
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Exact commit containing this benchmark and the Windows compiler candidate.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-native-preparation-comparison-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
|
||||
jobs:
|
||||
compare:
|
||||
name: Native preparation comparison (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
os: [windows-2022, windows-11-arm]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.source_sha }}
|
||||
persist-credentials: false
|
||||
- name: Record measurement start and validate source
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
run: |
|
||||
[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
mkdir -p .build/windows-native-benchmark
|
||||
node -e 'require("node:fs").writeFileSync(".build/windows-native-benchmark/job-start.json", JSON.stringify({startedAt:new Date().toISOString()}))'
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
native-runtime: none
|
||||
cache-pnpm-store: 'false'
|
||||
cache-pnpm-verification: 'false'
|
||||
- name: Compare isolated cold and same-build warm preparation
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
BENCHMARK_RUNNER: ${{ matrix.os }}
|
||||
run: node config/scripts/benchmark-windows-native-preparation.mjs
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: windows-native-preparation-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .build/windows-native-benchmark/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -168,7 +168,7 @@ try {
|
||||
$report.nativeInputs=$verified
|
||||
Write-Stage 'preview-native-input-verification-complete'
|
||||
} else {
|
||||
Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage}
|
||||
Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt)))
|
||||
$verified=@()
|
||||
foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){
|
||||
$path=Join-Path $sshDir $binary
|
||||
|
||||
@@ -38,22 +38,52 @@ try {
|
||||
function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}}
|
||||
function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}}
|
||||
function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}}
|
||||
function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}}
|
||||
function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}}
|
||||
function Assert-CapabilityCommand([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction,[string]$Operation){
|
||||
if(-not $Online -or $Name -ne 'OpenSSH.Server~~~~0.0.1.0' -or $ErrorAction -ne 'Stop' -or [IO.Path]::GetDirectoryName($LogPath) -ne $script:capabilityLogs -or [IO.Path]::GetFileName($LogPath) -notlike "inbox-capability-$Operation-*.log"){throw 'Capability command changed its online identity, error handling or receipt log boundary'}
|
||||
$script:capabilityLogPaths.Add($LogPath)
|
||||
[IO.File]::WriteAllText($LogPath,"$Operation fixture")
|
||||
}
|
||||
function Get-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){
|
||||
$script:capabilityQueries++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'query'
|
||||
if($script:failCapabilityQuery){Write-Error 'Injected capability query failure' -ErrorAction $ErrorAction}
|
||||
return @{State=$script:capabilityState}
|
||||
}
|
||||
function Add-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){
|
||||
$script:capabilityAdds++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'install'
|
||||
if($script:activeCapabilityReceipt){
|
||||
$script:installObservedReceipt=Get-Content -LiteralPath $script:activeCapabilityReceipt -Raw | ConvertFrom-Json
|
||||
if($script:installObservedReceipt.inboxCapabilityInitialState -ne 'NotPresent' -or $script:installObservedReceipt.inboxCapabilityOperations[0].status -ne 'passed' -or $script:installObservedReceipt.inboxCapabilityOperations[1].status -ne 'running'){throw 'Initial capability state was not durably recorded before installation'}
|
||||
}
|
||||
if($script:failCapabilityInstall){Write-Error 'Injected capability install failure' -ErrorAction $ErrorAction}
|
||||
return @{Path='C:\';Online=$true;RestartNeeded=$script:capabilityRestartNeeded}
|
||||
}
|
||||
function Reset-CapabilityControl([string]$State){
|
||||
$script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0
|
||||
$script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0
|
||||
$script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false
|
||||
$script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false;$script:failCapabilityQuery=$false
|
||||
$script:activeCapabilityReceipt=$null;$script:installObservedReceipt=$null;$script:capabilityRestartNeeded=$false
|
||||
$script:capabilityStages=[Collections.Generic.List[string]]::new()
|
||||
$script:capabilityLogPaths=[Collections.Generic.List[string]]::new()
|
||||
}
|
||||
$script:capabilityLogs=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
foreach($state in @('Installed','NotPresent')){
|
||||
Reset-CapabilityControl $state
|
||||
$capabilityReport=@{}
|
||||
Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)}
|
||||
Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs
|
||||
$expectedAdds=if($state -eq 'Installed'){0}else{1}
|
||||
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
|
||||
$expectedStages=@('inbox-capability-start','inbox-capability-query-start','inbox-capability-query-finished')
|
||||
if($expectedAdds){$expectedStages+=@('inbox-capability-install-start','inbox-capability-install-finished')}
|
||||
$expectedStages+=@('inbox-capability-guards-start','inbox-capability-complete')
|
||||
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityQueries -ne 1 -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne ($expectedStages -join ',')){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
|
||||
if($capabilityReport.inboxCapabilityOperations.Count -ne 1+$expectedAdds){throw 'Capability operations missing'}
|
||||
foreach($operation in $capabilityReport.inboxCapabilityOperations){
|
||||
if($operation.status -ne 'passed' -or $operation.elapsedMs -isnot [long] -or $operation.elapsedMs -lt 0 -or -not [IO.File]::Exists((Join-Path $script:capabilityLogs $operation.log))){throw 'Capability operation did not retain its timing, verdict or diagnostic log'}
|
||||
}
|
||||
if($expectedAdds -and ($capabilityReport.inboxCapabilityInstallResult.online -ne $true -or $capabilityReport.inboxCapabilityInstallResult.path -ne 'C:\' -or $capabilityReport.inboxCapabilityInstallResult.restartNeeded -ne $false)){throw 'Capability install result omitted'}
|
||||
if(-not $expectedAdds -and $capabilityReport.ContainsKey('inboxCapabilityInstallResult')){throw 'Installed capability invented an install result'}
|
||||
}
|
||||
foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){
|
||||
foreach($fault in @('host','global-before','shell-before','global-after','shell-after','query','install')){
|
||||
Reset-CapabilityControl 'NotPresent'
|
||||
switch($fault){
|
||||
'host' {$script:refuseCapabilityHost=$true}
|
||||
@@ -61,26 +91,45 @@ foreach($fault in @('host','global-before','shell-before','global-after','shell-
|
||||
'shell-before' {$script:refuseShellCheck=1}
|
||||
'global-after' {$script:refuseGlobalCheck=2}
|
||||
'shell-after' {$script:refuseShellCheck=2}
|
||||
'query' {$script:failCapabilityQuery=$true}
|
||||
'install' {$script:failCapabilityInstall=$true}
|
||||
}
|
||||
$rejected=$false
|
||||
try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true}
|
||||
$faultReport=@{}
|
||||
try {Install-WindowsInboxSshCapability 'x64' $faultReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs} catch {$rejected=$true}
|
||||
if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"}
|
||||
if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'}
|
||||
if($fault -eq 'query' -and $script:capabilityAdds){throw 'Failed query proceeded to installation'}
|
||||
if($fault -in @('query','install') -and ($faultReport.inboxCapabilityOperations[-1].status -ne 'failed' -or $faultReport.inboxCapabilityOperations[-1].elapsedMs -isnot [long])){throw 'Failed servicing operation lost its verdict or duration'}
|
||||
}
|
||||
Reset-CapabilityControl 'NotPresent'
|
||||
foreach($invocation in 1..2){Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs}
|
||||
if($script:capabilityLogPaths.Count -ne 4 -or ($script:capabilityLogPaths | Select-Object -Unique).Count -ne 4){throw 'Separate servicing operations or invocations reused a diagnostic log'}
|
||||
} finally {Remove-Item -LiteralPath $script:capabilityLogs -Recurse -Force -ErrorAction SilentlyContinue}
|
||||
$capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $capabilityRoot | Out-Null
|
||||
$capabilityReceipt=Join-Path $capabilityRoot 'capability.json'
|
||||
$script:capabilityLogs=$capabilityRoot
|
||||
Reset-CapabilityControl 'Installed'
|
||||
Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt
|
||||
$completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
|
||||
if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'}
|
||||
Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true
|
||||
if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed' -or $completed.preparationMode -ne 'overlap'){throw 'Capability success receipt missing its observed initial state or default mode'}
|
||||
Reset-CapabilityControl 'NotPresent';$script:capabilityLogs=$capabilityRoot;$script:activeCapabilityReceipt=$capabilityReceipt;$script:capabilityRestartNeeded=$true
|
||||
Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt 'serial'
|
||||
$installed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
|
||||
if($installed.status -ne 'passed' -or $installed.preparationMode -ne 'serial' -or $installed.inboxCapabilityInstallResult.restartNeeded -ne $true -or -not $script:installObservedReceipt){throw 'Serial preparation lost its mode, restart result or pre-install receipt'}
|
||||
Reset-CapabilityControl 'NotPresent';$script:activeCapabilityReceipt=$capabilityReceipt;$script:failCapabilityInstall=$true
|
||||
$rejected=$false
|
||||
try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true}
|
||||
$failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
|
||||
if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'}
|
||||
if($failed.inboxCapabilityOperations[-1].status -ne 'failed' -or -not $failed.stages.stage.Contains('inbox-capability-install-finished')){throw 'Failed preparation omitted its servicing result'}
|
||||
Reset-CapabilityControl 'NotPresent';$script:failCapabilityQuery=$true
|
||||
$rejected=$false
|
||||
try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true}
|
||||
$failedQuery=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
|
||||
if(-not $rejected -or $failedQuery.status -ne 'failed' -or $failedQuery.inboxCapabilityOperations[0].status -ne 'failed' -or $failedQuery.inboxCapabilityOperations.Count -ne 1 -or $script:capabilityAdds -or $failedQuery.inboxCapabilityInitialState){throw 'Failed query receipt invented a capability state or proceeded to installation'}
|
||||
} finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue}
|
||||
& {
|
||||
param($ProofAst)
|
||||
|
||||
@@ -33,26 +33,48 @@ function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[strin
|
||||
return $written
|
||||
}
|
||||
|
||||
function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) {
|
||||
function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage,[Parameter(Mandatory=$true)][string]$LogDirectory) {
|
||||
Assert-IsolatedWindowsSshCi $Arch
|
||||
Assert-WindowsSshGlobalServerDormant 'inbox'
|
||||
Assert-WindowsSshStockShell
|
||||
& $Stage 'inbox-capability-start'
|
||||
$capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0'
|
||||
$Report.inboxCapabilityInitialState=[string]$capability.State
|
||||
if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null}
|
||||
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
|
||||
$invocation=[Guid]::NewGuid().ToString('N')
|
||||
$query=@{operation='query';status='running';log="inbox-capability-query-$invocation.log"}
|
||||
$Report.inboxCapabilityOperations=@($query)
|
||||
& $Stage 'inbox-capability-query-start'
|
||||
$watch=[Diagnostics.Stopwatch]::StartNew()
|
||||
try {
|
||||
$capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $query.log) -ErrorAction Stop
|
||||
$Report.inboxCapabilityInitialState=[string]$capability.State
|
||||
$query.status='passed'
|
||||
} catch {$query.status='failed';$query.error=$_.Exception.Message;throw}
|
||||
finally {$watch.Stop();$query.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-query-finished'}
|
||||
if($capability.State -ne 'Installed'){
|
||||
$install=@{operation='install';status='running';log="inbox-capability-install-$invocation.log"}
|
||||
$Report.inboxCapabilityOperations+=@($install)
|
||||
& $Stage 'inbox-capability-install-start'
|
||||
$watch=[Diagnostics.Stopwatch]::StartNew()
|
||||
try {
|
||||
$result=Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $install.log) -ErrorAction Stop
|
||||
$Report.inboxCapabilityInstallResult=@{path=$result.Path;online=$result.Online;restartNeeded=$result.RestartNeeded}
|
||||
$install.status='passed'
|
||||
} catch {$install.status='failed';$install.error=$_.Exception.Message;throw}
|
||||
finally {$watch.Stop();$install.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-install-finished'}
|
||||
}
|
||||
& $Stage 'inbox-capability-guards-start'
|
||||
Assert-WindowsSshGlobalServerDormant 'inbox'
|
||||
Assert-WindowsSshStockShell
|
||||
& $Stage 'inbox-capability-complete'
|
||||
}
|
||||
|
||||
function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) {
|
||||
$report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'}
|
||||
function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt,[ValidateSet('overlap','serial')][string]$PreparationMode='overlap') {
|
||||
$report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;preparationMode=$PreparationMode;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'}
|
||||
try {
|
||||
if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'}
|
||||
Install-WindowsInboxSshCapability $Arch $report {param($name)
|
||||
if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'}
|
||||
}
|
||||
} ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt)))
|
||||
$report.status='passed'
|
||||
} catch {$report.status='failed';$report.error=$_.Exception.Message;throw}
|
||||
finally {
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
3 src/main/runtime/mobile-session-layout-projection.ts
|
||||
2 src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
|
||||
2 src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
|
||||
2 src/main/runtime/orca-runtime-attach-window.ts
|
||||
2 src/main/runtime/session-graph/session-graph-promotion.ts
|
||||
2 src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
|
||||
1 src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
|
||||
1 src/main/runtime/session-tabs/structured-session-tab-close.ts
|
||||
1 src/main/runtime/orca-runtime-get-runtime-id.ts
|
||||
2 src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
|
||||
1 src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
|
||||
1 src/main/runtime/session-tabs/terminal-surface-retirements.ts
|
||||
1 src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
|
||||
3 src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
|
||||
1 src/main/runtime/orca-runtime-terminal-drivers.ts
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
|
||||
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
|
||||
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
|
||||
import { hasWindowsCompilerSelection } from './initialize-windows-native-toolchain.mjs'
|
||||
import {
|
||||
compilerEnvironmentKey,
|
||||
environmentReceipt,
|
||||
hash
|
||||
} from './windows-native-benchmark-observer.mjs'
|
||||
|
||||
const addonNames = [
|
||||
'node-pty',
|
||||
'@orca/windows-registry',
|
||||
'@orca/windows-security',
|
||||
'@vscode/windows-process-tree'
|
||||
]
|
||||
const candidate = 'initialize-windows-native-toolchain.mjs'
|
||||
const control = 'ensure-native-runtime.mjs'
|
||||
const unchangedFiles = [
|
||||
`.github/actions/prepare-native-runtime/action.yml`,
|
||||
`config/scripts/${control}`,
|
||||
`config/scripts/${candidate}`,
|
||||
'config/scripts/capture-windows-native-environment.cmd',
|
||||
'config/scripts/windows-native-benchmark-observer.mjs',
|
||||
'config/scripts/benchmark-windows-native-preparation.mjs'
|
||||
]
|
||||
|
||||
export function validateObservedPreparation(receipt, variant, warm = false) {
|
||||
assert.equal(
|
||||
receipt.parentBefore.hash,
|
||||
receipt.parentAfter.hash,
|
||||
'Outer compiler environment changed'
|
||||
)
|
||||
const calls = (phase) => receipt.calls.filter((call) => call.phase === phase)
|
||||
const captured = variant !== 'control' && !warm
|
||||
assert.equal(calls('discovery').length, captured ? 1 : 0, 'Unexpected compiler discovery count')
|
||||
assert.equal(calls('capture').length, captured ? 1 : 0, 'Unexpected developer command count')
|
||||
assert.equal(
|
||||
calls('probe').length,
|
||||
variant === 'control' ? 0 : 1,
|
||||
'Missing helper preflight observation'
|
||||
)
|
||||
for (const call of receipt.calls) {
|
||||
assert(
|
||||
!call.error && !call.timedOut && !call.outputTruncated,
|
||||
'Incomplete compiler observation'
|
||||
)
|
||||
}
|
||||
if (variant === 'control') {
|
||||
assert.equal(calls('addon-build').length, warm ? 0 : 4, 'Expected all four cold addon builds')
|
||||
assert(
|
||||
receipt.calls.every((call) => call.code === 0),
|
||||
'Control preparation failed'
|
||||
)
|
||||
return
|
||||
}
|
||||
const [prepare] = calls('preparation')
|
||||
assert.equal(calls('preparation').length, 1, 'Missing unchanged preparation child')
|
||||
assert.equal(prepare.code, 0, 'Native preparation failed')
|
||||
assert.equal(calls('probe')[0].code, warm ? 0 : 1, 'Unexpected helper cold/warm probe')
|
||||
if (warm || variant === 'fallback') {
|
||||
assert.equal(
|
||||
prepare.environment.hash,
|
||||
receipt.parentBefore.hash,
|
||||
'Fallback/warm changed compiler selection'
|
||||
)
|
||||
assert.deepEqual(prepare.changedNames, [], 'Fallback/warm altered child environment')
|
||||
} else {
|
||||
assert.equal(calls('discovery')[0].code, 0)
|
||||
assert.equal(calls('capture')[0].code, 0)
|
||||
assert(prepare.changedNames.length > 0, 'Captured environment was not applied')
|
||||
assert(
|
||||
prepare.changedNames.every((name) => compilerEnvironmentKey.test(name)),
|
||||
'Unrelated environment changed'
|
||||
)
|
||||
assert(prepare.environment.compiler.vcinstalldir, 'Prepared child lacks compiler selection')
|
||||
}
|
||||
if (variant === 'fallback' && !warm) {
|
||||
assert.equal(calls('discovery')[0].code, 0)
|
||||
assert.equal(calls('capture')[0].code, 7, 'Fixed failure fixture did not run')
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNativeColdProbe(result) {
|
||||
assert.equal(result.code, 1, 'Cold fixture unexpectedly passed')
|
||||
for (const name of addonNames) {
|
||||
assert(result.stderr.includes(`${name}:`), `Cold fixture did not fail ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNoHeaderDownloads(result) {
|
||||
assert(
|
||||
!/gyp http (GET|200|download)/i.test(`${result.stdout}\n${result.stderr}`),
|
||||
'Timed preparation downloaded headers/libraries'
|
||||
)
|
||||
}
|
||||
|
||||
function digestFiles(root, paths) {
|
||||
return Object.fromEntries(
|
||||
paths
|
||||
.filter((path) => existsSync(join(root, path)))
|
||||
.map((path) => [path, hash(readFileSync(join(root, path)))])
|
||||
)
|
||||
}
|
||||
|
||||
function treeDigest(root) {
|
||||
const digest = createHash('sha256')
|
||||
const files = []
|
||||
const visit = (path) => {
|
||||
for (const name of readdirSync(path).sort()) {
|
||||
const child = join(path, name)
|
||||
if (statSync(child).isDirectory()) {
|
||||
visit(child)
|
||||
} else {
|
||||
files.push(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
for (const file of files) {
|
||||
digest.update(relative(root, file)).update('\0').update(readFileSync(file))
|
||||
}
|
||||
return { sha256: digest.digest('hex'), files: files.length }
|
||||
}
|
||||
|
||||
function nativeDirectories(snapshot) {
|
||||
const require = createRequire(join(snapshot, 'package.json'))
|
||||
const root = realpathSync(snapshot)
|
||||
return addonNames.map((name) => {
|
||||
const directory = realpathSync(dirname(require.resolve(`${name}/package.json`)))
|
||||
assert(directory.startsWith(`${root}${sep}`), `Addon escapes its isolated copy: ${name}`)
|
||||
return { name, directory }
|
||||
})
|
||||
}
|
||||
|
||||
function binaryReceipt(snapshot) {
|
||||
const require = createRequire(join(snapshot, 'package.json'))
|
||||
const paths = [
|
||||
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty.node'),
|
||||
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/conpty.dll'),
|
||||
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/OpenConsole.exe'),
|
||||
join(snapshot, 'native/windows-registry/build/Release/orca_windows_registry.node'),
|
||||
join(snapshot, 'native/windows-security/build/Release/orca_windows_security.node'),
|
||||
join(
|
||||
dirname(require.resolve('@vscode/windows-process-tree/package.json')),
|
||||
'build/Release/windows_process_tree.node'
|
||||
)
|
||||
]
|
||||
return paths.map((path) => {
|
||||
assert(existsSync(path), `Missing prepared artifact: ${path}`)
|
||||
return {
|
||||
path: relative(snapshot, path),
|
||||
bytes: statSync(path).size,
|
||||
sha256: hash(readFileSync(path))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function benchmarkWindowsNativePreparation(
|
||||
root = resolve(import.meta.dirname, '../..')
|
||||
) {
|
||||
assert.equal(process.platform, 'win32', 'This hosted comparison requires Windows')
|
||||
assert(['x64', 'arm64'].includes(process.arch), 'Unsupported Windows runner')
|
||||
assert.equal(process.versions.node, '24.21.0', 'Comparison requires pinned Node')
|
||||
assert(
|
||||
!hasWindowsCompilerSelection(process.env),
|
||||
'Explicit compiler selection prevents a qualified automatic-capture comparison'
|
||||
)
|
||||
assert(/^[0-9a-f]{40}$/.test(process.env.SOURCE_SHA ?? ''), 'SOURCE_SHA must be an exact commit')
|
||||
const output = join(root, '.build/windows-native-benchmark')
|
||||
mkdirSync(output, { recursive: true })
|
||||
const temporary = mkdtempSync(
|
||||
join(process.env.RUNNER_TEMP || tmpdir(), 'orca-native-comparison-')
|
||||
)
|
||||
const archive = join(temporary, 'source.tar')
|
||||
const headers = join(temporary, 'headers')
|
||||
const pnpm = resolvePnpmCliInvocation()
|
||||
const environment = {
|
||||
...process.env,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
npm_config_devdir: headers,
|
||||
npm_package_config_node_gyp_devdir: headers
|
||||
}
|
||||
const parentBefore = environmentReceipt(process.env)
|
||||
const githubEnvironmentBefore = existsSync(process.env.GITHUB_ENV ?? '')
|
||||
? hash(readFileSync(process.env.GITHUB_ENV))
|
||||
: null
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
startedAt: new Date().toISOString(),
|
||||
qualification: 'incomplete',
|
||||
firstRecordedJobStep: JSON.parse(readFileSync(join(output, 'job-start.json'), 'utf8')),
|
||||
phases: [],
|
||||
samples: [],
|
||||
scope: {
|
||||
warm: 'Same-build warm preparation; no Actions native-cache restore/save is performed.',
|
||||
metric:
|
||||
'Cold addon preparation with identical diagnostic preload; installs, header downloads, standalone qualification probes and artifact upload are recorded separately; the helper preflight remains included.',
|
||||
occupancy:
|
||||
'Use complete hosted job started_at/completed_at, including setup and upload/post steps, for actual runner occupancy. This artifact only measures its explicit window.',
|
||||
excluded:
|
||||
'No Node-to-Electron, cross-target template, exact-key restore, or CI workload gain is claimed.'
|
||||
}
|
||||
}
|
||||
const writeReport = () =>
|
||||
writeFileSync(join(output, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`)
|
||||
const command = async (label, program, args, cwd = root, env = environment, accepted = [0]) => {
|
||||
const startedAt = new Date().toISOString()
|
||||
const start = performance.now()
|
||||
const result = await runProcess({
|
||||
program,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs: 1_200_000,
|
||||
maxOutputBytes: 64 * 1024 * 1024,
|
||||
terminationBarrier: true
|
||||
})
|
||||
const elapsedMs = performance.now() - start
|
||||
const stdoutFile = `${label}.stdout.log`
|
||||
const stderrFile = `${label}.stderr.log`
|
||||
writeFileSync(join(output, stdoutFile), result.stdout)
|
||||
writeFileSync(join(output, stderrFile), result.stderr)
|
||||
report.phases.push({
|
||||
label,
|
||||
program,
|
||||
args,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: elapsedMs,
|
||||
code: result.code,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
outputTruncated: result.outputTruncated,
|
||||
stdoutFile,
|
||||
stderrFile,
|
||||
stdoutSha256: hash(result.stdout),
|
||||
stderrSha256: hash(result.stderr)
|
||||
})
|
||||
writeReport()
|
||||
assert(
|
||||
!result.timedOut &&
|
||||
!result.signal &&
|
||||
!result.outputTruncated &&
|
||||
accepted.includes(result.code),
|
||||
`${label} failed: ${result.stderr.slice(-4000)}`
|
||||
)
|
||||
return { ...result, durationMs: elapsedMs }
|
||||
}
|
||||
const pnpmCommand = (label, args, cwd) =>
|
||||
command(label, pnpm.command, [...pnpm.prefixArgs, ...args], cwd)
|
||||
try {
|
||||
const head = (await command('source-head', 'git', ['rev-parse', 'HEAD'])).stdout.trim()
|
||||
assert.equal(head, process.env.SOURCE_SHA, 'Checkout differs from frozen source')
|
||||
await command('source-clean', 'git', ['diff', '--exit-code', 'HEAD', '--'])
|
||||
const tree = (await command('source-tree', 'git', ['rev-parse', 'HEAD^{tree}'])).stdout.trim()
|
||||
const pnpmVersion = (await pnpmCommand('pnpm-version', ['--version'], root)).stdout.trim()
|
||||
assert.equal(pnpmVersion, '12.8.1', 'Comparison requires pinned pnpm')
|
||||
const action = readFileSync(
|
||||
join(root, '.github/actions/prepare-native-runtime/action.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const hashExpression = action.match(/NATIVE_SOURCE_HASH: (.+)/)?.[1]
|
||||
assert(hashExpression, 'Missing production source identity')
|
||||
const inputs = [...hashExpression.matchAll(/'([^']+)'/g)].map((match) => match[1])
|
||||
assert(
|
||||
inputs.includes(`config/scripts/${candidate}`),
|
||||
'Frozen source lacks the candidate native-cache fingerprint'
|
||||
)
|
||||
const requiredScriptHashes = digestFiles(root, unchangedFiles)
|
||||
assert.equal(
|
||||
Object.keys(requiredScriptHashes).length,
|
||||
unchangedFiles.length,
|
||||
'Frozen source lacks required scripts'
|
||||
)
|
||||
const sourceInputs = digestFiles(root, inputs)
|
||||
report.identity = {
|
||||
sourceSha: head,
|
||||
tree,
|
||||
runtime: 'node',
|
||||
node: process.versions.node,
|
||||
nodeAbi: process.versions.modules,
|
||||
arch: process.arch,
|
||||
pnpm: pnpmVersion,
|
||||
runner: process.env.BENCHMARK_RUNNER,
|
||||
imageOS: process.env.ImageOS,
|
||||
imageVersion: process.env.ImageVersion,
|
||||
workflowRef: process.env.GITHUB_WORKFLOW_REF,
|
||||
workflowSha: process.env.GITHUB_WORKFLOW_SHA,
|
||||
runId: process.env.GITHUB_RUN_ID,
|
||||
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
|
||||
sourceInputs,
|
||||
requiredScriptHashes,
|
||||
sourceInputDigest: hash(JSON.stringify(sourceInputs)),
|
||||
productionHashExpression: hashExpression,
|
||||
parentCompilerEnvironment: parentBefore.compiler
|
||||
}
|
||||
await command('archive-source', 'git', ['archive', '--format=tar', '--output', archive, 'HEAD'])
|
||||
report.identity.archiveSha256 = hash(readFileSync(archive))
|
||||
const require = createRequire(join(root, 'package.json'))
|
||||
const nodeGyp = process.env.npm_config_node_gyp || require.resolve('node-gyp/bin/node-gyp.js')
|
||||
report.identity.nodeGyp = JSON.parse(
|
||||
readFileSync(resolve(dirname(nodeGyp), '..', 'package.json'), 'utf8')
|
||||
).version
|
||||
report.identity.nodeGypEntrySha256 = hash(readFileSync(nodeGyp))
|
||||
await command('download-headers-before-measurement', process.execPath, [
|
||||
nodeGyp,
|
||||
'install',
|
||||
`--target=${process.versions.node}`,
|
||||
`--arch=${process.arch}`,
|
||||
`--devdir=${headers}`
|
||||
])
|
||||
const headersBefore = treeDigest(headers)
|
||||
report.identity.headers = headersBefore
|
||||
const physicalDirectories = new Set()
|
||||
const schedule = buildCounterbalancedSchedule(2, 'control', 'candidate').flat()
|
||||
report.schedule = schedule
|
||||
for (const [index, variant] of [...schedule, 'fallback'].entries()) {
|
||||
const label = `sample-${String(index + 1).padStart(2, '0')}`
|
||||
const snapshot = join(temporary, label)
|
||||
mkdirSync(snapshot)
|
||||
await command(`${label}-extract`, 'tar', ['-xf', archive, '-C', snapshot])
|
||||
await pnpmCommand(
|
||||
`${label}-install`,
|
||||
[
|
||||
'install',
|
||||
'--offline',
|
||||
'--frozen-lockfile',
|
||||
'--ignore-scripts',
|
||||
'--package-import-method=copy',
|
||||
'--side-effects-cache=false'
|
||||
],
|
||||
snapshot
|
||||
)
|
||||
await pnpmCommand(`${label}-internal-packages`, ['run', 'build:packages'], snapshot)
|
||||
const directories = nativeDirectories(snapshot)
|
||||
for (const { directory } of directories) {
|
||||
assert(
|
||||
!physicalDirectories.has(directory.toLowerCase()),
|
||||
'Native copies share a build directory'
|
||||
)
|
||||
physicalDirectories.add(directory.toLowerCase())
|
||||
rmSync(join(directory, 'build'), { recursive: true, force: true })
|
||||
}
|
||||
const initial = await command(
|
||||
`${label}-cold-probe`,
|
||||
process.execPath,
|
||||
[join(snapshot, 'config/scripts', control), '--check-only'],
|
||||
snapshot,
|
||||
environment,
|
||||
[1]
|
||||
)
|
||||
assertNativeColdProbe(initial)
|
||||
const snapshotScriptHashes = digestFiles(snapshot, unchangedFiles)
|
||||
assert.deepEqual(
|
||||
snapshotScriptHashes,
|
||||
requiredScriptHashes,
|
||||
'Ordinary snapshot source/config drift'
|
||||
)
|
||||
const originalCapture = readFileSync(
|
||||
join(snapshot, 'config/scripts/capture-windows-native-environment.cmd')
|
||||
)
|
||||
if (variant === 'fallback') {
|
||||
writeFileSync(
|
||||
join(snapshot, 'config/scripts/capture-windows-native-environment.cmd'),
|
||||
'@echo off\r\nexit /b 7\r\n'
|
||||
)
|
||||
}
|
||||
const sample = {
|
||||
label,
|
||||
variant,
|
||||
sourceHashesBeforeInjection: snapshotScriptHashes,
|
||||
originalCaptureSha256: hash(originalCapture),
|
||||
addonDirectories: directories,
|
||||
measurements: []
|
||||
}
|
||||
report.samples.push(sample)
|
||||
for (const warm of [false, true]) {
|
||||
const stage = `${label}-${warm ? 'warm' : 'cold'}`
|
||||
const traceFile = join(output, `${stage}.trace.json`)
|
||||
const script = join(snapshot, 'config/scripts', variant === 'control' ? control : candidate)
|
||||
const args = [
|
||||
'--import',
|
||||
pathToFileURL(join(snapshot, 'config/scripts/windows-native-benchmark-observer.mjs'))
|
||||
.href,
|
||||
script,
|
||||
...(variant === 'control' ? ['--runtime=node'] : [])
|
||||
]
|
||||
const result = await command(stage, process.execPath, args, snapshot, {
|
||||
...environment,
|
||||
ORCA_NATIVE_BENCHMARK_TRACE: traceFile
|
||||
})
|
||||
assertNoHeaderDownloads(result)
|
||||
const receipt = JSON.parse(readFileSync(traceFile, 'utf8'))
|
||||
validateObservedPreparation(receipt, variant, warm)
|
||||
if (variant === 'fallback' && !warm) {
|
||||
assert(result.stderr.includes('retaining node-gyp discovery'), 'Fallback warning missing')
|
||||
}
|
||||
const final = await command(
|
||||
`${stage}-final-probe`,
|
||||
process.execPath,
|
||||
[join(snapshot, 'config/scripts', control), '--check-only'],
|
||||
snapshot
|
||||
)
|
||||
assert.equal(final.code, 0)
|
||||
const calls = receipt.calls
|
||||
const milliseconds = (phase) =>
|
||||
calls
|
||||
.filter((call) => call.phase === phase)
|
||||
.reduce((total, call) => total + call.durationMs, 0)
|
||||
sample.measurements.push({
|
||||
warm,
|
||||
totalPreparationMs: result.durationMs,
|
||||
helperDiscoveryMs: milliseconds('discovery'),
|
||||
helperCaptureMs: milliseconds('capture'),
|
||||
helperPreprobeMs: milliseconds('probe'),
|
||||
unchangedPreparationChildMs: variant === 'control' ? null : milliseconds('preparation'),
|
||||
helperOutsidePreparationChildMs:
|
||||
variant === 'control' ? null : result.durationMs - milliseconds('preparation'),
|
||||
traceFile: relative(output, traceFile),
|
||||
traceSha256: hash(readFileSync(traceFile)),
|
||||
binaries: binaryReceipt(snapshot)
|
||||
})
|
||||
assert.deepEqual(
|
||||
treeDigest(headers),
|
||||
headersBefore,
|
||||
'Timed preparation changed prefilled headers/libraries'
|
||||
)
|
||||
assert.equal(
|
||||
environmentReceipt(process.env).hash,
|
||||
parentBefore.hash,
|
||||
'Parent environment changed'
|
||||
)
|
||||
assert.equal(
|
||||
existsSync(process.env.GITHUB_ENV ?? '')
|
||||
? hash(readFileSync(process.env.GITHUB_ENV))
|
||||
: null,
|
||||
githubEnvironmentBefore,
|
||||
'Compiler setup wrote GITHUB_ENV'
|
||||
)
|
||||
writeReport()
|
||||
}
|
||||
sample.isolatedCopyWindowMs = report.phases
|
||||
.filter((phase) => phase.label.startsWith(`${label}-`))
|
||||
.reduce((total, phase) => total + phase.durationMs, 0)
|
||||
sample.injectedCaptureSha256 =
|
||||
variant === 'fallback'
|
||||
? hash(
|
||||
readFileSync(join(snapshot, 'config/scripts/capture-windows-native-environment.cmd'))
|
||||
)
|
||||
: null
|
||||
}
|
||||
report.comparison = Object.fromEntries(
|
||||
[false, true].map((warm) => {
|
||||
const timings = (variant) =>
|
||||
report.samples
|
||||
.filter((sample) => sample.variant === variant)
|
||||
.map(
|
||||
(sample) =>
|
||||
sample.measurements.find((measurement) => measurement.warm === warm)
|
||||
.totalPreparationMs
|
||||
)
|
||||
const baseline = timings('control')
|
||||
const proposed = timings('candidate')
|
||||
return [
|
||||
warm ? 'sameBuildWarm' : 'cold',
|
||||
{
|
||||
control: summarizeBenchmarkSamples(baseline),
|
||||
candidate: summarizeBenchmarkSamples(proposed),
|
||||
pairDeltaMs: baseline.map((value, index) => proposed[index] - value),
|
||||
role: 'Two counterbalanced pairs are descriptive; no significance or ordinary CI hit-rate estimate.'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
report.parentEnvironment = {
|
||||
before: parentBefore,
|
||||
after: environmentReceipt(process.env),
|
||||
githubEnvironmentBefore,
|
||||
githubEnvironmentAfter: githubEnvironmentBefore
|
||||
}
|
||||
report.qualification = 'passed'
|
||||
} catch (error) {
|
||||
report.failure = error.message
|
||||
report.qualification = 'failed'
|
||||
throw error
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true })
|
||||
report.finishedAt = new Date().toISOString()
|
||||
report.explicitWindowMs = Date.parse(report.finishedAt) - Date.parse(report.startedAt)
|
||||
report.explicitJobWindowMs =
|
||||
Date.parse(report.finishedAt) - Date.parse(report.firstRecordedJobStep.startedAt)
|
||||
writeReport()
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await benchmarkWindowsNativePreparation()
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
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 { runProcessSync } from '@orca/process-host'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
shouldReuseCompiledWindowsRustExecutable,
|
||||
windowsRustExecutableFileVersion,
|
||||
@@ -45,6 +46,43 @@ function itWindows(name, test) {
|
||||
runner(name, { timeout: 300_000 }, test)
|
||||
}
|
||||
|
||||
let sharedLauncherRoot = null
|
||||
let sharedLauncherPath = null
|
||||
let sharedLauncherBuild = null
|
||||
|
||||
// Compile once; each launch case gets a private copy so it can own its fixture tree.
|
||||
beforeAll(() => {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
sharedLauncherRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher build '))
|
||||
sharedLauncherPath = join(sharedLauncherRoot, 'orca.exe')
|
||||
const result = runProcessSync({
|
||||
program: process.execPath,
|
||||
args: ['config/scripts/build-windows-cli-launcher.mjs', '--output', sharedLauncherPath],
|
||||
cwd: projectRoot,
|
||||
timeoutMs: 300_000
|
||||
})
|
||||
sharedLauncherBuild = { status: result.code, stdout: result.stdout, stderr: result.stderr }
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`${result.stdout}\n${result.stderr}`)
|
||||
}
|
||||
}, 300_000)
|
||||
|
||||
afterAll(() => {
|
||||
if (sharedLauncherRoot) {
|
||||
removeFixtureTree(sharedLauncherRoot)
|
||||
}
|
||||
})
|
||||
|
||||
function copySharedLauncher(target) {
|
||||
if (!sharedLauncherPath) {
|
||||
throw new Error('shared Windows CLI launcher was not built')
|
||||
}
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(sharedLauncherPath, target)
|
||||
}
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
it('reuses restored builds only while all embedded inputs and the release version match', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
|
||||
@@ -107,12 +145,12 @@ describe('Windows CLI launcher', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca launcher metadata '))
|
||||
try {
|
||||
const launcherPath = join(root, 'orca.exe')
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
expect(sharedLauncherBuild).not.toBeNull()
|
||||
expect(
|
||||
sharedLauncherBuild.status,
|
||||
`${sharedLauncherBuild.stdout}\n${sharedLauncherBuild.stderr}`
|
||||
).toBe(0)
|
||||
copySharedLauncher(launcherPath)
|
||||
const inspect = spawnSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
@@ -183,12 +221,7 @@ describe('Windows CLI launcher', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
copySharedLauncher(launcherPath)
|
||||
|
||||
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
|
||||
const powershell = spawnSync(
|
||||
@@ -247,12 +280,7 @@ describe('Windows CLI launcher', () => {
|
||||
}))\n`,
|
||||
'utf8'
|
||||
)
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
copySharedLauncher(launcherPath)
|
||||
writeFileSync(
|
||||
join(dirname(launcherPath), 'orca-launcher.cfg'),
|
||||
`node=${process.execPath}\ncli=${cliPath}\nuser-data=${profile}\n`
|
||||
@@ -326,12 +354,7 @@ describe('Windows CLI launcher', () => {
|
||||
}))\n`,
|
||||
'utf8'
|
||||
)
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
copySharedLauncher(launcherPath)
|
||||
|
||||
const compiler = findFrameworkCompiler()
|
||||
expect(compiler).not.toBeNull()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
call "%ORCA_WINDOWS_VSDEVCMD%" -no_logo -arch=%ORCA_WINDOWS_NATIVE_ARCH% -host_arch=%ORCA_WINDOWS_NATIVE_ARCH%
|
||||
if errorlevel 1 exit /b 1
|
||||
echo ORCA_WINDOWS_NATIVE_ENVIRONMENT
|
||||
set
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
|
||||
const read = (path) => parse(readFileSync(path, 'utf8'))
|
||||
const workflow = (name) => read(`.github/workflows/${name}.yml`)
|
||||
@@ -300,6 +301,116 @@ describe('CI dependency download caches', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps PR warmer validation within the workflow family that runs it', () => {
|
||||
const shared = workflow('ci-cache-warmup')
|
||||
const platforms = workflow('ci-cache-warmup-platforms')
|
||||
expect(shared.on.pull_request.paths).toEqual([
|
||||
'.github/workflows/ci-cache-warmup.yml',
|
||||
'.github/actions/prepare-headless-compiler/**',
|
||||
'config/scripts/headless-detector-compiler-cache.mjs',
|
||||
'.github/actions/prepare-wayland-clipboard/**'
|
||||
])
|
||||
expect(platforms.on.pull_request.paths).toEqual([
|
||||
'.github/workflows/ci-cache-warmup-platforms.yml',
|
||||
'.github/workflows/ci-cache-warmup-platform-jobs.yml'
|
||||
])
|
||||
expect(shared.jobs['warm-platforms'].uses).toBe(platforms.jobs['warm-platforms'].uses)
|
||||
expect(shared.on.schedule).toEqual([{ cron: '41 */6 * * *' }])
|
||||
expect(shared.on).toHaveProperty('workflow_dispatch')
|
||||
for (const producer of [shared, platforms]) {
|
||||
expect(producer.on.push.branches).toEqual(['main'])
|
||||
expect(producer.on.push.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'.github/workflows/ci-cache-warmup.yml',
|
||||
'.github/workflows/ci-cache-warmup-platforms.yml',
|
||||
'.github/workflows/ci-cache-warmup-platform-jobs.yml',
|
||||
'.github/actions/install-node-dependencies/**',
|
||||
'.github/actions/prepare-native-runtime/**',
|
||||
'pnpm-lock.yaml'
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pull_request', false, false],
|
||||
['push', true, false],
|
||||
['schedule', true, true],
|
||||
['workflow_dispatch', true, true]
|
||||
])('retains cache producers and Windows validation on %s', (event, armRuns, sharedPlatforms) => {
|
||||
const shared = workflow('ci-cache-warmup')
|
||||
const platforms = read(workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses)
|
||||
const context = { github: { event_name: event } }
|
||||
expect(runInNewContext(platforms.jobs['warm-linux-arm'].if, context)).toBe(armRuns)
|
||||
expect(runInNewContext(shared.jobs['warm-platforms'].if, context)).toBe(sharedPlatforms)
|
||||
expect(shared.jobs.warm.if).toBeUndefined()
|
||||
expect(platforms.jobs['warm-windows'].if).toBeUndefined()
|
||||
expect(platforms.jobs['warm-windows'].strategy.matrix.os).toEqual([
|
||||
'windows-2022',
|
||||
'windows-11-arm'
|
||||
])
|
||||
})
|
||||
|
||||
it('validates the omitted ARM warmer preparation in the real PR consumers', () => {
|
||||
const platforms = read(workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses)
|
||||
const warm = platforms.jobs['warm-linux-arm']
|
||||
const pr = workflow('pr')
|
||||
const units = workflow('unit-tests').jobs.test
|
||||
const installer = './.github/actions/install-node-dependencies'
|
||||
const warmInstall = warm.steps.find((step) => step.uses === installer)
|
||||
const unitInstall = units.steps.find((step) => step.uses === installer)
|
||||
expect(warm['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(pr.jobs.preflight['runs-on']).toBe(warm['runs-on'])
|
||||
expect(pr.jobs.test.with.runner).toBe(warm['runs-on'])
|
||||
expect(units['runs-on']).toBe('${{ inputs.runner }}')
|
||||
expect(JSON.parse(pr.jobs.test.with.node_versions)).toEqual(['24'])
|
||||
expect(warmInstall.if).toBeUndefined()
|
||||
expect(unitInstall.if).toBeUndefined()
|
||||
expect(warmInstall.with['native-runtime']).toBe('node')
|
||||
expect(unitInstall.with['native-runtime']).toBe(warmInstall.with['native-runtime'])
|
||||
expect(warmInstall.with['node-version']).toBe('24')
|
||||
expect(unitInstall.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(unitInstall.with['cache-electron-package']).toBe(
|
||||
warmInstall.with['cache-electron-package']
|
||||
)
|
||||
const preflightInstalls = pr.jobs.preflight.steps.filter((step) => step.uses === installer)
|
||||
expect(preflightInstalls.map((step) => step.if)).toEqual([
|
||||
"needs.code_paths.outputs.mobile_dependencies != 'true'",
|
||||
"needs.code_paths.outputs.mobile_dependencies == 'true'"
|
||||
])
|
||||
for (const step of preflightInstalls) {
|
||||
expect(step.with['native-runtime']).toBe('node')
|
||||
expect(step.with['node-version']).toBe('24')
|
||||
}
|
||||
const populate = 'node config/scripts/install-electron-package-binary.mjs'
|
||||
const unitPopulate = units.steps.find((step) => step.run === populate)
|
||||
expect(unitPopulate.if).toBeUndefined()
|
||||
expect(warm.steps.map((step) => step.uses ?? step.run)).toEqual([
|
||||
'actions/checkout@v6',
|
||||
installer,
|
||||
populate,
|
||||
'node config/scripts/ensure-native-runtime.mjs --check-only'
|
||||
])
|
||||
const native = read('.github/actions/prepare-native-runtime/action.yml')
|
||||
const identity = native.runs.steps.find((step) => step.id === 'native-cache-scope')
|
||||
const inputs = [...identity.env.NATIVE_SOURCE_HASH.matchAll(/'([^']+)'/g)].map(
|
||||
(match) => match[1]
|
||||
)
|
||||
for (const path of [
|
||||
...inputs,
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
...workflow('ci-cache-warmup-platforms').on.pull_request.paths,
|
||||
'.github/workflows/ci-cache-warmup.yml'
|
||||
]) {
|
||||
expect(classifyPrJobs([path], { allowMobileCiTools: true }), path).toMatchObject({
|
||||
static_analysis: true,
|
||||
typecheck: true,
|
||||
test: true,
|
||||
mobile_ci_tools: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('restores Windows packaging downloads from the release cache without a PR upload', () => {
|
||||
const packaging = workflow('pr').jobs.package_windows
|
||||
const restore = packaging.steps.find((step) => step.name === 'Cache electron-builder downloads')
|
||||
|
||||
@@ -4456,7 +4456,6 @@
|
||||
"src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts": 5902,
|
||||
"src/main/runtime/mobile-subscribe-integration.test.ts": 22684,
|
||||
"src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 5282,
|
||||
"src/main/runtime/native-chat-visuals-workspace-verdict.test.ts": 326,
|
||||
"src/main/runtime/omp-readiness-transcripts.test.ts": 7797,
|
||||
"src/main/runtime/opencode-agent-row-enter-transcript.test.ts": 562,
|
||||
"src/main/runtime/opencode-agent-row-structure-transcript.test.ts": 347,
|
||||
@@ -8422,7 +8421,7 @@
|
||||
"src/renderer/src/components/sidebar/workspace-attachment-source-label.test.ts": 76,
|
||||
"src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts": 1483,
|
||||
"src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 243,
|
||||
"src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 69,
|
||||
"src/shared/workspace-creator-visibility.test.ts": 69,
|
||||
"src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts": 46,
|
||||
"src/renderer/src/components/sidebar/workspace-hover-attachments.test.ts": 106,
|
||||
"src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 186,
|
||||
@@ -10075,7 +10074,7 @@
|
||||
"src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 137,
|
||||
"src/renderer/src/lib/renderer-app-platform.test.ts": 177,
|
||||
"src/renderer/src/lib/renderer-memory-profile.test.ts": 64,
|
||||
"src/renderer/src/lib/repo-display-labels.test.ts": 50,
|
||||
"src/shared/repo-display-labels.test.ts": 50,
|
||||
"src/renderer/src/lib/repo-runtime-owner.test.ts": 61,
|
||||
"src/renderer/src/lib/repo-search.test.ts": 43,
|
||||
"src/renderer/src/lib/repo-slug-cache.test.ts": 37,
|
||||
|
||||
@@ -477,7 +477,12 @@ describe('packaged runtime resources', () => {
|
||||
{ recursive: true }
|
||||
)
|
||||
await seedBundledRipgrep(resourcesDir)
|
||||
await seedClipboardPublisher(resourcesDir, 0x3e)
|
||||
// Layout fixtures use a cross-arch slice; native PTY probes need real packaged binaries.
|
||||
const target =
|
||||
process.arch === 'x64'
|
||||
? { electronArch: 3, machine: 0xb7 }
|
||||
: { electronArch: 1, machine: 0x3e }
|
||||
await seedClipboardPublisher(resourcesDir, target.machine)
|
||||
await stubObjdump(root)
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
|
||||
// Why: afterPack now fails hard when the unpacked daemon entry is
|
||||
@@ -512,7 +517,7 @@ describe('packaged runtime resources', () => {
|
||||
await electronBuilderConfig.afterPack({
|
||||
appOutDir: join(root, 'linux-unpacked'),
|
||||
electronPlatformName: 'linux',
|
||||
arch: 1,
|
||||
arch: target.electronArch,
|
||||
packager: { appInfo: { version: '9.9.9' } }
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
|
||||
const ENVIRONMENT_MARKER = 'ORCA_WINDOWS_NATIVE_ENVIRONMENT'
|
||||
const compilerEnvironmentKey =
|
||||
/^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i
|
||||
|
||||
export function parseWindowsCompilerEnvironment(output, arch) {
|
||||
const lines = output.split(/\r?\n/)
|
||||
const marker = lines.indexOf(ENVIRONMENT_MARKER)
|
||||
if (marker === -1) {
|
||||
throw new Error('Developer environment receipt is missing')
|
||||
}
|
||||
const environment = {}
|
||||
for (const line of lines.slice(marker + 1)) {
|
||||
const separator = line.indexOf('=')
|
||||
if (separator <= 0) {
|
||||
continue
|
||||
}
|
||||
const name = line.slice(0, separator)
|
||||
if (compilerEnvironmentKey.test(name)) {
|
||||
environment[name] = line.slice(separator + 1)
|
||||
}
|
||||
}
|
||||
const value = (name) => {
|
||||
const key = Object.keys(environment).find((key) => key.toLowerCase() === name.toLowerCase())
|
||||
return key ? environment[key] : undefined
|
||||
}
|
||||
if (!value('VCINSTALLDIR') || !value('VSCMD_VER') || !value('WindowsSDKVersion')) {
|
||||
throw new Error('Developer environment lacks the compiler or Windows SDK')
|
||||
}
|
||||
if (
|
||||
!/^\d+\.\d+\./.test(value('VSCMD_VER')) ||
|
||||
!/^\d+\.\d+\.\d+\.\d+\\?$/.test(value('WindowsSDKVersion'))
|
||||
) {
|
||||
throw new Error('Developer environment has an invalid compiler or Windows SDK version')
|
||||
}
|
||||
const target = value('VSCMD_ARG_TGT_ARCH')?.toLowerCase()
|
||||
if (!(target === arch || (arch === 'x64' && target === 'amd64'))) {
|
||||
throw new Error('Developer environment targets a different architecture')
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
export function hasWindowsCompilerSelection(environment) {
|
||||
return Object.entries(environment).some(
|
||||
([name, value]) =>
|
||||
/^(VCINSTALLDIR|GYP_MSVS_VERSION|npm_config_msvs_version|npm_package_config_node_gyp_msvs_version)$/i.test(
|
||||
name
|
||||
) && value
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeWindowsCompilerEnvironment(environment, compilerEnvironment) {
|
||||
const compilerKeys = new Set(Object.keys(compilerEnvironment).map((key) => key.toLowerCase()))
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(environment).filter(([key]) => !compilerKeys.has(key.toLowerCase()))
|
||||
),
|
||||
...compilerEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeWindowsNativeToolchain() {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
if (hasWindowsCompilerSelection(process.env)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!['x64', 'arm64'].includes(process.arch)) {
|
||||
throw new Error('Windows compiler setup requires a supported runner architecture')
|
||||
}
|
||||
const programFiles = Object.entries(process.env)
|
||||
.filter(([key]) => /^(ProgramFiles|ProgramFiles\(x86\))$/i.test(key))
|
||||
.map(([, value]) => value)
|
||||
const vswhere = programFiles
|
||||
.map((directory) => join(directory, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe'))
|
||||
.find(existsSync)
|
||||
if (!vswhere) {
|
||||
throw new Error('Visual Studio discovery tool is unavailable')
|
||||
}
|
||||
const discovery = runProcessSync({
|
||||
program: vswhere,
|
||||
args: [
|
||||
'-latest',
|
||||
'-products',
|
||||
'*',
|
||||
'-utf8',
|
||||
'-requires',
|
||||
process.arch === 'arm64'
|
||||
? 'Microsoft.VisualStudio.Component.VC.Tools.ARM64'
|
||||
: 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
|
||||
'-property',
|
||||
'installationPath'
|
||||
],
|
||||
stdio: 'pipe',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
const installations = discovery.stdout.trim().split(/\r?\n/).filter(Boolean)
|
||||
if (discovery.code !== 0 || installations.length !== 1) {
|
||||
throw new Error('Visual Studio discovery did not select one compiler')
|
||||
}
|
||||
const devcmd = join(installations[0], 'Common7', 'Tools', 'VsDevCmd.bat')
|
||||
if (!existsSync(devcmd)) {
|
||||
throw new Error('Visual Studio developer command is unavailable')
|
||||
}
|
||||
// process-host owns the fixed batch runner's quoting and hidden console.
|
||||
const result = runProcessSync({
|
||||
program: join(import.meta.dirname, 'capture-windows-native-environment.cmd'),
|
||||
args: [],
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_WINDOWS_VSDEVCMD: devcmd,
|
||||
ORCA_WINDOWS_NATIVE_ARCH: process.arch
|
||||
},
|
||||
stdio: 'pipe',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error('Visual Studio developer command failed')
|
||||
}
|
||||
const environment = parseWindowsCompilerEnvironment(result.stdout, process.arch)
|
||||
console.log(`[native-runtime] Initialized Windows ${process.arch} compiler environment`)
|
||||
return environment
|
||||
} catch {
|
||||
console.warn(
|
||||
'[native-runtime] Compiler environment setup unavailable; retaining node-gyp discovery'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareWindowsNodeRuntime(runProcess = runProcessSync) {
|
||||
const script = join(import.meta.dirname, 'ensure-native-runtime.mjs')
|
||||
const check = runProcess({
|
||||
program: process.execPath,
|
||||
args: [script, '--check-only'],
|
||||
stdio: 'pipe',
|
||||
timeoutMs: null
|
||||
})
|
||||
const compilerEnvironment = check.code === 0 ? undefined : initializeWindowsNativeToolchain()
|
||||
const result = runProcess({
|
||||
program: process.execPath,
|
||||
args: [script, '--runtime=node'],
|
||||
env: compilerEnvironment
|
||||
? mergeWindowsCompilerEnvironment(process.env, compilerEnvironment)
|
||||
: process.env,
|
||||
stdio: 'inherit',
|
||||
timeoutMs: null
|
||||
})
|
||||
return result.code ?? 1
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exitCode = prepareWindowsNodeRuntime()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
hasWindowsCompilerSelection,
|
||||
mergeWindowsCompilerEnvironment,
|
||||
parseWindowsCompilerEnvironment,
|
||||
prepareWindowsNodeRuntime
|
||||
} from './initialize-windows-native-toolchain.mjs'
|
||||
|
||||
const receipt = [
|
||||
'Developer Command Prompt banner, not an environment variable',
|
||||
'TOKEN=should-not-be-exported',
|
||||
'ORCA_WINDOWS_NATIVE_ENVIRONMENT',
|
||||
'VCINSTALLDIR=C:\\Visual Studio\\VC\\',
|
||||
'VSCMD_VER=18.10.12224.181',
|
||||
'WindowsSDKVersion=10.0.28000.0\\',
|
||||
'VSCMD_ARG_TGT_ARCH=arm64',
|
||||
'Path=C:\\工具;C:\\Program Files\\Visual Studio',
|
||||
'INCLUDE=C:\\SDK=Custom\\Include',
|
||||
'TOKEN=should-not-be-exported',
|
||||
'=C:=C:\\',
|
||||
''
|
||||
].join('\r\n')
|
||||
|
||||
describe('Windows compiler environment receipt', () => {
|
||||
it('keeps compiler paths and equals signs while excluding unrelated variables and banners', () => {
|
||||
expect(parseWindowsCompilerEnvironment(receipt, 'arm64')).toEqual({
|
||||
VCINSTALLDIR: 'C:\\Visual Studio\\VC\\',
|
||||
VSCMD_VER: '18.10.12224.181',
|
||||
WindowsSDKVersion: '10.0.28000.0\\',
|
||||
VSCMD_ARG_TGT_ARCH: 'arm64',
|
||||
Path: 'C:\\工具;C:\\Program Files\\Visual Studio',
|
||||
INCLUDE: 'C:\\SDK=Custom\\Include'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
receipt.replace('ORCA_WINDOWS_NATIVE_ENVIRONMENT', 'missing'),
|
||||
receipt.replace('VCINSTALLDIR=', 'unrelated='),
|
||||
receipt.replace('VSCMD_VER=', 'unrelated='),
|
||||
receipt.replace('WindowsSDKVersion=', 'unrelated='),
|
||||
receipt.replace('10.0.28000.0\\', 'unavailable'),
|
||||
receipt.replace('18.10.12224.181', 'unavailable'),
|
||||
receipt.replace('VSCMD_ARG_TGT_ARCH=arm64', 'VSCMD_ARG_TGT_ARCH=x64')
|
||||
])('rejects incomplete, invalid or wrong-architecture setup before use', (output) => {
|
||||
expect(() => parseWindowsCompilerEnvironment(output, 'arm64')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Windows native compiler environment scope', () => {
|
||||
it('replaces path keys case-insensitively without changing the parent environment', () => {
|
||||
const original = { PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' }
|
||||
expect(
|
||||
mergeWindowsCompilerEnvironment(original, { Path: 'compiler-path', Lib: 'compiler-lib' })
|
||||
).toEqual({ Path: 'compiler-path', Lib: 'compiler-lib', TOKEN: 'unchanged' })
|
||||
expect(original).toEqual({ PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'VCINSTALLDIR',
|
||||
'GYP_MSVS_VERSION',
|
||||
'npm_config_msvs_version',
|
||||
'npm_package_config_node_gyp_msvs_version'
|
||||
])('retains an existing compiler selection through %s', (name) => {
|
||||
expect(hasWindowsCompilerSelection({ [name.toLowerCase()]: 'requested' })).toBe(true)
|
||||
expect(hasWindowsCompilerSelection({ [name]: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the original preparation and environment after a passing warm probe', () => {
|
||||
const runProcess = vi.fn(() => ({ code: 0 }))
|
||||
expect(prepareWindowsNodeRuntime(runProcess)).toBe(0)
|
||||
expect(runProcess).toHaveBeenCalledTimes(2)
|
||||
expect(runProcess).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
args: [expect.stringContaining('ensure-native-runtime.mjs'), '--runtime=node'],
|
||||
env: process.env,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates native preparation failure after a passing warm probe', () => {
|
||||
const runProcess = vi.fn().mockReturnValueOnce({ code: 0 }).mockReturnValueOnce({ code: 9 })
|
||||
expect(prepareWindowsNodeRuntime(runProcess)).toBe(9)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
|
||||
const action = parse(
|
||||
readFileSync('.github/actions/prepare-linux-package-fixture/action.yml', 'utf8')
|
||||
@@ -132,3 +134,45 @@ it('uses exact context/platform keys and the same restore-only action in package
|
||||
.every((step) => step.with['save-cache'] === "${{ github.event_name != 'pull_request' }}")
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pull_request', false],
|
||||
['push', true],
|
||||
['schedule', true],
|
||||
['workflow_dispatch', true]
|
||||
])('keeps fixture seeding available without a redundant PR runner on %s', (event, runs) => {
|
||||
const source = workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses
|
||||
const warm = parse(readFileSync(source, 'utf8')).jobs['warm-linux-package-fixtures']
|
||||
const context = { github: { event_name: event } }
|
||||
expect(warm['runs-on']).toBe('ubuntu-latest')
|
||||
expect(warm.steps.map((step) => step.uses)).toEqual([
|
||||
'actions/checkout@v6',
|
||||
'./.github/actions/prepare-linux-package-fixture',
|
||||
'./.github/actions/prepare-linux-package-fixture',
|
||||
'./.github/actions/prepare-linux-package-fixture'
|
||||
])
|
||||
expect(runInNewContext(warm.if, context)).toBe(runs)
|
||||
for (const step of warm.steps.slice(1)) {
|
||||
expect(runInNewContext(step.with['save-cache'].slice(3, -2), context)).toBe(runs)
|
||||
}
|
||||
const consumers = workflow('pr').jobs.package.steps.filter((step) =>
|
||||
step.uses?.endsWith('/prepare-linux-package-fixture')
|
||||
)
|
||||
expect(consumers.every((step) => step.with['save-cache'] === undefined)).toBe(true)
|
||||
expect(consumers.map((step) => step.with.fixture)).toEqual(
|
||||
warm.steps.slice(1).map((step) => step.with.fixture)
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'.github/actions/prepare-linux-package-fixture/action.yml',
|
||||
'config/docker/daemon-shutdown-descendants/Dockerfile',
|
||||
'config/docker/headless-serve-shutdown/Dockerfile',
|
||||
'config/docker/cli-launch-contract/Dockerfile'
|
||||
])('validates a changed fixture in Linux packaging: %s', (file) => {
|
||||
expect(classifyPrJobs([file])).toMatchObject({
|
||||
test: true,
|
||||
package: true,
|
||||
package_windows: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3734,9 +3734,6 @@
|
||||
"auto.components.settings.terminal.clipboard.search.5ffcd13c90": {
|
||||
"ko": "tmux"
|
||||
},
|
||||
"auto.components.settings.terminal.clipboard.search.603818e8d8": {
|
||||
"ko": "터미널 선택 영역이 만들어지는 즉시 자동으로 클립보드에 복사합니다."
|
||||
},
|
||||
"auto.components.settings.terminal.clipboard.search.e87c6d776d": {
|
||||
"ko": "자동"
|
||||
},
|
||||
@@ -5246,9 +5243,6 @@
|
||||
"auto.components.settings.TerminalPane.a9d47451d1": {
|
||||
"ko": "\"새 탭\"은 현재 포커스를 가져가지 않고 \"Setup\"이라는 제목의 백그라운드 탭에서 설정 명령을 엽니다."
|
||||
},
|
||||
"auto.components.settings.TerminalPane.4729c645fc": {
|
||||
"ko": "터미널에서 선택한 내용을 클립보드에 자동으로 복사합니다."
|
||||
},
|
||||
"auto.components.settings.TerminalPane.9129b7e805": {
|
||||
"ko": "터미널 패널 위에 마우스를 올리면 클릭하지 않고도 활성화됩니다."
|
||||
},
|
||||
|
||||
@@ -41,6 +41,8 @@ const ALWAYS_FILES = new Set([
|
||||
'config/scripts/workspace-source-exports.mjs',
|
||||
'config/scripts/workspace-source-exports.test.mjs',
|
||||
'config/scripts/headless-detector-compiler-cache.mjs',
|
||||
'config/scripts/initialize-windows-native-toolchain.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd',
|
||||
'config/scripts/node-server-qualification.mjs',
|
||||
// The source graph sees process-host source, not the build that emits the dist servers load.
|
||||
'src/packages/process-host/.gitignore',
|
||||
|
||||
@@ -59,6 +59,8 @@ function context(os, arch, event, ref, template = false) {
|
||||
},
|
||||
success: () => true,
|
||||
fromJSON: JSON.parse,
|
||||
format: (template, ...values) =>
|
||||
template.replace(/\{(\d+)\}/g, (_match, index) => String(values[Number(index)])),
|
||||
contains: (values, value) => values.includes(value)
|
||||
}
|
||||
}
|
||||
@@ -173,6 +175,38 @@ describe('SSH Windows consumers of qualified server slots', () => {
|
||||
const sshWorkflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8'))
|
||||
const sshSteps = sshWorkflow.jobs.hosts.steps
|
||||
const sshPrepare = sshSteps.find((step) => step.uses === prepare.uses)
|
||||
const inboxBackground = sshSteps.find((step) => step.id === 'inbox-capability')
|
||||
const inboxSerial = sshSteps.find((step) => step.name?.endsWith('after the builds'))
|
||||
const allLanes = [
|
||||
{ arch: 'x64', runner: 'windows-2022', server: 'inbox' },
|
||||
{ arch: 'x64', runner: 'windows-2022', server: 'preview', archive: 'OpenSSH-Win64.zip' },
|
||||
{ arch: 'arm64', runner: 'windows-11-arm', server: 'inbox' },
|
||||
{ arch: 'arm64', runner: 'windows-11-arm', server: 'preview', archive: 'OpenSSH-ARM64.zip' }
|
||||
]
|
||||
|
||||
function sshContext(event, inputs = {}) {
|
||||
const ctx = context('Windows', 'ARM64', event, 'refs/heads/main')
|
||||
ctx.github.event = { inputs, pull_request: { number: undefined } }
|
||||
ctx.inputs = inputs
|
||||
ctx.env = {
|
||||
INBOX_PREPARATION_MODE: sshValue(sshWorkflow.jobs.hosts.env.INBOX_PREPARATION_MODE, ctx)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function sshValue(value, ctx) {
|
||||
return evaluate(value.slice(3, -2).trim(), ctx)
|
||||
}
|
||||
|
||||
function backgroundPreparationRuns(ctx) {
|
||||
const condition = inboxBackground.run
|
||||
.match(/^\s*if\((.*)\)\{\s*$/m)[1]
|
||||
.replace(/'\$\{\{ matrix\.(arch|server) \}\}'/g, (_match, name) => `matrix.${name}`)
|
||||
.replaceAll('$env:INBOX_PREPARATION_MODE', 'env.INBOX_PREPARATION_MODE')
|
||||
.replaceAll(' -eq ', ' === ')
|
||||
.replaceAll(' -and ', ' && ')
|
||||
return evaluate(condition, ctx)
|
||||
}
|
||||
|
||||
it.each(['pull_request', 'workflow_dispatch'])(
|
||||
'restores only PR slots and keeps manual %s qualification fresh',
|
||||
@@ -188,13 +222,8 @@ describe('SSH Windows consumers of qualified server slots', () => {
|
||||
|
||||
it('keeps both architectures, both sshd versions and the existing build order', () => {
|
||||
expect(
|
||||
sshWorkflow.jobs.hosts.strategy.matrix.include.map(({ arch, server }) => [arch, server])
|
||||
).toEqual([
|
||||
['x64', 'inbox'],
|
||||
['x64', 'preview'],
|
||||
['arm64', 'inbox'],
|
||||
['arm64', 'preview']
|
||||
])
|
||||
sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, sshContext('pull_request'))
|
||||
).toEqual(allLanes)
|
||||
const addon = sshSteps.findIndex((step) =>
|
||||
step.run?.includes('build-windows-process-tree-addon.mjs')
|
||||
)
|
||||
@@ -215,6 +244,92 @@ describe('SSH Windows consumers of qualified server slots', () => {
|
||||
'config/scripts/orcad-windows-prebuild-cache.mjs'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pull_request', {}, 'overlap', false],
|
||||
['pull_request', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false],
|
||||
['push', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false],
|
||||
['workflow_dispatch', {}, 'overlap', false],
|
||||
['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: false }, 'overlap', false],
|
||||
['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: false }, 'serial', false],
|
||||
['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: true }, 'overlap', true],
|
||||
['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: true }, 'serial', true]
|
||||
])('%s inputs=%j mode=%s ARM-only=%s', (event, inputs, mode, armOnly) => {
|
||||
const ctx = sshContext(event, inputs)
|
||||
const lanes = sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, ctx)
|
||||
expect(lanes).toEqual(armOnly ? [allLanes[2]] : allLanes)
|
||||
expect(ctx.env.INBOX_PREPARATION_MODE).toBe(mode)
|
||||
for (const matrix of lanes) {
|
||||
ctx.matrix = matrix
|
||||
const inboxArm = matrix.server === 'inbox' && matrix.arch === 'arm64'
|
||||
expect(backgroundPreparationRuns(ctx)).toBe(inboxArm && mode === 'overlap')
|
||||
expect(sshValue(inboxSerial.if, ctx)).toBe(inboxArm && mode === 'serial')
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults to the existing overlap schedule and keeps serial after the successful wait', () => {
|
||||
expect(sshWorkflow.on.workflow_dispatch.inputs.preparation_mode).toMatchObject({
|
||||
type: 'choice',
|
||||
default: 'overlap',
|
||||
options: ['overlap', 'serial']
|
||||
})
|
||||
expect(sshWorkflow.on.workflow_dispatch.inputs.arm_inbox_only).toMatchObject({
|
||||
type: 'boolean',
|
||||
default: false
|
||||
})
|
||||
const dependencies = sshSteps.findIndex((step) => step.id === 'dependencies')
|
||||
const template = sshSteps.findIndex((step) => step.run?.includes('build-orcad-template.mjs'))
|
||||
const wait = sshSteps.findIndex((step) => step.wait === 'inbox-capability')
|
||||
const serial = sshSteps.indexOf(inboxSerial)
|
||||
const hosts = sshSteps.findIndex((step) => step.name?.startsWith('Run the Windows host cells'))
|
||||
expect(inboxBackground.background).toBe(true)
|
||||
expect(inboxBackground.if).toBeUndefined()
|
||||
expect(sshSteps.indexOf(inboxBackground)).toBeLessThan(dependencies)
|
||||
expect(template).toBeLessThan(wait)
|
||||
expect(serial).toBe(wait + 1)
|
||||
expect(hosts).toBe(serial + 1)
|
||||
expect(inboxSerial.background).toBeUndefined()
|
||||
for (const preparation of [inboxBackground, inboxSerial]) {
|
||||
expect(preparation['continue-on-error']).toBeUndefined()
|
||||
expect(preparation.run).toContain(
|
||||
'. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1'
|
||||
)
|
||||
expect(preparation.run).toContain(
|
||||
"-Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE"
|
||||
)
|
||||
}
|
||||
expect(sshSteps[hosts].run).toContain('-InboxPreparationReceipt $preparation')
|
||||
expect(sshWorkflow.jobs.hosts.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
|
||||
expect(sshWorkflow.jobs.hosts.env.ORCA_ISOLATED_SSH_CI).toBe('1')
|
||||
for (const event of ['pull_request', 'workflow_dispatch']) {
|
||||
for (const preparation_mode of ['overlap', 'serial']) {
|
||||
for (const [cells, jobMinutes, hostMinutes] of [
|
||||
['', 75, 50],
|
||||
['orcad-cli-managed', 160, 130]
|
||||
]) {
|
||||
const ctx = sshContext(event, { preparation_mode, arm_inbox_only: true, cells })
|
||||
expect(sshValue(sshWorkflow.jobs.hosts['timeout-minutes'], ctx)).toBe(jobMinutes)
|
||||
expect(sshValue(sshSteps[hosts]['timeout-minutes'], ctx)).toBe(hostMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates manual comparison modes while preserving ordinary cancellation groups', () => {
|
||||
const group = (ctx) =>
|
||||
sshWorkflow.concurrency.group.replace(/\$\{\{(.*?)\}\}/g, (_match, expression) =>
|
||||
String(evaluate(expression.trim(), ctx))
|
||||
)
|
||||
const regular = sshContext('pull_request')
|
||||
regular.github.event.pull_request = { number: 28669 }
|
||||
expect(group(regular)).toBe('ssh-windows-hosts-28669')
|
||||
expect(group(sshContext('workflow_dispatch'))).toBe('ssh-windows-hosts-refs/heads/main')
|
||||
for (const mode of ['overlap', 'serial']) {
|
||||
expect(
|
||||
group(sshContext('workflow_dispatch', { arm_inbox_only: true, preparation_mode: mode }))
|
||||
).toBe(`ssh-windows-hosts-refs/heads/main-${mode}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const commandMocks = `
|
||||
|
||||
@@ -267,6 +267,8 @@ const MANAGED_HOOK_PREFIXES = [
|
||||
|
||||
const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/initialize-windows-native-toolchain',
|
||||
'config/scripts/capture-windows-native-environment',
|
||||
'config/scripts/rebuild-native-deps',
|
||||
'config/scripts/node-pty-job-ownership',
|
||||
'config/scripts/windows-pe-machine',
|
||||
@@ -288,6 +290,8 @@ const NATIVE_CACHE_FILES = new Set([
|
||||
'.npmrc',
|
||||
'.pnpmfile.cjs',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/initialize-windows-native-toolchain.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd',
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'config/scripts/node-pty-job-ownership.cjs',
|
||||
'config/scripts/windows-pe-machine.cjs',
|
||||
|
||||
@@ -37,7 +37,10 @@ const STACK_WRITERS = [
|
||||
'src/renderer/src/runtime/sync-runtime-graph.ts',
|
||||
'src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts',
|
||||
'src/main/runtime/orca-runtime-split-terminal.ts',
|
||||
'src/main/runtime/orca-runtime-sync-window-graph.ts',
|
||||
'src/main/runtime/session-graph/session-window-graph.ts',
|
||||
'src/main/runtime/session-tabs/mobile-session-hydration.ts',
|
||||
'src/main/runtime/session-tabs/mobile-session-reconciliation.ts',
|
||||
'src/main/runtime/session-tabs/mobile-session-preservation.ts',
|
||||
'src/main/runtime/mobile-session-layout-projection.ts',
|
||||
'src/main/runtime/headless-runtime-graph.ts',
|
||||
'src/main/persistence/loading-store/session-host-partitions.ts',
|
||||
|
||||
@@ -38,6 +38,10 @@ const LAYOUT_ORACLE_PREFIXES = [
|
||||
'src/main/runtime/terminal-surface-close.ts',
|
||||
'src/main/runtime/terminal/output/terminal-derived-agent-facts.ts',
|
||||
'src/main/runtime/terminal/output/terminal-output-generation.ts',
|
||||
'src/main/runtime/session-graph/session-window-graph.ts',
|
||||
'src/main/runtime/session-tabs/mobile-session-hydration.ts',
|
||||
'src/main/runtime/session-tabs/mobile-session-reconciliation.ts',
|
||||
'src/main/runtime/session-tabs/mobile-session-preservation.ts',
|
||||
'src/renderer/src/components/terminal/terminal-tab-actions.ts',
|
||||
'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts',
|
||||
'src/shared/rpc-contract/workspace-layout-params.ts',
|
||||
|
||||
@@ -54,6 +54,7 @@ function fixture(onTestFinished, distSeed) {
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
lib: ['ES2022'],
|
||||
module: 'Node16',
|
||||
moduleResolution: 'Node16',
|
||||
rootDir: 'src',
|
||||
|
||||
@@ -69,6 +69,7 @@ function fixture(): { root: string; packageDir: string; main: string } {
|
||||
extends: '../../../tsconfig.base.json',
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
lib: ['ES2022'],
|
||||
module: 'Node16',
|
||||
moduleResolution: 'Node16',
|
||||
rootDir: 'src',
|
||||
|
||||
@@ -153,6 +153,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts',
|
||||
'src/main/native-chat/native-chat-visuals-sweep.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts',
|
||||
|
||||
@@ -2,10 +2,11 @@ import { readdirSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
|
||||
import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy'
|
||||
|
||||
/**
|
||||
* Bare JS scripts cannot use the TS runProcess chokepoint; resolve package bins
|
||||
* under process.execPath instead (see oxc-cli-invocation.mjs). The list only shrinks.
|
||||
* Resolve package bins under process.execPath (see oxc-cli-invocation.mjs).
|
||||
* The legacy list only shrinks; reviewed native compiler references use process-host.
|
||||
* The literal check catches quoted .cmd/.bat paths, excluding drive letters and
|
||||
* templates; comment-only lines are dropped, but trailing comments fail closed.
|
||||
* The AST check catches spawn/spawnSync/execFile/execFileSync first arguments
|
||||
@@ -13,10 +14,10 @@ import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
|
||||
* local variable initializers and ternaries. It does not follow imports, function
|
||||
* returns, assignments, destructuring, aliased spawn names or computed members.
|
||||
* Names are matched without scope/import resolution; shadowed names and non-path
|
||||
* join/resolve calls can fail closed. Paths are joined textually, without reducing
|
||||
* dot segments. Neither check proves a Windows branch or an actual unsafe spawn.
|
||||
* join/resolve calls can fail closed. Package paths fold dot segments.
|
||||
* Neither check proves a Windows branch or an actual unsafe spawn.
|
||||
*/
|
||||
const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/i
|
||||
const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/gi
|
||||
|
||||
const SCANNED_ROOTS = ['config/scripts', 'tests/tools']
|
||||
|
||||
@@ -83,7 +84,13 @@ describe('windows batch shim spawn boundary', () => {
|
||||
)
|
||||
const offenders = scripts.filter((relativePath) => {
|
||||
const contents = readFileSync(path.join(repoRoot, relativePath), 'utf8')
|
||||
return WINDOWS_SHIM_LITERAL.test(codeText(contents)) || hasNodeModulesBinSpawn(contents)
|
||||
const literals = [...codeText(contents).matchAll(WINDOWS_SHIM_LITERAL)].map((match) =>
|
||||
match[0].slice(1, -1)
|
||||
)
|
||||
return (
|
||||
hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals) ||
|
||||
hasNodeModulesBinSpawn(contents)
|
||||
)
|
||||
})
|
||||
|
||||
it('scans a plausible number of scripts', () => {
|
||||
@@ -95,7 +102,7 @@ describe('windows batch shim spawn boundary', () => {
|
||||
const unlisted = offenders.filter((name) => !WINDOWS_SHIM_SPAWN_ALLOWLIST.includes(name))
|
||||
expect(
|
||||
unlisted,
|
||||
'Node cannot spawn a Windows batch shim without a shell. Resolve the real executable — see oxc-cli-invocation.mjs.'
|
||||
'Resolve package shims to their real executable. Fixed native compiler batch runners must pass the scoped process-host policy.'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy'
|
||||
|
||||
const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs'
|
||||
const benchmark = 'config/scripts/benchmark-windows-native-preparation.mjs'
|
||||
const observer = 'config/scripts/windows-native-benchmark-observer.mjs'
|
||||
const metadataCallers = [
|
||||
'config/scripts/node-server-change-scope.mjs',
|
||||
'config/scripts/pr-code-change-scope.mjs',
|
||||
'config/scripts/windows-native-toolchain-routing.test.mjs'
|
||||
]
|
||||
const capture = 'capture-windows-native-environment.cmd'
|
||||
const initializerSource = `
|
||||
import { runProcessSync as captureEnvironment } from '@orca/process-host'
|
||||
const devcmd = join(installation, 'Common7', 'Tools', 'VsDevCmd.bat')
|
||||
existsSync(devcmd)
|
||||
captureEnvironment({
|
||||
program: join(import.meta.dirname, '${capture}'),
|
||||
env: { ORCA_WINDOWS_VSDEVCMD: devcmd }
|
||||
})
|
||||
`
|
||||
const benchmarkSource = `
|
||||
import { runProcess } from '@orca/process-host'
|
||||
const source = 'config/scripts/${capture}'
|
||||
await runProcess({ program: process.execPath, args: [source] })
|
||||
`
|
||||
|
||||
function hasViolation(relativePath: string, contents: string): boolean {
|
||||
const literals = [...contents.matchAll(/['"][\w./\\-]*\.(?:cmd|bat)['"]/gi)].map((match) =>
|
||||
match[0].slice(1, -1)
|
||||
)
|
||||
return hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals)
|
||||
}
|
||||
|
||||
describe('reviewed Windows native batch references', () => {
|
||||
it.each([...metadataCallers, initializer, benchmark, observer])(
|
||||
'accepts the current reviewed source in %s',
|
||||
(relativePath) => {
|
||||
const root = path.resolve(import.meta.dirname, '..', '..')
|
||||
expect(hasViolation(relativePath, readFileSync(path.join(root, relativePath), 'utf8'))).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(metadataCallers)('allows the fixed source path as metadata in %s', (relativePath) => {
|
||||
expect(hasViolation(relativePath, `const source = 'config/scripts/${capture}'`)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts the process-host executor through its imported alias', () => {
|
||||
expect(hasViolation(initializer, initializerSource)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts the benchmark executor', () => {
|
||||
expect(hasViolation(benchmark, benchmarkSource)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'config/scripts/other-native-preparation.mjs',
|
||||
'tests/tools/initialize-windows-native-toolchain.mjs',
|
||||
`config/scripts/nested/${path.basename(initializer)}`
|
||||
])('rejects the fixed runner in another caller: %s', (relativePath) => {
|
||||
expect(hasViolation(relativePath, initializerSource)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'elsewhere/capture-windows-native-environment.cmd',
|
||||
'./capture-windows-native-environment.cmd',
|
||||
'CAPTURE-WINDOWS-NATIVE-ENVIRONMENT.cmd',
|
||||
'other.cmd',
|
||||
'other.bat'
|
||||
])('rejects another batch path in a reviewed executor: %s', (literal) => {
|
||||
expect(hasViolation(initializer, `${initializerSource}\nconst other = '${literal}'`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(metadataCallers)('rejects other batch metadata in %s', (relativePath) => {
|
||||
expect(
|
||||
hasViolation(relativePath, `const sources = ['config/scripts/${capture}', 'other.cmd']`)
|
||||
).toBe(true)
|
||||
expect(hasViolation(relativePath, `const command = 'VsDevCmd.bat'`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[initializer, initializerSource],
|
||||
[benchmark, benchmarkSource],
|
||||
[observer, `require('@orca/process-host'); const capture = '${capture}'`]
|
||||
])('rejects a missing process-host load in %s', (relativePath, contents) => {
|
||||
expect(hasViolation(relativePath, contents.replace('@orca/process-host', 'other-runner'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unused process-host import beside an unverified executor', () => {
|
||||
expect(
|
||||
hasViolation(initializer, initializerSource.replace('captureEnvironment({', 'otherRunner({'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects another executor for the fixed capture script beside a valid call', () => {
|
||||
expect(
|
||||
hasViolation(
|
||||
initializer,
|
||||
`${initializerSource}\notherRunner({ program: join(import.meta.dirname, '${capture}') })`
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"import { spawnSync as execute } from 'node:child_process'",
|
||||
"import * as cp from 'child_process'",
|
||||
"await import('node:child_process')",
|
||||
"const builtins = 'node:child_process'; process.getBuiltinModule(builtins)",
|
||||
"const require = createRequire(import.meta.url); const cp = require('child_process')"
|
||||
])('rejects raw child-process module loads: %s', (load) => {
|
||||
expect(hasViolation(initializer, `${initializerSource}\n${load}`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"spawn('program', [])",
|
||||
"cp.spawnSync('program', [])",
|
||||
"cp['execFile']('program', [])",
|
||||
"execFileSync('program', [])",
|
||||
"exec('program')",
|
||||
"execSync('program')",
|
||||
"fork('program')"
|
||||
])('rejects raw process calls: %s', (call) => {
|
||||
expect(hasViolation(initializer, `${initializerSource}\n${call}`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"runProcess({ program: 'node_modules/.bin/pnpm' })",
|
||||
"const bin = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program: bin })",
|
||||
"runProcess({ program: join(root, 'node_modules', 'tools', '..', '.bin', 'pnpm') })",
|
||||
"const spec = { 'program': 'root\\\\node_modules\\\\.bin\\\\pnpm' }; runProcess(spec)",
|
||||
"const program = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program })",
|
||||
"runProcess({ ['program']: join(root, 'node_modules', '.bin', 'pnpm') })"
|
||||
])('rejects package-bin programs through process-host: %s', (call) => {
|
||||
expect(hasViolation(benchmark, `${benchmarkSource}\n${call}`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(metadataCallers)('rejects process execution added to metadata in %s', (relativePath) => {
|
||||
expect(
|
||||
hasViolation(
|
||||
relativePath,
|
||||
`const capture = 'config/scripts/${capture}'; runProcess({ program: capture })`
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasViolation(
|
||||
relativePath,
|
||||
`import { runProcess as execute } from '@orca/process-host'; const capture = 'config/scripts/${capture}'; execute({ program: capture })`
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"captureEnvironment({ program: 'VsDevCmd.bat' })",
|
||||
'captureEnvironment({ program: devcmd })',
|
||||
'const program = devcmd; captureEnvironment({ program })',
|
||||
"const devcmd = join(other, 'VsDevCmd.bat')"
|
||||
])('keeps the discovered developer command as metadata: %s', (extra) => {
|
||||
expect(hasViolation(initializer, `${initializerSource}\n${extra}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import ts from 'typescript-api'
|
||||
import {
|
||||
collectModuleSpecifiers,
|
||||
literalString,
|
||||
propertyName
|
||||
} from './static-module-specifiers.mjs'
|
||||
import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
|
||||
|
||||
const references = new Map([
|
||||
[
|
||||
'config/scripts/node-server-change-scope.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/pr-code-change-scope.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/windows-native-toolchain-routing.test.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/windows-native-benchmark-observer.mjs',
|
||||
'capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/initialize-windows-native-toolchain.mjs',
|
||||
'capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/benchmark-windows-native-preparation.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
]
|
||||
])
|
||||
const executors = new Map([
|
||||
['config/scripts/initialize-windows-native-toolchain.mjs', 'runProcessSync'],
|
||||
['config/scripts/benchmark-windows-native-preparation.mjs', 'runProcess']
|
||||
])
|
||||
const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs'
|
||||
const observer = 'config/scripts/windows-native-benchmark-observer.mjs'
|
||||
const rawProcessMethods = new Set([
|
||||
'spawn',
|
||||
'spawnSync',
|
||||
'exec',
|
||||
'execSync',
|
||||
'execFile',
|
||||
'execFileSync',
|
||||
'fork'
|
||||
])
|
||||
const processHostMethods = new Set(['runProcess', 'runProcessSync', 'spawnProcess'])
|
||||
|
||||
function callName(node: ts.Expression): string {
|
||||
return ts.isIdentifier(node) ? node.text : (propertyName(node) ?? '')
|
||||
}
|
||||
|
||||
function isDeveloperCommandMetadata(node: ts.StringLiteral): boolean {
|
||||
const call = node.parent
|
||||
const declaration = call.parent
|
||||
return (
|
||||
ts.isCallExpression(call) &&
|
||||
callName(call.expression) === 'join' &&
|
||||
call.arguments.at(-1) === node &&
|
||||
ts.isVariableDeclaration(declaration) &&
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
declaration.name.text === 'devcmd'
|
||||
)
|
||||
}
|
||||
|
||||
function isDeveloperCommandUse(node: ts.Identifier): boolean {
|
||||
const parent = node.parent
|
||||
return (
|
||||
(ts.isVariableDeclaration(parent) && parent.name === node) ||
|
||||
(ts.isCallExpression(parent) &&
|
||||
callName(parent.expression) === 'existsSync' &&
|
||||
parent.arguments[0] === node) ||
|
||||
(ts.isPropertyAssignment(parent) &&
|
||||
parent.initializer === node &&
|
||||
ts.isIdentifier(parent.name) &&
|
||||
parent.name.text === 'ORCA_WINDOWS_VSDEVCMD')
|
||||
)
|
||||
}
|
||||
|
||||
function isProcessHostCapture(node: ts.StringLiteral, importedMethods: Set<string>): boolean {
|
||||
const path = node.parent
|
||||
if (
|
||||
!ts.isCallExpression(path) ||
|
||||
callName(path.expression) !== 'join' ||
|
||||
path.arguments.length !== 2 ||
|
||||
path.arguments[0].getText() !== 'import.meta.dirname' ||
|
||||
path.arguments[1] !== node
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const program = path.parent
|
||||
if (
|
||||
!ts.isPropertyAssignment(program) ||
|
||||
!ts.isIdentifier(program.name) ||
|
||||
program.name.text !== 'program'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const spec = program.parent
|
||||
const call = spec.parent
|
||||
return (
|
||||
ts.isObjectLiteralExpression(spec) &&
|
||||
ts.isCallExpression(call) &&
|
||||
call.arguments[0] === spec &&
|
||||
ts.isIdentifier(call.expression) &&
|
||||
importedMethods.has(call.expression.text)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasWindowsNativeBatchReferenceViolation(
|
||||
relativePath: string,
|
||||
contents: string,
|
||||
literals: readonly string[]
|
||||
): boolean {
|
||||
const capture = references.get(relativePath)
|
||||
if (!capture) {
|
||||
return literals.length > 0
|
||||
}
|
||||
if (
|
||||
literals.some(
|
||||
(literal) =>
|
||||
literal !== capture && !(relativePath === initializer && literal === 'VsDevCmd.bat')
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const modules = collectModuleSpecifiers(relativePath, contents)
|
||||
if (modules.some((module) => module === 'child_process' || module === 'node:child_process')) {
|
||||
return true
|
||||
}
|
||||
const expectedMethod = executors.get(relativePath)
|
||||
if ((expectedMethod || relativePath === observer) && !modules.includes('@orca/process-host')) {
|
||||
return true
|
||||
}
|
||||
if (!expectedMethod && relativePath !== observer && modules.includes('@orca/process-host')) {
|
||||
return true
|
||||
}
|
||||
const source = ts.createSourceFile(relativePath, contents, ts.ScriptTarget.Latest, true)
|
||||
const importedMethods = new Set<string>()
|
||||
const calls: ts.CallExpression[] = []
|
||||
const programs: ts.Expression[] = []
|
||||
const captureReferences: ts.StringLiteral[] = []
|
||||
let violation = false
|
||||
let developerCommandDeclarations = 0
|
||||
function visit(node: ts.Node): void {
|
||||
if (
|
||||
ts.isImportDeclaration(node) &&
|
||||
ts.isStringLiteral(node.moduleSpecifier) &&
|
||||
node.moduleSpecifier.text === '@orca/process-host'
|
||||
) {
|
||||
const bindings = node.importClause?.namedBindings
|
||||
if (bindings && ts.isNamedImports(bindings)) {
|
||||
for (const binding of bindings.elements) {
|
||||
if ((binding.propertyName ?? binding.name).text === expectedMethod) {
|
||||
importedMethods.add(binding.name.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ts.isCallExpression(node)) {
|
||||
calls.push(node)
|
||||
const method = callName(node.expression)
|
||||
if (rawProcessMethods.has(method)) {
|
||||
violation = true
|
||||
}
|
||||
if (!expectedMethod && relativePath !== observer && processHostMethods.has(method)) {
|
||||
violation = true
|
||||
}
|
||||
}
|
||||
if (
|
||||
ts.isPropertyAssignment(node) &&
|
||||
((ts.isIdentifier(node.name) && node.name.text === 'program') ||
|
||||
(ts.isStringLiteral(node.name) && node.name.text === 'program') ||
|
||||
(ts.isComputedPropertyName(node.name) && literalString(node.name.expression) === 'program'))
|
||||
) {
|
||||
programs.push(node.initializer)
|
||||
}
|
||||
if (ts.isShorthandPropertyAssignment(node) && node.name.text === 'program') {
|
||||
programs.push(node.name)
|
||||
}
|
||||
if (relativePath === initializer) {
|
||||
if (ts.isStringLiteral(node) && node.text === capture) {
|
||||
captureReferences.push(node)
|
||||
}
|
||||
if (
|
||||
ts.isStringLiteral(node) &&
|
||||
node.text === 'VsDevCmd.bat' &&
|
||||
!isDeveloperCommandMetadata(node)
|
||||
) {
|
||||
violation = true
|
||||
}
|
||||
if (ts.isIdentifier(node) && node.text === 'devcmd') {
|
||||
if (!isDeveloperCommandUse(node)) {
|
||||
violation = true
|
||||
}
|
||||
if (ts.isVariableDeclaration(node.parent) && node.parent.name === node) {
|
||||
developerCommandDeclarations += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
if (
|
||||
expectedMethod &&
|
||||
!calls.some(
|
||||
(call) => ts.isIdentifier(call.expression) && importedMethods.has(call.expression.text)
|
||||
)
|
||||
) {
|
||||
violation = true
|
||||
}
|
||||
if (relativePath === initializer && developerCommandDeclarations !== 1) {
|
||||
violation = true
|
||||
}
|
||||
if (captureReferences.some((node) => !isProcessHostCapture(node, importedMethods))) {
|
||||
violation = true
|
||||
}
|
||||
// Reuse package-path folding for process-host's named program option.
|
||||
return (
|
||||
violation ||
|
||||
hasNodeModulesBinSpawn(contents) ||
|
||||
programs.some((program) =>
|
||||
hasNodeModulesBinSpawn(`${contents}\nspawnSync(${program.getText(source)})`)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { basename } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
export const compilerEnvironmentKey =
|
||||
/^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i
|
||||
export const hash = (value) => createHash('sha256').update(value).digest('hex')
|
||||
|
||||
export function environmentReceipt(environment) {
|
||||
const entries = Object.entries(environment)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([name, value]) => [name.toLowerCase(), String(value)])
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
assert.equal(
|
||||
new Set(entries.map(([name]) => name)).size,
|
||||
entries.length,
|
||||
'Duplicate environment keys'
|
||||
)
|
||||
return {
|
||||
hash: hash(JSON.stringify(entries)),
|
||||
versions: Object.fromEntries(
|
||||
entries.filter(
|
||||
([name, value]) =>
|
||||
/^(visualstudioversion|vscmd_ver|windowssdkversion|vctoolsversion|vscmd_arg_tgt_arch)$/.test(
|
||||
name
|
||||
) && /^(?:[\d.]+\\?|x64|amd64|arm64)$/.test(value)
|
||||
)
|
||||
),
|
||||
compiler: Object.fromEntries(
|
||||
entries
|
||||
.filter(([name]) => compilerEnvironmentKey.test(name))
|
||||
.map(([name, value]) => [name, hash(value)])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function changedEnvironmentNames(before, after) {
|
||||
const normalize = (environment) =>
|
||||
new Map(Object.entries(environment).map(([name, value]) => [name.toLowerCase(), value]))
|
||||
const original = normalize(before)
|
||||
const prepared = normalize(after)
|
||||
return [...new Set([...original.keys(), ...prepared.keys()])]
|
||||
.filter((name) => original.get(name) !== prepared.get(name))
|
||||
.sort()
|
||||
}
|
||||
|
||||
export function processPhase(spec) {
|
||||
const name = basename(spec.program).toLowerCase()
|
||||
if (name === 'vswhere.exe') {
|
||||
return 'discovery'
|
||||
}
|
||||
if (name === 'capture-windows-native-environment.cmd') {
|
||||
return 'capture'
|
||||
}
|
||||
if (spec.args?.some((arg) => arg.endsWith('ensure-native-runtime.mjs'))) {
|
||||
return spec.args.includes('--check-only') ? 'probe' : 'preparation'
|
||||
}
|
||||
return spec.args?.includes('rebuild') ? 'addon-build' : 'other'
|
||||
}
|
||||
|
||||
export function installNativeBenchmarkObserver(api, output, environment = process.env) {
|
||||
const original = api.runProcessSync
|
||||
const parent = environmentReceipt(environment)
|
||||
const receipt = { parentBefore: parent, parentAfter: parent, calls: [] }
|
||||
writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`)
|
||||
api.runProcessSync = (spec) => {
|
||||
const startedAt = new Date().toISOString()
|
||||
const start = performance.now()
|
||||
const childEnvironment = spec.env ?? environment
|
||||
const call = {
|
||||
phase: processPhase(spec),
|
||||
program: basename(spec.program),
|
||||
args: spec.args,
|
||||
startedAt,
|
||||
environment: environmentReceipt(childEnvironment),
|
||||
changedNames: changedEnvironmentNames(environment, childEnvironment)
|
||||
}
|
||||
try {
|
||||
const result = original(spec)
|
||||
Object.assign(call, {
|
||||
code: result.code,
|
||||
timedOut: result.timedOut,
|
||||
outputTruncated: result.outputTruncated,
|
||||
stdoutSha256: hash(result.stdout ?? ''),
|
||||
stderrSha256: hash(result.stderr ?? ''),
|
||||
stdoutBytes: Buffer.byteLength(result.stdout ?? ''),
|
||||
stderrBytes: Buffer.byteLength(result.stderr ?? '')
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
call.error = error.message
|
||||
throw error
|
||||
} finally {
|
||||
call.durationMs = performance.now() - start
|
||||
call.finishedAt = new Date().toISOString()
|
||||
receipt.calls.push(call)
|
||||
receipt.parentAfter = environmentReceipt(environment)
|
||||
assert.equal(
|
||||
receipt.parentAfter.hash,
|
||||
parent.hash,
|
||||
'Compiler setup changed its parent environment'
|
||||
)
|
||||
writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`)
|
||||
}
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
if (process.env.ORCA_NATIVE_BENCHMARK_TRACE) {
|
||||
const require = createRequire(import.meta.url)
|
||||
installNativeBenchmarkObserver(
|
||||
require('@orca/process-host'),
|
||||
process.env.ORCA_NATIVE_BENCHMARK_TRACE
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { test } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import {
|
||||
assertNativeColdProbe,
|
||||
assertNoHeaderDownloads,
|
||||
validateObservedPreparation
|
||||
} from './benchmark-windows-native-preparation.mjs'
|
||||
import {
|
||||
changedEnvironmentNames,
|
||||
environmentReceipt,
|
||||
installNativeBenchmarkObserver
|
||||
} from './windows-native-benchmark-observer.mjs'
|
||||
|
||||
const original = { Path: 'unchanged', TOKEN: 'private' }
|
||||
const parent = environmentReceipt(original)
|
||||
const captureEnvironment = { ...original, VCINSTALLDIR: 'compiler' }
|
||||
const call = (phase, code = 0, prepared = false) => ({
|
||||
phase,
|
||||
code,
|
||||
environment: prepared ? environmentReceipt(captureEnvironment) : parent,
|
||||
changedNames: prepared ? ['vcinstalldir'] : []
|
||||
})
|
||||
const receipt = (calls) => ({ parentBefore: parent, parentAfter: parent, calls })
|
||||
const candidate = () =>
|
||||
receipt([call('probe', 1), call('discovery'), call('capture'), call('preparation', 0, true)])
|
||||
|
||||
for (const [variant, warm, calls] of [
|
||||
[
|
||||
'control',
|
||||
false,
|
||||
[call('addon-build'), call('addon-build'), call('addon-build'), call('addon-build')]
|
||||
],
|
||||
['control', true, []],
|
||||
['candidate', false, candidate().calls],
|
||||
['candidate', true, [call('probe'), call('preparation')]],
|
||||
[
|
||||
'fallback',
|
||||
false,
|
||||
[call('probe', 1), call('discovery'), call('capture', 7), call('preparation')]
|
||||
],
|
||||
['fallback', true, [call('probe'), call('preparation')]]
|
||||
]) {
|
||||
test(`qualifies ${variant} ${warm ? 'warm' : 'cold'} receipts`, () =>
|
||||
validateObservedPreparation(receipt(calls), variant, warm))
|
||||
}
|
||||
|
||||
test('fails closed on an unobserved or unchanged candidate', () => {
|
||||
assert.throws(() => validateObservedPreparation(receipt([]), 'candidate'), /discovery count/)
|
||||
const missingCapture = candidate()
|
||||
missingCapture.calls.splice(2, 1)
|
||||
assert.throws(
|
||||
() => validateObservedPreparation(missingCapture, 'candidate'),
|
||||
/developer command count/
|
||||
)
|
||||
const unchanged = candidate()
|
||||
unchanged.calls[3] = call('preparation')
|
||||
assert.throws(() => validateObservedPreparation(unchanged, 'candidate'), /not applied/)
|
||||
})
|
||||
|
||||
test('rejects failed preparation and unrelated or persistent environment changes', () => {
|
||||
for (const mutate of [
|
||||
(value) => {
|
||||
value.calls[3].code = 2
|
||||
},
|
||||
(value) => {
|
||||
value.calls[3].changedNames = ['token']
|
||||
},
|
||||
(value) => {
|
||||
value.parentAfter = environmentReceipt(captureEnvironment)
|
||||
},
|
||||
(value) => {
|
||||
value.calls[2].timedOut = true
|
||||
}
|
||||
]) {
|
||||
const value = candidate()
|
||||
mutate(value)
|
||||
assert.throws(() => validateObservedPreparation(value, 'candidate'))
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
validateObservedPreparation(
|
||||
receipt([call('probe'), call('preparation', 0, true)]),
|
||||
'candidate',
|
||||
true
|
||||
),
|
||||
/changed compiler/
|
||||
)
|
||||
})
|
||||
|
||||
test('requires all four initial addon failures and excludes timed header downloads', () => {
|
||||
const result = {
|
||||
code: 1,
|
||||
stderr: [
|
||||
'node-pty',
|
||||
'@orca/windows-registry',
|
||||
'@orca/windows-security',
|
||||
'@vscode/windows-process-tree'
|
||||
]
|
||||
.map((name) => `${name}: unavailable`)
|
||||
.join('\n')
|
||||
}
|
||||
assertNativeColdProbe(result)
|
||||
assert.throws(() =>
|
||||
assertNativeColdProbe({ ...result, stderr: result.stderr.replace('node-pty:', 'missing:') })
|
||||
)
|
||||
assertNoHeaderDownloads({ stdout: '', stderr: 'gyp info find VS using VS2026' })
|
||||
assert.throws(
|
||||
() =>
|
||||
assertNoHeaderDownloads({
|
||||
stdout: '',
|
||||
stderr: 'gyp http GET https://nodejs.org/header.tar.gz'
|
||||
}),
|
||||
/downloaded/
|
||||
)
|
||||
})
|
||||
|
||||
test('uses case-insensitive environment comparisons and never publishes values', () => {
|
||||
assert.equal(
|
||||
environmentReceipt(original).hash,
|
||||
environmentReceipt({ PATH: 'unchanged', token: 'private' }).hash
|
||||
)
|
||||
assert.deepEqual(
|
||||
changedEnvironmentNames(original, {
|
||||
PATH: 'unchanged',
|
||||
TOKEN: 'private',
|
||||
VCINSTALLDIR: 'compiler'
|
||||
}),
|
||||
['vcinstalldir']
|
||||
)
|
||||
assert(!JSON.stringify(parent).includes('private'))
|
||||
assert.throws(() => environmentReceipt({ PATH: 'first', Path: 'second' }), /Duplicate/)
|
||||
})
|
||||
|
||||
test('writes an empty warm trace and observes calls without changing execution', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-test-'))
|
||||
try {
|
||||
const output = join(directory, 'trace.json')
|
||||
const result = { code: 0, stdout: 'original stdout', stderr: '', timedOut: false }
|
||||
const seen = []
|
||||
const api = {
|
||||
runProcessSync: (spec) => {
|
||||
seen.push(spec)
|
||||
return result
|
||||
}
|
||||
}
|
||||
installNativeBenchmarkObserver(api, output, original)
|
||||
assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')).calls, [])
|
||||
const spec = { program: 'vswhere.exe', args: ['-latest'], stdio: 'pipe' }
|
||||
assert.equal(api.runProcessSync(spec), result)
|
||||
assert.deepEqual(seen, [spec])
|
||||
const value = JSON.parse(readFileSync(output, 'utf8'))
|
||||
assert.equal(value.calls[0].phase, 'discovery')
|
||||
assert.equal(value.calls[0].stdoutBytes, result.stdout.length)
|
||||
assert.equal(value.parentBefore.hash, value.parentAfter.hash)
|
||||
assert(!readFileSync(output, 'utf8').includes('original stdout'))
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preload observes the CJS process-host export imported by production ESM code', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-esm-test-'))
|
||||
try {
|
||||
const trace = join(directory, 'trace.json')
|
||||
const program = `import { runProcessSync } from '@orca/process-host'; const value = runProcessSync({program:process.execPath,args:['-e','process.stdout.write("proof")']}); if(value.stdout !== 'proof') process.exit(2);`
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
'--import',
|
||||
pathToFileURL(join(import.meta.dirname, 'windows-native-benchmark-observer.mjs')).href,
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
program
|
||||
],
|
||||
cwd: join(import.meta.dirname, '../..'),
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_NATIVE_BENCHMARK_TRACE: trace }
|
||||
})
|
||||
assert.equal(result.code, 0, result.stderr)
|
||||
const value = JSON.parse(readFileSync(trace, 'utf8'))
|
||||
assert.equal(value.calls.length, 1)
|
||||
assert.equal(value.calls[0].code, 0)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('manual workflow freezes source and limits Windows occupancy without changing a PR gate', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(
|
||||
join(import.meta.dirname, '../../.github/workflows/windows-native-preparation-benchmark.yml'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
assert.deepEqual(Object.keys(workflow.on), ['workflow_dispatch'])
|
||||
assert.equal(workflow.jobs.compare.strategy['max-parallel'], 2)
|
||||
assert.deepEqual(workflow.jobs.compare.strategy.matrix.os, ['windows-2022', 'windows-11-arm'])
|
||||
assert.equal(workflow.jobs.compare.steps[0].with.ref, '${{ inputs.source_sha }}')
|
||||
const install = workflow.jobs.compare.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
assert.equal(install.with['native-runtime'], 'none')
|
||||
assert.equal(install.with['node-version'], '24.21.0')
|
||||
assert.equal(install.with['cache-pnpm-store'], 'false')
|
||||
assert.equal(workflow.jobs.compare.steps.at(-1).if, 'always()')
|
||||
assert(!Object.hasOwn(workflow.jobs, 'verify'))
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
import { classifyNodeServerChanges } from './node-server-change-scope.mjs'
|
||||
import { nodeServerQualification } from './node-server-qualification.mjs'
|
||||
|
||||
const toolchainSources = [
|
||||
'config/scripts/initialize-windows-native-toolchain.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
]
|
||||
|
||||
describe('Windows native toolchain CI routing', () => {
|
||||
it.each(toolchainSources)(
|
||||
'prepares native caches and packages for a change to %s alone',
|
||||
(file) => {
|
||||
expect(classifyPrJobs([file])).toMatchObject({
|
||||
should_run: true,
|
||||
native_cache_changed: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(toolchainSources)(
|
||||
'selects both Windows server runners for a change to %s alone',
|
||||
async (file) => {
|
||||
const collect = vi.fn(async () => new Set())
|
||||
for (const options of [
|
||||
{},
|
||||
{ deferGraph: true },
|
||||
{ modifiedFiles: [file] },
|
||||
{ deferGraph: true, modifiedFiles: [file] }
|
||||
]) {
|
||||
const scope = await classifyNodeServerChanges([file], collect, options)
|
||||
expect(scope).toEqual({ shouldRun: true, reason: `Build or CI input changed: ${file}` })
|
||||
expect(nodeServerQualification([file], scope)).toEqual({
|
||||
qualification: false,
|
||||
runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm']
|
||||
})
|
||||
}
|
||||
expect(collect).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves unrelated build-script routing', async () => {
|
||||
const file = 'config/scripts/unrelated-toolchain.mjs'
|
||||
expect(classifyPrJobs([file])).toMatchObject({
|
||||
test: true,
|
||||
native_cache_changed: false,
|
||||
package: false,
|
||||
package_windows: false
|
||||
})
|
||||
expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -5,13 +5,9 @@
|
||||
# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)
|
||||
src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
|
||||
src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
|
||||
src/main/runtime/orca-runtime-attach-window.ts
|
||||
src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
|
||||
src/main/runtime/orca-runtime-build-pty-terminal-summary.ts
|
||||
src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
|
||||
src/main/runtime/orca-runtime-close-mobile-session-tab.ts
|
||||
src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
|
||||
src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts
|
||||
src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts
|
||||
src/main/runtime/orca-runtime-create-agent-session.ts
|
||||
src/main/runtime/orca-runtime-create-mobile-session-terminal.ts
|
||||
@@ -26,29 +22,23 @@ src/main/runtime/orca-runtime-focus-terminal.ts
|
||||
src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts
|
||||
src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts
|
||||
src/main/runtime/orca-runtime-get-runtime-id.ts
|
||||
src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts
|
||||
src/main/runtime/orca-runtime-get-worktree-ps.ts
|
||||
src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts
|
||||
src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts
|
||||
src/main/runtime/orca-runtime-has-live-or-persisted-serve-or-ssh-owned-pty-binding.ts
|
||||
src/main/runtime/orca-runtime-has-terminals-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts
|
||||
src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts
|
||||
src/main/runtime/orca-runtime-linear-commands.ts
|
||||
src/main/runtime/orca-runtime-list-managed-worktrees.ts
|
||||
src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts
|
||||
src/main/runtime/orca-runtime-notify-ssh-state-changed.ts
|
||||
src/main/runtime/orca-runtime-on-pty-exit.ts
|
||||
src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts
|
||||
src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
|
||||
src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
|
||||
src/main/runtime/orca-runtime-prepare-pty-execution-context.ts
|
||||
src/main/runtime/orca-runtime-preserved-branch-cleanup.ts
|
||||
src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts
|
||||
src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
|
||||
src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts
|
||||
src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts
|
||||
src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts
|
||||
src/main/runtime/orca-runtime-refresh-pty-worktree-records-from-controller.ts
|
||||
src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts
|
||||
src/main/runtime/orca-runtime-refuse-unattributed-mobile-session-tab-close.ts
|
||||
@@ -57,8 +47,6 @@ src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktre
|
||||
src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts
|
||||
src/main/runtime/orca-runtime-resolve-terminal-pane.ts
|
||||
src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts
|
||||
src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts
|
||||
src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts
|
||||
src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts
|
||||
src/main/runtime/orca-runtime-runtime-id.ts
|
||||
src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts
|
||||
@@ -68,16 +56,11 @@ src/main/runtime/orca-runtime-split-pty-backed-terminal.ts
|
||||
src/main/runtime/orca-runtime-split-terminal.ts
|
||||
src/main/runtime/orca-runtime-state-fields.ts
|
||||
src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
|
||||
src/main/runtime/orca-runtime-stop-requested-pty-ids.ts
|
||||
src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts
|
||||
src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts
|
||||
src/main/runtime/orca-runtime-sync-window-graph.ts
|
||||
src/main/runtime/orca-runtime-terminal-create-deduplication.ts
|
||||
src/main/runtime/orca-runtime-terminal-drivers.ts
|
||||
src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts
|
||||
src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts
|
||||
src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts
|
||||
src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"fflate": "0.8.3",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react-native": "^1.14.0",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it-footnote": "^4.0.0",
|
||||
"mermaid": "11.17.2",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
@@ -67,6 +69,7 @@
|
||||
"react-native-web": "^0.21.3",
|
||||
"react-native-webview": "13.16.2",
|
||||
"react-native-worklets": "^0.8.3",
|
||||
"remend": "1.4.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.22.0",
|
||||
"zod": "~4.6.5",
|
||||
@@ -74,6 +77,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@orca/process-host": "link:../src/packages/process-host",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/markdown-it-footnote": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/react": "~19.2.18",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
Generated
+105
@@ -275,6 +275,12 @@ importers:
|
||||
lucide-react-native:
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.0(react-native-svg@15.15.5(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
|
||||
markdown-it:
|
||||
specifier: ^14.3.2
|
||||
version: 14.3.2
|
||||
markdown-it-footnote:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
mermaid:
|
||||
specifier: 11.17.2
|
||||
version: 11.17.2
|
||||
@@ -314,6 +320,9 @@ importers:
|
||||
react-native-worklets:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
|
||||
remend:
|
||||
specifier: 1.4.0
|
||||
version: 1.4.0
|
||||
tweetnacl:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
@@ -330,6 +339,15 @@ importers:
|
||||
'@orca/process-host':
|
||||
specifier: link:../src/packages/process-host
|
||||
version: link:../src/packages/process-host
|
||||
'@types/markdown-it':
|
||||
specifier: 14.1.2
|
||||
version: 14.1.2
|
||||
'@types/markdown-it-footnote':
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
'@types/mdast':
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4
|
||||
'@types/react':
|
||||
specifier: ~19.2.18
|
||||
version: 19.2.18
|
||||
@@ -2883,6 +2901,24 @@ packages:
|
||||
'@types/json5@0.0.29':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
'@types/markdown-it-footnote@3.0.4':
|
||||
resolution: {integrity: sha512-XJ6n+v+2u+2gmYLSHcxyoNT/YrgrKvHuDJQlykFjuxCQCr86P2/fx1V6/0lcKxv5cSIlCaJ6sUcNS3zDI7I+LA==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
'@types/markdown-it@14.2.0':
|
||||
resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/mdurl@2.0.0':
|
||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||
|
||||
'@types/node@25.6.0':
|
||||
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||
|
||||
@@ -5443,6 +5479,9 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkify-it@5.0.2:
|
||||
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5514,6 +5553,13 @@ packages:
|
||||
makeerror@1.0.12:
|
||||
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
|
||||
|
||||
markdown-it-footnote@4.0.0:
|
||||
resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==}
|
||||
|
||||
markdown-it@14.3.2:
|
||||
resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==}
|
||||
hasBin: true
|
||||
|
||||
marked@16.4.2:
|
||||
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -5529,6 +5575,9 @@ packages:
|
||||
mdn-data@2.0.14:
|
||||
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
|
||||
|
||||
mdurl@2.1.0:
|
||||
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
|
||||
|
||||
memoize-one@5.2.1:
|
||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||
|
||||
@@ -6071,6 +6120,10 @@ packages:
|
||||
psl@1.15.0:
|
||||
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6281,6 +6334,9 @@ packages:
|
||||
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
|
||||
hasBin: true
|
||||
|
||||
remend@1.4.0:
|
||||
resolution: {integrity: sha512-mHiYhhwphs1IgIZmkToKLwJn0U99IjPXuutkA2acrNcG0vlEq2G4TGnd2eT0saIHwh2KjaCpvleHcsd5SAz96A==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -6840,6 +6896,9 @@ packages:
|
||||
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -10239,6 +10298,28 @@ snapshots:
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it-footnote@3.0.4':
|
||||
dependencies:
|
||||
'@types/markdown-it': 14.2.0
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
|
||||
'@types/markdown-it@14.2.0':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/mdurl@2.0.0': {}
|
||||
|
||||
'@types/node@25.6.0':
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
@@ -13388,6 +13469,10 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkify-it@5.0.2:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
@@ -13455,6 +13540,17 @@ snapshots:
|
||||
dependencies:
|
||||
tmpl: 1.0.5
|
||||
|
||||
markdown-it-footnote@4.0.0: {}
|
||||
|
||||
markdown-it@14.3.2:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.2
|
||||
mdurl: 2.1.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
marked@16.4.2: {}
|
||||
|
||||
marky@1.3.0: {}
|
||||
@@ -13463,6 +13559,8 @@ snapshots:
|
||||
|
||||
mdn-data@2.0.14: {}
|
||||
|
||||
mdurl@2.1.0: {}
|
||||
|
||||
memoize-one@5.2.1: {}
|
||||
|
||||
memoize-one@6.0.0: {}
|
||||
@@ -14244,6 +14342,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
@@ -14520,6 +14620,8 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 3.1.0
|
||||
|
||||
remend@1.4.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
requires-port@1.0.0: {}
|
||||
@@ -15102,6 +15204,8 @@ snapshots:
|
||||
|
||||
ua-parser-js@1.0.41: {}
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -15408,3 +15512,4 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/emscripten': 1.41.5
|
||||
type-fest: 5.6.0
|
||||
|
||||
|
||||
+482
-494
File diff suppressed because it is too large
Load Diff
@@ -4,80 +4,6 @@
|
||||
"family": "settings.repo-metadata",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"03c9a2ba60b9": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"0c4296f215a4": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-3",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"settings": {
|
||||
"defaultTuiAgent": "codex",
|
||||
"disabledTuiAgents": ["claude"],
|
||||
"hostSettingOverrides": {},
|
||||
"prBotAuthorOverrides": ["bot-user"],
|
||||
"visibleTaskProviders": ["github", "linear"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"12e70439f294": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 1,
|
||||
@@ -145,18 +71,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"19b2979d7fc2": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"1a160fc2d137": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"18762ff064ed": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
@@ -172,10 +93,29 @@
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-3",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"settings": {
|
||||
"defaultTuiAgent": "codex",
|
||||
"disabledTuiAgents": ["claude"],
|
||||
"hostSettingOverrides": {},
|
||||
"prBotAuthorOverrides": ["bot-user"],
|
||||
"visibleTaskProviders": ["github", "linear"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"19b2979d7fc2": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"1b23dab83fcf": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 1,
|
||||
@@ -210,23 +150,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"1ff13ee8621c": {
|
||||
"name": "repoIconsByName",
|
||||
"ordinal": 4,
|
||||
"value": []
|
||||
},
|
||||
"2f1b95eac8f1": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
]
|
||||
},
|
||||
"36a96ddbd396": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 12,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
"299d571abf13": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"44136fa355b3": {},
|
||||
"4526bff18b74": {
|
||||
@@ -310,30 +271,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"53226cb82ec3": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 15,
|
||||
"value": "linux"
|
||||
},
|
||||
"6063e534d9db": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 13,
|
||||
"value": []
|
||||
},
|
||||
"6134b73f18d0": {
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
"5248b37ec085": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"624ec4e5082c": {
|
||||
"name": "repo.list#1",
|
||||
@@ -370,10 +340,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"668a7e313975": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
"661b4721aec6": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"68916f125c3c": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"6d43117882f3": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"747c556da67a": {
|
||||
"name": "repo.list#1",
|
||||
@@ -406,13 +432,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"7e772c1c111d": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 5,
|
||||
"value": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
"7c508088602c": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"8a72d3d14d44": {
|
||||
"name": "repo.list#1",
|
||||
@@ -449,21 +493,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"8ef3b348c179": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 6,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"96e0c54d86b0": {
|
||||
"8b618db22116": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"902c0b2ed3ce": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -487,54 +524,24 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"b428bce85ee7": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 11,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
"91e7f4ef9a77": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 13,
|
||||
"value": "linux"
|
||||
},
|
||||
"bea13a4a2e87": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 14,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"c752e67787f9": {
|
||||
"c1246f5792ee": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c1d6fc758acc": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"d68475063b62": {
|
||||
"name": "repo.list#1",
|
||||
@@ -573,6 +580,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"d9c464d0dc78": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 12,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"dae756300589": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 1,
|
||||
@@ -605,6 +617,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"e2881f748a46": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 11,
|
||||
"value": []
|
||||
},
|
||||
"e49da3e1abca": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 4,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -647,24 +672,6 @@
|
||||
"ok": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"f4667dda0187": {
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -673,34 +680,32 @@
|
||||
{
|
||||
"id": "settings-repo-metadata-fulfilled.normal:settings-pending",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "6134b73f18d0",
|
||||
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
|
||||
"state": "68916f125c3c",
|
||||
"effects": ["661b4721aec6", "e49da3e1abca"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settings-repo-metadata-fulfilled.normal:settled",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+471
-481
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,43 +4,9 @@
|
||||
"family": "settings.repo-metadata",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"03c9a2ba60b9": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"0c4296f215a4": {
|
||||
"18762ff064ed": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -83,13 +49,13 @@
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"1a160fc2d137": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"299d571abf13": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
@@ -105,33 +71,28 @@
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"1ff13ee8621c": {
|
||||
"name": "repoIconsByName",
|
||||
"ordinal": 4,
|
||||
"value": []
|
||||
},
|
||||
"2053629911e7": {
|
||||
"35f30397fcb2": {
|
||||
"name": "repo.list#2",
|
||||
"ordinal": 17,
|
||||
"ordinal": 15,
|
||||
"json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"2f1b95eac8f1": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
]
|
||||
},
|
||||
"36a96ddbd396": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 12,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"4526bff18b74": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 1,
|
||||
@@ -179,59 +140,135 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"53226cb82ec3": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 15,
|
||||
"value": "linux"
|
||||
"5248b37ec085": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"6063e534d9db": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 13,
|
||||
"value": []
|
||||
"661b4721aec6": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"6134b73f18d0": {
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
"68916f125c3c": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"6d43117882f3": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"668a7e313975": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"7e772c1c111d": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 5,
|
||||
"value": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"8ef3b348c179": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 6,
|
||||
"value": [
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"7c508088602c": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"96e0c54d86b0": {
|
||||
"8b618db22116": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"902c0b2ed3ce": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -255,23 +292,9 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"b428bce85ee7": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 11,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"ba8a19e3e2b1": {
|
||||
"status": "pending",
|
||||
"startedAt": 60000
|
||||
},
|
||||
"bea13a4a2e87": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 14,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"c402c05b8b96": {
|
||||
"9157a9ad3cbd": {
|
||||
"name": "repo.list#2",
|
||||
"ordinal": 16,
|
||||
"ordinal": 14,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -295,44 +318,46 @@
|
||||
"startedAt": 60000
|
||||
}
|
||||
},
|
||||
"c752e67787f9": {
|
||||
"91e7f4ef9a77": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 13,
|
||||
"value": "linux"
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"ba8a19e3e2b1": {
|
||||
"status": "pending",
|
||||
"startedAt": 60000
|
||||
},
|
||||
"c1246f5792ee": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c1d6fc758acc": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"d9c464d0dc78": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 12,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"e2881f748a46": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 11,
|
||||
"value": []
|
||||
},
|
||||
"e49da3e1abca": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 4,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -349,24 +374,6 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f4667dda0187": {
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -375,79 +382,73 @@
|
||||
{
|
||||
"id": "settings-pending",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "6134b73f18d0",
|
||||
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
|
||||
"state": "68916f125c3c",
|
||||
"effects": ["661b4721aec6", "e49da3e1abca"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settled",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cache-hit",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a",
|
||||
"cached-query": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cache-warm",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a",
|
||||
"cached-query": "eb79a9b3682a",
|
||||
"warm-query": "f43afee17848"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -456,17 +457,17 @@
|
||||
"observation": {
|
||||
"sender": [
|
||||
"4526bff18b74",
|
||||
"c752e67787f9",
|
||||
"0c4296f215a4",
|
||||
"03c9a2ba60b9",
|
||||
"c402c05b8b96"
|
||||
"299d571abf13",
|
||||
"18762ff064ed",
|
||||
"5248b37ec085",
|
||||
"9157a9ad3cbd"
|
||||
],
|
||||
"payloads": [
|
||||
"19b2979d7fc2",
|
||||
"668a7e313975",
|
||||
"b428bce85ee7",
|
||||
"36a96ddbd396",
|
||||
"2053629911e7"
|
||||
"c1246f5792ee",
|
||||
"8b618db22116",
|
||||
"c1d6fc758acc",
|
||||
"35f30397fcb2"
|
||||
],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -475,15 +476,13 @@
|
||||
"warm-query": "f43afee17848",
|
||||
"expired-query": "ba8a19e3e2b1"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,43 +4,9 @@
|
||||
"family": "settings.repo-metadata",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"03c9a2ba60b9": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"0c4296f215a4": {
|
||||
"18762ff064ed": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -83,13 +49,13 @@
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"1a160fc2d137": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"299d571abf13": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
@@ -105,28 +71,23 @@
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"1ff13ee8621c": {
|
||||
"name": "repoIconsByName",
|
||||
"ordinal": 4,
|
||||
"value": []
|
||||
},
|
||||
"2f1b95eac8f1": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
]
|
||||
},
|
||||
"36a96ddbd396": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 12,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"4526bff18b74": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 1,
|
||||
@@ -174,59 +135,135 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"53226cb82ec3": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 15,
|
||||
"value": "linux"
|
||||
"5248b37ec085": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"6063e534d9db": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 13,
|
||||
"value": []
|
||||
"661b4721aec6": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"6134b73f18d0": {
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
"68916f125c3c": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"6d43117882f3": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"668a7e313975": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"7e772c1c111d": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 5,
|
||||
"value": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"8ef3b348c179": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 6,
|
||||
"value": [
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"7c508088602c": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"96e0c54d86b0": {
|
||||
"8b618db22116": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"902c0b2ed3ce": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -250,54 +287,42 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"b428bce85ee7": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 11,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
"91e7f4ef9a77": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 13,
|
||||
"value": "linux"
|
||||
},
|
||||
"bea13a4a2e87": {
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"c1246f5792ee": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c1d6fc758acc": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"d9c464d0dc78": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 14,
|
||||
"ordinal": 12,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"c752e67787f9": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"e2881f748a46": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 11,
|
||||
"value": []
|
||||
},
|
||||
"e49da3e1abca": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 4,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -306,24 +331,6 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f4667dda0187": {
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -332,34 +339,32 @@
|
||||
{
|
||||
"id": "settings-pending",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "6134b73f18d0",
|
||||
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
|
||||
"state": "68916f125c3c",
|
||||
"effects": ["661b4721aec6", "e49da3e1abca"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settled",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,27 +4,68 @@
|
||||
"family": "settings.repo-metadata",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"0fd72e1aba93": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"badgeColor": "#123456",
|
||||
"displayName": "Lucide",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1",
|
||||
"repoIcon": {
|
||||
"name": "Rocket",
|
||||
"type": "lucide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "Emoji",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2",
|
||||
"repoIcon": {
|
||||
"emoji": "🐳",
|
||||
"type": "emoji"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "Avatar",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-3",
|
||||
"path": "repo-3",
|
||||
"repoIcon": {
|
||||
"label": "acme/orca",
|
||||
"source": "github",
|
||||
"src": "https://github.com/acme.png?size=64",
|
||||
"type": "image"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "darwin",
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"],
|
||||
["repo-3", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"19b2979d7fc2": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"19bf6d2469a6": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
["Lucide", "#123456"],
|
||||
["Emoji", "#f97316"],
|
||||
["Avatar", "#f97316"]
|
||||
]
|
||||
"1c76208c9beb": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 13,
|
||||
"value": "darwin"
|
||||
},
|
||||
"1a160fc2d137": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"299d571abf13": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
@@ -40,70 +81,26 @@
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"25b44bca9e45": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 15,
|
||||
"value": "darwin"
|
||||
},
|
||||
"36a96ddbd396": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 12,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"6063e534d9db": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 13,
|
||||
"value": []
|
||||
},
|
||||
"6338d57f282f": {
|
||||
"repoColorsByName": [
|
||||
["Lucide", "#123456"],
|
||||
["Emoji", "#f97316"],
|
||||
["Avatar", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"],
|
||||
["repo-3", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [
|
||||
[
|
||||
"Lucide",
|
||||
{
|
||||
"name": "Rocket",
|
||||
"type": "lucide"
|
||||
}
|
||||
],
|
||||
[
|
||||
"Emoji",
|
||||
{
|
||||
"emoji": "🐳",
|
||||
"type": "emoji"
|
||||
}
|
||||
],
|
||||
[
|
||||
"Avatar",
|
||||
{
|
||||
"label": "acme/orca",
|
||||
"source": "github",
|
||||
"src": "https://github.com/acme.png?size=64",
|
||||
"type": "image"
|
||||
}
|
||||
]
|
||||
],
|
||||
"repoIdsByName": [
|
||||
["Lucide", "repo-1"],
|
||||
["Emoji", "repo-2"],
|
||||
["Avatar", "repo-3"]
|
||||
]
|
||||
},
|
||||
"64f5522cc303": {
|
||||
"466c0317a24a": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -139,36 +136,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"668a7e313975": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
"7c508088602c": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"6bc5e5b7a259": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 6,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"],
|
||||
["repo-3", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"77cbfd1f087c": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 5,
|
||||
"value": [
|
||||
["Lucide", "repo-1"],
|
||||
["Emoji", "repo-2"],
|
||||
["Avatar", "repo-3"]
|
||||
]
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"96e0c54d86b0": {
|
||||
"8b618db22116": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"902c0b2ed3ce": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -192,6 +193,86 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"910617d262f4": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"badgeColor": "#123456",
|
||||
"displayName": "Lucide",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1",
|
||||
"repoIcon": {
|
||||
"name": "Rocket",
|
||||
"type": "lucide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "Emoji",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2",
|
||||
"repoIcon": {
|
||||
"emoji": "🐳",
|
||||
"type": "emoji"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "Avatar",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-3",
|
||||
"path": "repo-3",
|
||||
"repoIcon": {
|
||||
"label": "acme/orca",
|
||||
"source": "github",
|
||||
"src": "https://github.com/acme.png?size=64",
|
||||
"type": "image"
|
||||
}
|
||||
}
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"],
|
||||
["repo-3", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"9bc91d02ce24": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "darwin"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"a9add325bb0a": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 1,
|
||||
@@ -259,128 +340,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ae27a5b525fa": {
|
||||
"name": "repoIconsByName",
|
||||
"b4ad28ffe331": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 4,
|
||||
"value": [
|
||||
[
|
||||
"Lucide",
|
||||
{
|
||||
"name": "Rocket",
|
||||
"type": "lucide"
|
||||
}
|
||||
],
|
||||
[
|
||||
"Emoji",
|
||||
{
|
||||
"emoji": "🐳",
|
||||
"type": "emoji"
|
||||
}
|
||||
],
|
||||
[
|
||||
"Avatar",
|
||||
{
|
||||
"label": "acme/orca",
|
||||
"source": "github",
|
||||
"src": "https://github.com/acme.png?size=64",
|
||||
"type": "image"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"b428bce85ee7": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 11,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"bea13a4a2e87": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 14,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"c020f709d835": {
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "darwin",
|
||||
"repoColorsByName": [
|
||||
["Lucide", "#123456"],
|
||||
["Emoji", "#f97316"],
|
||||
["Avatar", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"],
|
||||
["repo-3", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [
|
||||
[
|
||||
"Lucide",
|
||||
{
|
||||
"name": "Rocket",
|
||||
"type": "lucide"
|
||||
}
|
||||
],
|
||||
[
|
||||
"Emoji",
|
||||
{
|
||||
"emoji": "🐳",
|
||||
"type": "emoji"
|
||||
}
|
||||
],
|
||||
[
|
||||
"Avatar",
|
||||
{
|
||||
"label": "acme/orca",
|
||||
"source": "github",
|
||||
"src": "https://github.com/acme.png?size=64",
|
||||
"type": "image"
|
||||
}
|
||||
]
|
||||
],
|
||||
"repoIdsByName": [
|
||||
["Lucide", "repo-1"],
|
||||
["Emoji", "repo-2"],
|
||||
["Avatar", "repo-3"]
|
||||
]
|
||||
},
|
||||
"c752e67787f9": {
|
||||
"c1246f5792ee": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c1d6fc758acc": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"d9c464d0dc78": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 12,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"e2881f748a46": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 11,
|
||||
"value": []
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -390,39 +377,44 @@
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"ee8e16df5187": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
"f9217d79f53e": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
"badgeColor": "#123456",
|
||||
"displayName": "Lucide",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1",
|
||||
"repoIcon": {
|
||||
"name": "Rocket",
|
||||
"type": "lucide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
"displayName": "Emoji",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2",
|
||||
"repoIcon": {
|
||||
"emoji": "🐳",
|
||||
"type": "emoji"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "Avatar",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-3",
|
||||
"path": "repo-3",
|
||||
"repoIcon": {
|
||||
"label": "acme/orca",
|
||||
"source": "github",
|
||||
"src": "https://github.com/acme.png?size=64",
|
||||
"type": "image"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "darwin"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -431,34 +423,32 @@
|
||||
{
|
||||
"id": "settings-pending",
|
||||
"observation": {
|
||||
"sender": ["a9add325bb0a", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["a9add325bb0a", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "6338d57f282f",
|
||||
"effects": ["19bf6d2469a6", "ae27a5b525fa", "77cbfd1f087c", "6bc5e5b7a259"]
|
||||
"state": "910617d262f4",
|
||||
"effects": ["f9217d79f53e", "b4ad28ffe331"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settled",
|
||||
"observation": {
|
||||
"sender": ["a9add325bb0a", "c752e67787f9", "64f5522cc303", "ee8e16df5187"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["a9add325bb0a", "299d571abf13", "466c0317a24a", "9bc91d02ce24"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "c020f709d835",
|
||||
"state": "0fd72e1aba93",
|
||||
"effects": [
|
||||
"19bf6d2469a6",
|
||||
"ae27a5b525fa",
|
||||
"77cbfd1f087c",
|
||||
"6bc5e5b7a259",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"25b44bca9e45"
|
||||
"f9217d79f53e",
|
||||
"b4ad28ffe331",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"1c76208c9beb"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,43 +4,14 @@
|
||||
"family": "settings.repo-metadata",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"03c9a2ba60b9": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
"00d007a90b2c": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 26,
|
||||
"value": "linux"
|
||||
},
|
||||
"0c4296f215a4": {
|
||||
"18762ff064ed": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -78,19 +49,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"0ef3d910f42e": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 29,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"19b2979d7fc2": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"1a160fc2d137": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"1e4a3aa71af5": {
|
||||
"name": "host.platform#2",
|
||||
"ordinal": 20,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -110,37 +76,79 @@
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-8",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"1ff13ee8621c": {
|
||||
"name": "repoIconsByName",
|
||||
"ordinal": 4,
|
||||
"value": []
|
||||
"299d571abf13": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"2053629911e7": {
|
||||
"name": "repo.list#2",
|
||||
"ordinal": 17,
|
||||
"json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"2f1b95eac8f1": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 3,
|
||||
"306fcf2b8536": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 16,
|
||||
"value": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"36a96ddbd396": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 12,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"376d3874239c": {
|
||||
"name": "repoIconsByName",
|
||||
"ordinal": 19,
|
||||
"value": []
|
||||
"35f30397fcb2": {
|
||||
"name": "repo.list#2",
|
||||
"ordinal": 15,
|
||||
"json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"4526bff18b74": {
|
||||
"name": "repo.list#1",
|
||||
@@ -189,44 +197,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"455855a85ab4": {
|
||||
"name": "settings.get#2",
|
||||
"ordinal": 23,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"error": {
|
||||
"code": "refused",
|
||||
"message": "settings refused"
|
||||
},
|
||||
"id": "frame-7",
|
||||
"ok": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"4771e3dbbbcf": {
|
||||
"name": "host.platform#2",
|
||||
"ordinal": 24,
|
||||
"5248b37ec085": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -250,7 +223,7 @@
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-8",
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
@@ -258,64 +231,106 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"53226cb82ec3": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 15,
|
||||
"value": "linux"
|
||||
"661b4721aec6": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"6063e534d9db": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 13,
|
||||
"value": []
|
||||
},
|
||||
"6134b73f18d0": {
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
"68916f125c3c": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"69f575e15e15": {
|
||||
"name": "settings.get#2",
|
||||
"ordinal": 22,
|
||||
"json": "{\"id\":\"frame-7\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"6d43117882f3": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"668a7e313975": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"7e772c1c111d": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 5,
|
||||
"value": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"8c5c812580ad": {
|
||||
"name": "host.platform#2",
|
||||
"ordinal": 27,
|
||||
"json": "{\"id\":\"frame-8\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"8ef3b348c179": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 6,
|
||||
"value": [
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"7c508088602c": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"96e0c54d86b0": {
|
||||
"8b618db22116": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"902c0b2ed3ce": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -339,78 +354,33 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"a67416f78b93": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 28,
|
||||
"value": []
|
||||
},
|
||||
"b428bce85ee7": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 11,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"bea13a4a2e87": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 14,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"c497d2d846c9": {
|
||||
"name": "ssh.listTargetSummaries#2",
|
||||
"ordinal": 25,
|
||||
"json": "{\"id\":\"frame-6\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c752e67787f9": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ceef481be25c": {
|
||||
"91e7f4ef9a77": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 30,
|
||||
"ordinal": 13,
|
||||
"value": "linux"
|
||||
},
|
||||
"d51254d2537f": {
|
||||
"name": "settings.get#2",
|
||||
"ordinal": 26,
|
||||
"json": "{\"id\":\"frame-7\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"d569b5b2500f": {
|
||||
"9b915e5b8831": {
|
||||
"name": "host.platform#2",
|
||||
"ordinal": 23,
|
||||
"json": "{\"id\":\"frame-8\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"a465a07eb4b0": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 25,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"af3366c70550": {
|
||||
"name": "ssh.listTargetSummaries#2",
|
||||
"ordinal": 22,
|
||||
"ordinal": 21,
|
||||
"json": "{\"id\":\"frame-6\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"bc879379ce13": {
|
||||
"name": "ssh.listTargetSummaries#2",
|
||||
"ordinal": 18,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -447,17 +417,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"da7cf9752395": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 18,
|
||||
"bd0d18cd2160": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 17,
|
||||
"value": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"df78d2202264": {
|
||||
"c1246f5792ee": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c1d6fc758acc": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"d9c464d0dc78": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 12,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"df3bf9bbb7bf": {
|
||||
"name": "repo.list#2",
|
||||
"ordinal": 16,
|
||||
"ordinal": 14,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "repo.list"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"e2881f748a46": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 11,
|
||||
"value": []
|
||||
},
|
||||
"e3e3d558d06c": {
|
||||
"name": "repo.list#2",
|
||||
"ordinal": 14,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -502,55 +518,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ea2ced0895f3": {
|
||||
"e49da3e1abca": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 21,
|
||||
"ordinal": 4,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
"e7d590a1a17e": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 24,
|
||||
"value": []
|
||||
},
|
||||
"eb885d2ceee5": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 20,
|
||||
"value": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"f4667dda0187": {
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"fa585aceeec2": {
|
||||
"name": "repo.list#2",
|
||||
"ordinal": 16,
|
||||
"eab4940c8ca3": {
|
||||
"name": "settings.get#2",
|
||||
"ordinal": 19,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "repo.list"
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
@@ -566,8 +553,25 @@
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"error": {
|
||||
"code": "refused",
|
||||
"message": "settings refused"
|
||||
},
|
||||
"id": "frame-7",
|
||||
"ok": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -577,55 +581,51 @@
|
||||
{
|
||||
"id": "settings-pending",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "6134b73f18d0",
|
||||
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
|
||||
"state": "68916f125c3c",
|
||||
"effects": ["661b4721aec6", "e49da3e1abca"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settled",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "data-present",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -634,32 +634,30 @@
|
||||
"observation": {
|
||||
"sender": [
|
||||
"4526bff18b74",
|
||||
"c752e67787f9",
|
||||
"0c4296f215a4",
|
||||
"03c9a2ba60b9",
|
||||
"fa585aceeec2"
|
||||
"299d571abf13",
|
||||
"18762ff064ed",
|
||||
"5248b37ec085",
|
||||
"df3bf9bbb7bf"
|
||||
],
|
||||
"payloads": [
|
||||
"19b2979d7fc2",
|
||||
"668a7e313975",
|
||||
"b428bce85ee7",
|
||||
"36a96ddbd396",
|
||||
"2053629911e7"
|
||||
"c1246f5792ee",
|
||||
"8b618db22116",
|
||||
"c1d6fc758acc",
|
||||
"35f30397fcb2"
|
||||
],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a",
|
||||
"reload": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -668,45 +666,41 @@
|
||||
"observation": {
|
||||
"sender": [
|
||||
"4526bff18b74",
|
||||
"c752e67787f9",
|
||||
"0c4296f215a4",
|
||||
"03c9a2ba60b9",
|
||||
"df78d2202264",
|
||||
"d569b5b2500f",
|
||||
"455855a85ab4",
|
||||
"4771e3dbbbcf"
|
||||
"299d571abf13",
|
||||
"18762ff064ed",
|
||||
"5248b37ec085",
|
||||
"e3e3d558d06c",
|
||||
"bc879379ce13",
|
||||
"eab4940c8ca3",
|
||||
"1e4a3aa71af5"
|
||||
],
|
||||
"payloads": [
|
||||
"19b2979d7fc2",
|
||||
"668a7e313975",
|
||||
"b428bce85ee7",
|
||||
"36a96ddbd396",
|
||||
"2053629911e7",
|
||||
"c497d2d846c9",
|
||||
"d51254d2537f",
|
||||
"8c5c812580ad"
|
||||
"c1246f5792ee",
|
||||
"8b618db22116",
|
||||
"c1d6fc758acc",
|
||||
"35f30397fcb2",
|
||||
"af3366c70550",
|
||||
"69f575e15e15",
|
||||
"9b915e5b8831"
|
||||
],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a",
|
||||
"reload": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3",
|
||||
"da7cf9752395",
|
||||
"376d3874239c",
|
||||
"eb885d2ceee5",
|
||||
"ea2ced0895f3",
|
||||
"a67416f78b93",
|
||||
"0ef3d910f42e",
|
||||
"ceef481be25c"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77",
|
||||
"306fcf2b8536",
|
||||
"bd0d18cd2160",
|
||||
"e7d590a1a17e",
|
||||
"a465a07eb4b0",
|
||||
"00d007a90b2c"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,83 +4,18 @@
|
||||
"family": "settings.repo-metadata",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"03c9a2ba60b9": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"19b2979d7fc2": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"1a160fc2d137": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"299d571abf13": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"1ff13ee8621c": {
|
||||
"name": "repoIconsByName",
|
||||
"ordinal": 4,
|
||||
"value": []
|
||||
},
|
||||
"2e2ca6f7dc3f": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
@@ -100,28 +35,19 @@
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"error": {
|
||||
"code": "refused",
|
||||
"message": "settings refused"
|
||||
},
|
||||
"id": "frame-3",
|
||||
"ok": false
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"2f1b95eac8f1": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
]
|
||||
},
|
||||
"36a96ddbd396": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 12,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"4526bff18b74": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 1,
|
||||
@@ -169,59 +95,170 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"53226cb82ec3": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 15,
|
||||
"value": "linux"
|
||||
"5248b37ec085": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"6063e534d9db": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 13,
|
||||
"value": []
|
||||
"61b27dbdf3a7": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"error": {
|
||||
"code": "refused",
|
||||
"message": "settings refused"
|
||||
},
|
||||
"id": "frame-3",
|
||||
"ok": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"6134b73f18d0": {
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
"661b4721aec6": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"68916f125c3c": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"6d43117882f3": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"668a7e313975": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"7e772c1c111d": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 5,
|
||||
"value": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"8ef3b348c179": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 6,
|
||||
"value": [
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
"7c508088602c": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"96e0c54d86b0": {
|
||||
"8b618db22116": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"902c0b2ed3ce": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -245,54 +282,42 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"b428bce85ee7": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 11,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
"91e7f4ef9a77": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 13,
|
||||
"value": "linux"
|
||||
},
|
||||
"bea13a4a2e87": {
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"c1246f5792ee": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c1d6fc758acc": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"d9c464d0dc78": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 14,
|
||||
"ordinal": 12,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"c752e67787f9": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"e2881f748a46": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 11,
|
||||
"value": []
|
||||
},
|
||||
"e49da3e1abca": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 4,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -301,24 +326,6 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f4667dda0187": {
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -327,34 +334,32 @@
|
||||
{
|
||||
"id": "settings-pending",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "6134b73f18d0",
|
||||
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
|
||||
"state": "68916f125c3c",
|
||||
"effects": ["661b4721aec6", "e49da3e1abca"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settled",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "2e2ca6f7dc3f", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "61b27dbdf3a7", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"1a160fc2d137": {
|
||||
"1e027d0127f3": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -30,35 +30,118 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Connection closed",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"1f909a505b54": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 13,
|
||||
"value": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
},
|
||||
"2207323d0bd5": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Connection closed",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"302f82cb9a68": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Connection closed",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"35960f0ff1b9": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"1ff13ee8621c": {
|
||||
"name": "repoIconsByName",
|
||||
"ordinal": 4,
|
||||
"value": []
|
||||
},
|
||||
"2bd489a9fa29": {
|
||||
"repoColorsByName": [
|
||||
["Remote", "#f97316"],
|
||||
["Remote folder", "#ec4899"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "ssh:ssh-1"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Remote", "repo-1"],
|
||||
["Remote folder", "repo-2"]
|
||||
]
|
||||
},
|
||||
"36a96ddbd396": {
|
||||
"name": "host.platform#1",
|
||||
"379c0eb6dc14": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 12,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
"value": []
|
||||
},
|
||||
"4b6956e45bac": {
|
||||
"name": "repo.list#1",
|
||||
@@ -105,41 +188,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"4bf1ed33de14": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
"6e0383a0869e": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
"displayName": "Remote folder",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Connection closed",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"53dea128b181": {
|
||||
"7c508088602c": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -159,33 +228,22 @@
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Connection closed",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"6063e534d9db": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 13,
|
||||
"value": []
|
||||
"8b618db22116": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 9,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"668a7e313975": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"6a227ad27189": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"902c0b2ed3ce": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
@@ -209,96 +267,48 @@
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"937107a86348": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 5,
|
||||
"value": [
|
||||
["Remote", "repo-1"],
|
||||
["Remote folder", "repo-2"]
|
||||
]
|
||||
},
|
||||
"96e0c54d86b0": {
|
||||
"name": "settings.get#1",
|
||||
"c1246f5792ee": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 8,
|
||||
"args": [
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c1d6fc758acc": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"d23c5c4cde9a": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
"displayName": "Remote folder",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"9d20695fdb34": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 15,
|
||||
"value": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
},
|
||||
"a086cfd03185": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 6,
|
||||
"value": [
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "ssh:ssh-1"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"b428bce85ee7": {
|
||||
"name": "settings.get#1",
|
||||
"e2881f748a46": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 11,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"daecdc0e521b": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 14,
|
||||
"value": []
|
||||
},
|
||||
"dd3998116e8e": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Connection closed",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
"e7dd8e429107": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 4,
|
||||
"value": [
|
||||
["repo-1", "ssh:ssh-1"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -307,14 +317,6 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"fc0785cc0b08": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
["Remote", "#f97316"],
|
||||
["Remote folder", "#ec4899"]
|
||||
]
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -323,34 +325,32 @@
|
||||
{
|
||||
"id": "single-host-without-label-lookups",
|
||||
"observation": {
|
||||
"sender": ["4b6956e45bac", "6a227ad27189", "96e0c54d86b0", "1a160fc2d137"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4b6956e45bac", "35960f0ff1b9", "902c0b2ed3ce", "7c508088602c"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "2bd489a9fa29",
|
||||
"effects": ["fc0785cc0b08", "1ff13ee8621c", "937107a86348", "a086cfd03185"]
|
||||
"state": "d23c5c4cde9a",
|
||||
"effects": ["6e0383a0869e", "e7dd8e429107"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cleanup",
|
||||
"observation": {
|
||||
"sender": ["4b6956e45bac", "4bf1ed33de14", "dd3998116e8e", "53dea128b181"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4b6956e45bac", "2207323d0bd5", "302f82cb9a68", "1e027d0127f3"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "2bd489a9fa29",
|
||||
"state": "d23c5c4cde9a",
|
||||
"effects": [
|
||||
"fc0785cc0b08",
|
||||
"1ff13ee8621c",
|
||||
"937107a86348",
|
||||
"a086cfd03185",
|
||||
"6063e534d9db",
|
||||
"daecdc0e521b",
|
||||
"9d20695fdb34"
|
||||
"6e0383a0869e",
|
||||
"e7dd8e429107",
|
||||
"e2881f748a46",
|
||||
"379c0eb6dc14",
|
||||
"1f909a505b54"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
"family": "settings.repo-metadata",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"03c9a2ba60b9": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"19b2979d7fc2": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"299d571abf13": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
@@ -30,63 +35,19 @@
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"19b2979d7fc2": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
|
||||
},
|
||||
"1a160fc2d137": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"1ff13ee8621c": {
|
||||
"name": "repoIconsByName",
|
||||
"ordinal": 4,
|
||||
"value": []
|
||||
},
|
||||
"2f1b95eac8f1": {
|
||||
"name": "repoColorsByName",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
]
|
||||
},
|
||||
"36a96ddbd396": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 12,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"4526bff18b74": {
|
||||
"name": "repo.list#1",
|
||||
"ordinal": 1,
|
||||
@@ -134,14 +95,180 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"53226cb82ec3": {
|
||||
"5248b37ec085": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"661b4721aec6": {
|
||||
"name": "desktopRepos",
|
||||
"ordinal": 3,
|
||||
"value": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"68916f125c3c": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"6d43117882f3": {
|
||||
"desktopRepos": [
|
||||
{
|
||||
"displayName": "Local",
|
||||
"executionHostId": "local",
|
||||
"id": "repo-1",
|
||||
"path": "repo-1"
|
||||
},
|
||||
{
|
||||
"displayName": "Remote",
|
||||
"executionHostId": "ssh:ssh-1",
|
||||
"id": "repo-2",
|
||||
"path": "repo-2"
|
||||
}
|
||||
],
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"7c508088602c": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "host.platform"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"8b618db22116": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 9,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"902c0b2ed3ce": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"91e7f4ef9a77": {
|
||||
"name": "hostPlatform",
|
||||
"ordinal": 15,
|
||||
"ordinal": 13,
|
||||
"value": "linux"
|
||||
},
|
||||
"6021c58b2fd2": {
|
||||
"name": "settings.get#1",
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"c1246f5792ee": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"c1d6fc758acc": {
|
||||
"name": "host.platform#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
|
||||
},
|
||||
"ce32fe5b8377": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 6,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -171,125 +298,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"6063e534d9db": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 13,
|
||||
"value": []
|
||||
},
|
||||
"6134b73f18d0": {
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"668a7e313975": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
|
||||
},
|
||||
"7e772c1c111d": {
|
||||
"name": "repoIdsByName",
|
||||
"ordinal": 5,
|
||||
"value": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
},
|
||||
"8ef3b348c179": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 6,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"96e0c54d86b0": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 8,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "settings.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"b428bce85ee7": {
|
||||
"name": "settings.get#1",
|
||||
"ordinal": 11,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
|
||||
},
|
||||
"bea13a4a2e87": {
|
||||
"d9c464d0dc78": {
|
||||
"name": "hostLabelById",
|
||||
"ordinal": 14,
|
||||
"ordinal": 12,
|
||||
"value": [["ssh:ssh-1", "SSH"]]
|
||||
},
|
||||
"c752e67787f9": {
|
||||
"name": "ssh.listTargetSummaries#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "ssh.listTargetSummaries"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "ssh-1",
|
||||
"label": "SSH"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"e2881f748a46": {
|
||||
"name": "hostHealthById",
|
||||
"ordinal": 11,
|
||||
"value": []
|
||||
},
|
||||
"e49da3e1abca": {
|
||||
"name": "repoHostIdByRepoId",
|
||||
"ordinal": 4,
|
||||
"value": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
]
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -298,24 +323,6 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f4667dda0187": {
|
||||
"hostHealthById": [],
|
||||
"hostLabelById": [["ssh:ssh-1", "SSH"]],
|
||||
"hostPlatform": "linux",
|
||||
"repoColorsByName": [
|
||||
["Local", "#6366f1"],
|
||||
["Remote", "#f97316"]
|
||||
],
|
||||
"repoHostIdByRepoId": [
|
||||
["repo-1", "local"],
|
||||
["repo-2", "ssh:ssh-1"]
|
||||
],
|
||||
"repoIconsByName": [],
|
||||
"repoIdsByName": [
|
||||
["Local", "repo-1"],
|
||||
["Remote", "repo-2"]
|
||||
]
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -324,34 +331,32 @@
|
||||
{
|
||||
"id": "settings-pending",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "6134b73f18d0",
|
||||
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
|
||||
"state": "68916f125c3c",
|
||||
"effects": ["661b4721aec6", "e49da3e1abca"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settled",
|
||||
"observation": {
|
||||
"sender": ["4526bff18b74", "c752e67787f9", "6021c58b2fd2", "03c9a2ba60b9"],
|
||||
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
|
||||
"sender": ["4526bff18b74", "299d571abf13", "ce32fe5b8377", "5248b37ec085"],
|
||||
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "f4667dda0187",
|
||||
"state": "6d43117882f3",
|
||||
"effects": [
|
||||
"2f1b95eac8f1",
|
||||
"1ff13ee8621c",
|
||||
"7e772c1c111d",
|
||||
"8ef3b348c179",
|
||||
"6063e534d9db",
|
||||
"bea13a4a2e87",
|
||||
"53226cb82ec3"
|
||||
"661b4721aec6",
|
||||
"e49da3e1abca",
|
||||
"e2881f748a46",
|
||||
"d9c464d0dc78",
|
||||
"91e7f4ef9a77"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,10 @@ describe('MobileMarkdown file links', () => {
|
||||
expect(pressables(tree)).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves nested labels and empty image labels', () => {
|
||||
it('links the inner label of a nested bracket, and names an empty image label', () => {
|
||||
const tree = render('[[nested](https://example.com) ')
|
||||
pressByText(tree, '[nested')
|
||||
expect(flattenText(tree.root)).toBe('[nested image')
|
||||
pressByText(tree, 'nested')
|
||||
expect(openURL).toHaveBeenLastCalledWith('https://example.com')
|
||||
pressByText(tree, 'image')
|
||||
expect(openURL).toHaveBeenLastCalledWith('https://example.com/image')
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
const openURL = vi.fn((_url: string) => Promise.resolve())
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: (url: string) => openURL(url) },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
openURL.mockClear()
|
||||
})
|
||||
|
||||
function render(content: string, growing = false): ReactTestRenderer {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileMarkdown, { content, growing }))
|
||||
})
|
||||
return renderer!
|
||||
}
|
||||
|
||||
function flatten(node: ReactTestInstance | string): string {
|
||||
return typeof node === 'string' ? node : node.children.map(flatten).join('')
|
||||
}
|
||||
|
||||
const isText = (node: ReactTestInstance): boolean => String(node.type) === 'Text'
|
||||
|
||||
/** Every Text not inside another Text, in reading order: what the reader sees, block by block. */
|
||||
function texts(tree: ReactTestRenderer): string[] {
|
||||
return tree.root
|
||||
.findAll((node) => isText(node) && !(node.parent && hasTextAncestor(node.parent)))
|
||||
.map(flatten)
|
||||
}
|
||||
|
||||
function hasTextAncestor(node: ReactTestInstance): boolean {
|
||||
return isText(node) || (node.parent !== null && hasTextAncestor(node.parent))
|
||||
}
|
||||
|
||||
function press(tree: ReactTestRenderer, label: string): void {
|
||||
const target = tree.root.find(
|
||||
(node) => isText(node) && typeof node.props.onPress === 'function' && flatten(node) === label
|
||||
)
|
||||
act(() => target.props.onPress())
|
||||
}
|
||||
|
||||
describe('MobileMarkdown rendering', () => {
|
||||
it.each([
|
||||
[
|
||||
'C1: nested lists keep their own markers and numbers',
|
||||
'1. **Frontend**\n - React\n - Vite\n2. Backend',
|
||||
['1.', 'Frontend', '-', 'React', '-', 'Vite', '2.', 'Backend']
|
||||
],
|
||||
[
|
||||
'C1: blank lines keep one list, and a list cut short keeps its numbers',
|
||||
'1. Install\n\n Pulls in everything.\n\n2. Build\n\nDone.\n\n3. Ship',
|
||||
['1.', 'Install', 'Pulls in everything.', '2.', 'Build', 'Done.', '3.', 'Ship']
|
||||
],
|
||||
[
|
||||
'C1: a wrapped item stays one item',
|
||||
'- a long item\nwrapped at sixty\n- next',
|
||||
['-', 'a long item\nwrapped at sixty', '-', 'next']
|
||||
],
|
||||
[
|
||||
'C1: a number starting a wrapped line is not a list',
|
||||
'We counted to\n42. Then we stop.',
|
||||
['We counted to\n42. Then we stop.']
|
||||
],
|
||||
[
|
||||
'C2: code, links and emphasis inside bold',
|
||||
'Edit **`file.ts`** per **[docs](https://x.com)**, ***both***, *a **b** c*, **bold _ital_**',
|
||||
['Edit file.ts per docs, both, a b c, bold ital']
|
||||
],
|
||||
[
|
||||
'C4: a fence with an unusual language tag',
|
||||
'```c++\nint x;\n```\nAfter.\n\n```ts title="a.ts"\nconst a = 1\n```',
|
||||
['c++', 'int x;', 'After.', 'ts', 'const a = 1']
|
||||
],
|
||||
[
|
||||
'C5: tilde and four-backtick fences, with HTML inside left alone',
|
||||
'~~~html\n<div className="a"><span>Hi</span></div>\n~~~\n\n````md\n```js\nx\n```\n````',
|
||||
['html', '<div className="a"><span>Hi</span></div>', 'md', '```js\nx\n```']
|
||||
],
|
||||
[
|
||||
'C6: a fence under a list step',
|
||||
'1. Install:\n ```bash\n pnpm i\n ```\n2. Run',
|
||||
['1.', 'Install:', 'bash', 'pnpm i', '2.', 'Run']
|
||||
],
|
||||
[
|
||||
'C7: a table right under a sentence',
|
||||
'Here:\n| a | b |\n| --- | --- |\n| 1 | 2 |',
|
||||
['Here:', 'a', 'b', '1', '2']
|
||||
],
|
||||
['C8: short separator rows', '| a | b |\n|:-|-:|\n| 1 | 2 |', ['a', 'b', '1', '2']],
|
||||
[
|
||||
'C9: a line break inside a table cell',
|
||||
'| Flag | Note |\n| --- | --- |\n| -f | Skips checks<br>Use with care |',
|
||||
['Flag', 'Note', '-f', 'Skips checks\nUse with care']
|
||||
],
|
||||
['C14: backslash escapes', '\\*not italic\\* a\\_b a \\| b \\#', ['*not italic* a_b a | b #']],
|
||||
['C15: lone asterisks', '2 * 3 * 4 and *.ts and *.tsx', ['2 * 3 * 4 and *.ts and *.tsx']],
|
||||
['C16: inline code holding a backtick', '``a`b``', ['a`b']],
|
||||
[
|
||||
'C18: blocks inside a quote',
|
||||
'> - a\n>\n> ```sh\n> ls\n> ```\n> > inner',
|
||||
['-', 'a', 'sh', 'ls', 'inner']
|
||||
],
|
||||
['C19: underlined headings and closing hashes', 'Title\n=====\n\n## Sub ##', ['Title', 'Sub']],
|
||||
[
|
||||
'a README header block reads as its heading',
|
||||
'<h1 align="center">Orca</h1>\n\nText',
|
||||
['Orca', 'Text']
|
||||
]
|
||||
])('%s', (_name, content, expected) => {
|
||||
expect(texts(render(content))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('C12: opens a link with brackets in it, and one with a title, at their own address', () => {
|
||||
const tree = render(
|
||||
'[wiki](https://en.wikipedia.org/wiki/Foo_(bar)) and [docs](https://x.com/a "The docs")'
|
||||
)
|
||||
press(tree, 'wiki')
|
||||
expect(openURL).toHaveBeenLastCalledWith('https://en.wikipedia.org/wiki/Foo_(bar)')
|
||||
press(tree, 'docs')
|
||||
expect(openURL).toHaveBeenLastCalledWith('https://x.com/a')
|
||||
})
|
||||
|
||||
it('C13: opens an angle-bracket link without its brackets', () => {
|
||||
const tree = render('Open <http://localhost:3000>.')
|
||||
expect(texts(tree)).toEqual(['Open http://localhost:3000.'])
|
||||
press(tree, 'http://localhost:3000')
|
||||
expect(openURL).toHaveBeenLastCalledWith('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('C17: resolves a reference link and draws a footnote', () => {
|
||||
const tree = render(
|
||||
'See [the docs][1] and a note[^1].\n\n[1]: https://example.com/docs\n\n[^1]: The note.'
|
||||
)
|
||||
expect(texts(tree)).toEqual(['See the docs and a note[1].', '[1]', 'The note.'])
|
||||
press(tree, 'the docs')
|
||||
expect(openURL).toHaveBeenLastCalledWith('https://example.com/docs')
|
||||
})
|
||||
|
||||
it('C21: closes open markup while a reply grows, and never links a half-written address', () => {
|
||||
expect(texts(render('2 * 3 is six.\n\nRead **src/main/ses', true))).toEqual([
|
||||
'2 * 3 is six.',
|
||||
'Read src/main/ses'
|
||||
])
|
||||
const tree = render('See [config loader](https://exa', true)
|
||||
expect(texts(tree)).toEqual(['See config loader'])
|
||||
expect(tree.root.findAll((node) => typeof node.props.onPress === 'function')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
|
||||
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
|
||||
import { parseMobileMarkdown } from './mobile-markdown-parser'
|
||||
|
||||
describe('isMobileMermaidLanguage', () => {
|
||||
it('matches mermaid case-insensitively after trim', () => {
|
||||
@@ -18,63 +17,7 @@ describe('isMobileMermaidLanguage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMobileMarkdown', () => {
|
||||
it('parses mermaid fences as code blocks with language mermaid', () => {
|
||||
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')).toEqual([
|
||||
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: true }
|
||||
])
|
||||
expect(isMobileMermaidLanguage('mermaid')).toBe(true)
|
||||
})
|
||||
|
||||
it('marks an unterminated fence as not closed while it streams', () => {
|
||||
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B')).toEqual([
|
||||
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: false }
|
||||
])
|
||||
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')[0]).toMatchObject({
|
||||
closed: true
|
||||
})
|
||||
})
|
||||
|
||||
it('parses GFM tables into table blocks', () => {
|
||||
expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([
|
||||
{
|
||||
type: 'table',
|
||||
headers: ['Name', 'State'],
|
||||
rows: [['Orca', 'Open']]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an escaped pipe inside the cell that escaped it', () => {
|
||||
expect(parseMobileMarkdown('| Cmd | Note |\n| --- | --- |\n| a \\| b | c |')).toEqual([
|
||||
{
|
||||
type: 'table',
|
||||
headers: ['Cmd', 'Note'],
|
||||
rows: [['a | b', 'c']]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('ends a cell at the pipe following an escaped backslash', () => {
|
||||
expect(parseMobileMarkdown('| A | B |\n| --- | --- |\n| x\\\\|y |')).toEqual([
|
||||
{
|
||||
type: 'table',
|
||||
headers: ['A', 'B'],
|
||||
rows: [['x\\', 'y']]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('parses standalone HTTPS images without folding them into paragraphs', () => {
|
||||
expect(parseMobileMarkdown('')).toEqual([
|
||||
{
|
||||
type: 'image',
|
||||
alt: 'Screenshot',
|
||||
url: 'https://example.com/screen.png'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
describe('normalizeMobileMarkdownPreviewHtml', () => {
|
||||
it('normalizes common README HTML into readable Markdown preview text', () => {
|
||||
const normalized = normalizeMobileMarkdownPreviewHtml(`
|
||||
<h1 align="center">
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher'
|
||||
import { INLINE_TEXT_SELECTION } from './inline-text-selection'
|
||||
import { MobileSelectableText } from './MobileSelectableText'
|
||||
import {
|
||||
Fragment,
|
||||
createElement,
|
||||
createContext,
|
||||
memo,
|
||||
useContext,
|
||||
useMemo,
|
||||
type ComponentType,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { Pressable, ScrollView, Text as NativeText, View, type TextProps } from 'react-native'
|
||||
import { memo, useContext, useMemo, type ReactNode } from 'react'
|
||||
import { Text as NativeText, View } from 'react-native'
|
||||
import { styles } from './mobile-markdown-styles'
|
||||
import {
|
||||
detectFilePathSegments,
|
||||
isFilePathCodeSpan,
|
||||
normalizeFilePath
|
||||
} from './markdown-file-path-detection'
|
||||
import { routeMarkdownHref } from './markdown-href-routing'
|
||||
import {
|
||||
isIntrawordUnderscoreToken,
|
||||
trimAutolinkTrailingPunctuation
|
||||
} from './markdown-inline-token-rules'
|
||||
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
|
||||
import { useMobileMarkdownBlocks } from './use-mobile-markdown-blocks'
|
||||
import { renderBlocks, type MarkdownBlockContext } from './mobile-markdown-blocks'
|
||||
import { MarkdownText } from './mobile-markdown-text'
|
||||
import { MarkdownTextContext, type MarkdownTextSetup } from './mobile-markdown-text-setup'
|
||||
import { useMobileMarkdownTree } from './use-mobile-markdown-tree'
|
||||
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
|
||||
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
|
||||
|
||||
type Props = {
|
||||
content?: string
|
||||
@@ -47,188 +27,9 @@ type Props = {
|
||||
/** Native-chat assistant prose only: renders `::orca-visual{...}` directive lines. Without it,
|
||||
* a directive line is ordinary text. Must be referentially stable (this component is memoized). */
|
||||
renderVisual?: (directive: NativeChatVisualDirective, index: number) => ReactNode
|
||||
}
|
||||
|
||||
const MAX_TABLE_ROWS = 40
|
||||
const MAX_TABLE_COLUMNS = 8
|
||||
/** Prose base size — passed to MermaidDiagram fallback mono text. */
|
||||
const MERMAID_BASE = 13
|
||||
type MarkdownTextSetup = {
|
||||
TextComponent: ComponentType<TextProps>
|
||||
/** Disable native selection only within the Android transcript. */
|
||||
androidTranscript: boolean
|
||||
onLongPress?: () => void
|
||||
}
|
||||
const MarkdownTextContext = createContext<MarkdownTextSetup>({
|
||||
TextComponent: NativeText,
|
||||
androidTranscript: false
|
||||
})
|
||||
|
||||
function MarkdownText(props: TextProps): React.JSX.Element {
|
||||
const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext)
|
||||
if (!androidTranscript) {
|
||||
return createElement(TextComponent, props)
|
||||
}
|
||||
// Override selection without changing the nested spans' inherited behavior.
|
||||
return createElement(TextComponent, {
|
||||
...props,
|
||||
...(props.selectable === true ? { selectable: false } : {}),
|
||||
...(onLongPress && props.onPress ? { onLongPress } : {})
|
||||
})
|
||||
}
|
||||
|
||||
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
|
||||
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
|
||||
function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
|
||||
const route = routeMarkdownHref(href)
|
||||
if (route.kind === 'web') {
|
||||
// The seam, not react-native's `Linking`: this module is in the tasks page closure, and inside
|
||||
// the shell's WebView `openURL` resolves without opening anything.
|
||||
openExternalLink(route.url)
|
||||
return
|
||||
}
|
||||
if (route.kind === 'file' && onOpenFile) {
|
||||
onOpenFile(route.pathText)
|
||||
}
|
||||
}
|
||||
|
||||
// Render a plain (non-token) text run, splitting out tappable file paths when
|
||||
// onOpenFile is provided. Without it, paths stay plain text.
|
||||
function renderTextRun(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onOpenFile?: (pathText: string) => void
|
||||
): ReactNode {
|
||||
if (!onOpenFile) {
|
||||
return text
|
||||
}
|
||||
const segments = detectFilePathSegments(text)
|
||||
if (segments.length === 1 && segments[0]!.type === 'text') {
|
||||
return text
|
||||
}
|
||||
return segments.map((segment, segmentIndex) => {
|
||||
if (segment.type === 'file') {
|
||||
return (
|
||||
<MarkdownText
|
||||
key={`${keyPrefix}:${segmentIndex}`}
|
||||
style={styles.link}
|
||||
onPress={() => onOpenFile(segment.path)}
|
||||
>
|
||||
{segment.value}
|
||||
</MarkdownText>
|
||||
)
|
||||
}
|
||||
return <Fragment key={`${keyPrefix}:${segmentIndex}`}>{segment.value}</Fragment>
|
||||
})
|
||||
}
|
||||
|
||||
function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] {
|
||||
const parts: ReactNode[] = []
|
||||
const pattern = createMarkdownInlineMatcher(
|
||||
text,
|
||||
/(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g,
|
||||
true
|
||||
)
|
||||
let pendingStart = 0
|
||||
let match: MarkdownInlineMatch | null
|
||||
|
||||
while ((match = pattern.exec())) {
|
||||
const token = match[0]
|
||||
// Intraword `_` runs (snake_case, dunder tails) are literal text per
|
||||
// CommonMark; leaving them unflushed keeps surrounding file paths whole
|
||||
// for detection in the eventual text run.
|
||||
if (token.startsWith('_') && isIntrawordUnderscoreToken(text, match.index, token)) {
|
||||
// Resume after the opener so real tokens inside the rejected span are still scanned.
|
||||
pattern.lastIndex = match.index + 1
|
||||
continue
|
||||
}
|
||||
if (match.index > pendingStart) {
|
||||
parts.push(
|
||||
renderTextRun(text.slice(pendingStart, match.index), `t${pendingStart}`, onOpenFile)
|
||||
)
|
||||
}
|
||||
pendingStart = pattern.lastIndex
|
||||
const key = `${match.index}:${token}`
|
||||
const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
|
||||
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
|
||||
if (image) {
|
||||
parts.push(
|
||||
<MarkdownText
|
||||
key={key}
|
||||
style={styles.link}
|
||||
onPress={() => openMarkdownHref(image[2]!, onOpenFile)}
|
||||
>
|
||||
{image[1] || 'image'}
|
||||
</MarkdownText>
|
||||
)
|
||||
} else if (link) {
|
||||
parts.push(
|
||||
<MarkdownText
|
||||
key={key}
|
||||
style={styles.link}
|
||||
onPress={() => openMarkdownHref(link[2]!, onOpenFile)}
|
||||
>
|
||||
{link[1]}
|
||||
</MarkdownText>
|
||||
)
|
||||
} else if (/^https?:\/\//i.test(token)) {
|
||||
const { url, trailing } = trimAutolinkTrailingPunctuation(token)
|
||||
parts.push(
|
||||
<MarkdownText
|
||||
key={key}
|
||||
style={styles.link}
|
||||
onPress={() => openMarkdownHref(url, onOpenFile)}
|
||||
>
|
||||
{url}
|
||||
</MarkdownText>
|
||||
)
|
||||
if (trailing) {
|
||||
parts.push(<Fragment key={`${key}p`}>{trailing}</Fragment>)
|
||||
}
|
||||
} else if (token.startsWith('`')) {
|
||||
const code = token.slice(1, -1)
|
||||
if (onOpenFile && isFilePathCodeSpan(code)) {
|
||||
parts.push(
|
||||
<MarkdownText
|
||||
key={key}
|
||||
style={[styles.inlineCode, styles.inlineCodeLink]}
|
||||
onPress={() => onOpenFile(normalizeFilePath(code.trim()))}
|
||||
>
|
||||
{code}
|
||||
</MarkdownText>
|
||||
)
|
||||
} else {
|
||||
parts.push(
|
||||
<MarkdownText key={key} style={styles.inlineCode}>
|
||||
{code}
|
||||
</MarkdownText>
|
||||
)
|
||||
}
|
||||
} else if (token.startsWith('~~')) {
|
||||
parts.push(
|
||||
<MarkdownText key={key} style={styles.strike}>
|
||||
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
|
||||
</MarkdownText>
|
||||
)
|
||||
} else if (token.startsWith('**') || token.startsWith('__')) {
|
||||
parts.push(
|
||||
<MarkdownText key={key} style={styles.bold}>
|
||||
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
|
||||
</MarkdownText>
|
||||
)
|
||||
} else {
|
||||
parts.push(
|
||||
<MarkdownText key={key} style={styles.italic}>
|
||||
{renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)}
|
||||
</MarkdownText>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingStart < text.length) {
|
||||
parts.push(renderTextRun(text.slice(pendingStart), `t${pendingStart}`, onOpenFile))
|
||||
}
|
||||
return parts
|
||||
/** The text is still being appended to: markup its end leaves open is closed, as on desktop,
|
||||
* and a diagram stays code until the text stops. */
|
||||
growing?: boolean
|
||||
}
|
||||
|
||||
function MobileMarkdownContent({
|
||||
@@ -237,18 +38,13 @@ function MobileMarkdownContent({
|
||||
rangeSelectable = false,
|
||||
textScale = 1,
|
||||
onOpenFile,
|
||||
renderVisual
|
||||
renderVisual,
|
||||
growing = false
|
||||
}: Props) {
|
||||
// Interactive children own their touches and must forward the row action.
|
||||
const setup = useContext(MarkdownTextContext)
|
||||
const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined
|
||||
const text = content?.trim() ?? ''
|
||||
const { blocks, directives } = useMobileMarkdownBlocks(text, renderVisual !== undefined)
|
||||
// Scale prose sizes; inline spans inherit fontSize from the wrapping Text.
|
||||
const scaled = (size: number): { fontSize: number; lineHeight: number } | null =>
|
||||
textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null
|
||||
const proseScale = scaled(13)
|
||||
const listScale = scaled(14)
|
||||
const { root, directives } = useMobileMarkdownTree(text, renderVisual !== undefined, growing)
|
||||
if (!text) {
|
||||
return fallback ? (
|
||||
<MarkdownText selectable={rangeSelectable} style={styles.paragraph}>
|
||||
@@ -256,159 +52,24 @@ function MobileMarkdownContent({
|
||||
</MarkdownText>
|
||||
) : null
|
||||
}
|
||||
const mermaidSourceOccurrences = new Map<string, number>()
|
||||
// Scale prose sizes; inline spans inherit fontSize from the wrapping Text.
|
||||
const scaled = (size: number): { fontSize: number; lineHeight: number } | null =>
|
||||
textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null
|
||||
const ctx: MarkdownBlockContext = {
|
||||
growing,
|
||||
onOpenFile,
|
||||
listText: [styles.listText, scaled(14)],
|
||||
rowLongPress: setup.androidTranscript ? setup.onLongPress : undefined,
|
||||
directives,
|
||||
renderVisual,
|
||||
mermaidOccurrences: new Map()
|
||||
}
|
||||
// Native-chat range selection is set on each block; nested inline spans inherit it.
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{blocks.map((block, index) => {
|
||||
if (block.type === 'visual') {
|
||||
const directive = directives[block.index]
|
||||
return directive && renderVisual ? (
|
||||
<Fragment key={`visual:${block.index}:${directive.file}`}>
|
||||
{renderVisual(directive, block.index)}
|
||||
</Fragment>
|
||||
) : null
|
||||
}
|
||||
if (block.type === 'heading') {
|
||||
return (
|
||||
<MarkdownText
|
||||
key={index}
|
||||
selectable
|
||||
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
|
||||
>
|
||||
{renderInline(block.text, onOpenFile)}
|
||||
</MarkdownText>
|
||||
)
|
||||
}
|
||||
if (block.type === 'quote') {
|
||||
return (
|
||||
<View key={index} style={styles.quote}>
|
||||
<MarkdownText selectable style={styles.quoteText}>
|
||||
{renderInline(block.text, onOpenFile)}
|
||||
</MarkdownText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (block.type === 'code') {
|
||||
// Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar.
|
||||
// Unclosed fences are still streaming: mounting the WebView per tick would
|
||||
// reload its document up to 20x/sec, so they stay raw code until terminated.
|
||||
if (isMobileMermaidLanguage(block.language) && block.closed) {
|
||||
const occurrence = mermaidSourceOccurrences.get(block.text) ?? 0
|
||||
mermaidSourceOccurrences.set(block.text, occurrence + 1)
|
||||
return (
|
||||
<MermaidDiagram
|
||||
key={`${block.text}:${occurrence}`}
|
||||
source={block.text}
|
||||
base={MERMAID_BASE}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View key={index} style={styles.codeBlock}>
|
||||
{block.language ? (
|
||||
<NativeText style={styles.codeLanguage}>{block.language}</NativeText>
|
||||
) : null}
|
||||
<MarkdownText selectable style={styles.codeText}>
|
||||
{block.text}
|
||||
</MarkdownText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (block.type === 'image') {
|
||||
return (
|
||||
<Pressable
|
||||
key={index}
|
||||
style={styles.imageFrame}
|
||||
onPress={() => openMarkdownHref(block.url, onOpenFile)}
|
||||
onLongPress={rowLongPress}
|
||||
>
|
||||
<NativeText style={styles.link}>{block.alt || 'Open image'}</NativeText>
|
||||
<NativeText style={styles.imageCaption} numberOfLines={1}>
|
||||
{block.url}
|
||||
</NativeText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
if (block.type === 'table') {
|
||||
const visibleHeaders = block.headers.slice(0, MAX_TABLE_COLUMNS)
|
||||
const visibleRows = block.rows.slice(0, MAX_TABLE_ROWS)
|
||||
const hiddenRows = Math.max(0, block.rows.length - visibleRows.length)
|
||||
const hiddenColumns = Math.max(0, block.headers.length - visibleHeaders.length)
|
||||
return (
|
||||
<ScrollView key={index} horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableRow}>
|
||||
{visibleHeaders.map((header, cellIndex) => (
|
||||
<MarkdownText
|
||||
key={cellIndex}
|
||||
selectable
|
||||
style={[styles.tableCell, styles.tableHeader]}
|
||||
>
|
||||
{renderInline(header, onOpenFile)}
|
||||
</MarkdownText>
|
||||
))}
|
||||
</View>
|
||||
{visibleRows.map((row, rowIndex) => (
|
||||
<View key={rowIndex} style={styles.tableRow}>
|
||||
{visibleHeaders.map((_, cellIndex) => (
|
||||
<MarkdownText key={cellIndex} selectable style={styles.tableCell}>
|
||||
{renderInline(row[cellIndex] ?? '', onOpenFile)}
|
||||
</MarkdownText>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
{hiddenRows > 0 || hiddenColumns > 0 ? (
|
||||
<NativeText style={styles.tableTruncated}>
|
||||
{hiddenRows > 0 ? `${hiddenRows} more rows` : ''}
|
||||
{hiddenRows > 0 && hiddenColumns > 0 ? ' · ' : ''}
|
||||
{hiddenColumns > 0 ? `${hiddenColumns} more columns` : ''}
|
||||
</NativeText>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
if (block.type === 'list') {
|
||||
return (
|
||||
<View key={index} style={styles.list}>
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<View key={itemIndex} style={styles.listItem}>
|
||||
<NativeText style={styles.listMarker}>
|
||||
{item.checked == null
|
||||
? block.ordered
|
||||
? `${itemIndex + 1}.`
|
||||
: '-'
|
||||
: item.checked
|
||||
? '[x]'
|
||||
: '[ ]'}
|
||||
</NativeText>
|
||||
<MarkdownText selectable style={[styles.listText, listScale]}>
|
||||
{renderInline(item.text, onOpenFile)}
|
||||
</MarkdownText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (block.type === 'rule') {
|
||||
return <View key={index} style={styles.rule} />
|
||||
}
|
||||
return (
|
||||
<MarkdownText
|
||||
key={index}
|
||||
selectable={rangeSelectable}
|
||||
style={[styles.paragraph, proseScale]}
|
||||
>
|
||||
{block.text.split('\n').map((line, lineIndex) => (
|
||||
<Fragment key={lineIndex}>
|
||||
{lineIndex > 0 ? '\n' : null}
|
||||
{renderInline(line, onOpenFile)}
|
||||
</Fragment>
|
||||
))}
|
||||
</MarkdownText>
|
||||
)
|
||||
{renderBlocks(root.children, ctx, {
|
||||
style: [styles.paragraph, scaled(13)],
|
||||
selectable: rangeSelectable
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useEffect, useRef } from 'react'
|
||||
|
||||
type BottomDrawerModalHostScope = {
|
||||
/** False only while a self-showing host is down; its drawers render nothing until it is up. */
|
||||
shown: boolean
|
||||
/** Keeps a self-showing host up; null when the caller owns `visible`. */
|
||||
hold: (() => () => void) | null
|
||||
claimBack: (dismiss: () => void) => () => void
|
||||
}
|
||||
|
||||
export const BottomDrawerModalHostContext = createContext<BottomDrawerModalHostScope | null>(null)
|
||||
|
||||
/** Non-null when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and
|
||||
* must therefore skip its own native Modal (the host owns the single Modal). */
|
||||
export function useBottomDrawerModalHost(): BottomDrawerModalHostScope | null {
|
||||
return useContext(BottomDrawerModalHostContext)
|
||||
}
|
||||
|
||||
/** Back on the host's Modal reaches the host, not BackHandler; the newest claim takes it. */
|
||||
export function useBottomDrawerModalHostBackClaim(dismiss: (() => void) | null): void {
|
||||
const claimBack = useContext(BottomDrawerModalHostContext)?.claimBack
|
||||
const latest = useRef(dismiss)
|
||||
useEffect(() => {
|
||||
latest.current = dismiss
|
||||
})
|
||||
const claimed = dismiss !== null
|
||||
useEffect(() => {
|
||||
if (!claimBack || !claimed) {
|
||||
return
|
||||
}
|
||||
return claimBack(() => latest.current?.())
|
||||
}, [claimBack, claimed])
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Timing = { to: number; callback?: (finished: boolean) => void }
|
||||
|
||||
const animations = vi.hoisted((): { running: Timing[] } => ({ running: [] }))
|
||||
|
||||
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
|
||||
vi.mock('../platform/keyboard-occlusion', () => ({
|
||||
currentSoftKeyboardHeight: () => 0,
|
||||
subscribeSoftKeyboard: () => () => {}
|
||||
}))
|
||||
vi.mock('react-native', () => ({
|
||||
Keyboard: { dismiss: () => {} },
|
||||
Modal: 'Modal',
|
||||
Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
|
||||
View: 'View',
|
||||
useWindowDimensions: () => ({ width: 412, height: 900 })
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('react-native-gesture-handler', () => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of [
|
||||
'activeOffsetY',
|
||||
'simultaneousWithExternalGesture',
|
||||
'onBegin',
|
||||
'onUpdate',
|
||||
'onEnd'
|
||||
]) {
|
||||
chain[method] = () => chain
|
||||
}
|
||||
return {
|
||||
Gesture: { Pan: () => chain, Native: () => chain },
|
||||
GestureDetector: 'GestureDetector',
|
||||
GestureHandlerRootView: 'GestureHandlerRootView'
|
||||
}
|
||||
})
|
||||
vi.mock('react-native-reanimated', () => ({
|
||||
default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' },
|
||||
useSharedValue: (initial: number) => ({ value: initial }),
|
||||
useAnimatedStyle: () => ({}),
|
||||
useAnimatedScrollHandler: () => () => {},
|
||||
withSpring: (to: number) => to,
|
||||
withTiming: (to: number, _config?: unknown, callback?: (finished: boolean) => void) => {
|
||||
animations.running.push({ to, callback })
|
||||
return to
|
||||
},
|
||||
runOnJS: (fn: () => void) => fn,
|
||||
interpolate: () => 0,
|
||||
Extrapolation: { CLAMP: 'clamp' }
|
||||
}))
|
||||
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
|
||||
|
||||
function finishAnimations(): void {
|
||||
const finishing = animations.running
|
||||
animations.running = []
|
||||
act(() => {
|
||||
for (const animation of finishing) {
|
||||
animation.callback?.(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type Layers = { detail: boolean; form: boolean }
|
||||
|
||||
const closeDetail = vi.fn()
|
||||
const closeForm = vi.fn()
|
||||
|
||||
// Mirrors the Tasks surface: the Create Workspace form renders before the issue detail and is
|
||||
// raised over it by zIndex, both inside one host.
|
||||
function tasksSheets({ detail, form }: Layers) {
|
||||
return (
|
||||
<BottomDrawerModalHost>
|
||||
<BottomDrawer visible={form} onClose={closeForm} zIndex={1100}>
|
||||
{createElement('Sheet', { name: 'form' })}
|
||||
</BottomDrawer>
|
||||
<BottomDrawer visible={detail} onClose={closeDetail}>
|
||||
{createElement('Sheet', { name: 'detail' })}
|
||||
</BottomDrawer>
|
||||
</BottomDrawerModalHost>
|
||||
)
|
||||
}
|
||||
|
||||
let rendered: ReactTestRenderer | null = null
|
||||
|
||||
function show(layers: Layers): void {
|
||||
act(() => {
|
||||
if (rendered) {
|
||||
rendered.update(tasksSheets(layers))
|
||||
} else {
|
||||
rendered = create(tasksSheets(layers))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function nativeModals() {
|
||||
return rendered?.root.findAll((node) => String(node.type) === 'Modal') ?? []
|
||||
}
|
||||
|
||||
function sheetsInModal(): string[] {
|
||||
return nativeModals().flatMap((modal) =>
|
||||
modal.findAll((node) => String(node.type) === 'Sheet').map((sheet) => String(sheet.props.name))
|
||||
)
|
||||
}
|
||||
|
||||
function pressAndroidBack(): void {
|
||||
act(() => nativeModals()[0]?.props.onRequestClose())
|
||||
finishAnimations()
|
||||
}
|
||||
|
||||
describe('BottomDrawerModalHost without a caller-owned visible', () => {
|
||||
afterEach(() => {
|
||||
act(() => rendered?.unmount())
|
||||
rendered = null
|
||||
animations.running = []
|
||||
closeDetail.mockReset()
|
||||
closeForm.mockReset()
|
||||
})
|
||||
|
||||
// #21003: iOS refuses a native modal presented over a sibling one, so the form opened from an
|
||||
// issue detail never appeared. Both sheets must live in the single native Modal.
|
||||
it('opens a sheet over another inside the one native Modal', () => {
|
||||
show({ detail: true, form: false })
|
||||
finishAnimations()
|
||||
show({ detail: true, form: true })
|
||||
|
||||
expect(nativeModals()).toHaveLength(1)
|
||||
expect(sheetsInModal().sort()).toEqual(['detail', 'form'])
|
||||
})
|
||||
|
||||
it('keeps the Modal through the last close animation, then leaves none to swallow taps', () => {
|
||||
show({ detail: true, form: true })
|
||||
finishAnimations()
|
||||
|
||||
show({ detail: true, form: false })
|
||||
finishAnimations()
|
||||
expect(sheetsInModal()).toEqual(['detail'])
|
||||
|
||||
show({ detail: false, form: false })
|
||||
expect(sheetsInModal()).toEqual(['detail'])
|
||||
finishAnimations()
|
||||
expect(nativeModals()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hands Back to the sheet on top, then to the one under it', () => {
|
||||
show({ detail: true, form: false })
|
||||
finishAnimations()
|
||||
show({ detail: true, form: true })
|
||||
finishAnimations()
|
||||
|
||||
pressAndroidBack()
|
||||
expect(closeForm).toHaveBeenCalledTimes(1)
|
||||
expect(closeDetail).not.toHaveBeenCalled()
|
||||
|
||||
show({ detail: true, form: false })
|
||||
finishAnimations()
|
||||
pressAndroidBack()
|
||||
expect(closeDetail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,28 +1,57 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Modal } from 'react-native'
|
||||
|
||||
const BottomDrawerModalHostContext = createContext(false)
|
||||
|
||||
/** True when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and
|
||||
* must therefore skip its own native Modal (the host owns the single Modal). */
|
||||
export function useInsideBottomDrawerModalHost(): boolean {
|
||||
return useContext(BottomDrawerModalHostContext)
|
||||
}
|
||||
import { BottomDrawerModalHostContext } from './bottom-drawer-modal-host-scope'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
onRequestClose: () => void
|
||||
/** Omit to show the host while any drawer inside is requested, shown or closing. */
|
||||
visible?: boolean
|
||||
/** Omit to hand Back to the drawer that claimed it last. */
|
||||
onRequestClose?: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Why: iOS cannot reliably dismiss one native modal and present another in the same
|
||||
// beat. Flows that swap between sibling drawer modals (e.g. the Create Workspace form
|
||||
// → its repository/agent pickers) dropped the incoming modal, leaving the sheet dead
|
||||
// to taps. Hosting every drawer in ONE persistent native Modal makes those swaps
|
||||
// in-window view changes instead, so no present/dismiss race can eat the transition.
|
||||
// beat, and refuses outright to present one while a sibling is presented. Flows that
|
||||
// swap or stack sibling drawer modals (e.g. the Create Workspace form → its pickers, or
|
||||
// an issue detail → Create Workspace) dropped the incoming modal, leaving the sheet
|
||||
// dead to taps. Hosting every drawer in ONE native Modal makes those swaps and stacks
|
||||
// in-window view changes instead, ordered by zIndex.
|
||||
export function BottomDrawerModalHost({ visible, onRequestClose, children }: Props) {
|
||||
if (!visible) {
|
||||
return null
|
||||
const [holds, setHolds] = useState(0)
|
||||
const backClaims = useRef<(() => void)[]>([])
|
||||
const hold = useCallback(() => {
|
||||
let held = true
|
||||
setHolds((count) => count + 1)
|
||||
return () => {
|
||||
if (held) {
|
||||
held = false
|
||||
setHolds((count) => count - 1)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
const claimBack = useCallback((dismiss: () => void) => {
|
||||
const claim = () => dismiss()
|
||||
backClaims.current.push(claim)
|
||||
return () => {
|
||||
backClaims.current = backClaims.current.filter((entry) => entry !== claim)
|
||||
}
|
||||
}, [])
|
||||
const selfShowing = visible === undefined
|
||||
const shown = selfShowing ? holds > 0 : visible
|
||||
const scope = useMemo(
|
||||
() => ({ shown, hold: selfShowing ? hold : null, claimBack }),
|
||||
[shown, selfShowing, hold, claimBack]
|
||||
)
|
||||
const content = (
|
||||
<BottomDrawerModalHostContext.Provider value={scope}>
|
||||
{children}
|
||||
</BottomDrawerModalHostContext.Provider>
|
||||
)
|
||||
|
||||
if (!shown) {
|
||||
// Why: a self-showing host keeps its drawers mounted while down so a request can raise it.
|
||||
// They remount inside the Modal, which is safe because none of them is showing anything yet.
|
||||
return selfShowing ? content : null
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
@@ -30,11 +59,9 @@ export function BottomDrawerModalHost({ visible, onRequestClose, children }: Pro
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
onRequestClose={onRequestClose}
|
||||
onRequestClose={onRequestClose ?? (() => backClaims.current.at(-1)?.())}
|
||||
>
|
||||
<BottomDrawerModalHostContext.Provider value={true}>
|
||||
{children}
|
||||
</BottomDrawerModalHostContext.Provider>
|
||||
{content}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { MountedBottomDrawer, type MountedBottomDrawerProps } from './mounted-bottom-drawer'
|
||||
import { useBottomDrawerModalHost } from './bottom-drawer-modal-host-scope'
|
||||
|
||||
// iOS cannot present a native Modal while another is still presented, even mid-close, so one
|
||||
// drawer owns what is on screen and swaps sheets only after the previous Modal has unmounted.
|
||||
@@ -70,8 +71,14 @@ export function KeyedBottomDrawer<T>({
|
||||
)
|
||||
}, [released])
|
||||
|
||||
const host = useBottomDrawerModalHost()
|
||||
const holdHost = host?.hold
|
||||
// Why: a self-showing host stays up from the request until this sheet's close has landed.
|
||||
const wantsHost = sheet !== null || next.presented !== null || next.released !== null
|
||||
useLayoutEffect(() => (holdHost && wantsHost ? holdHost() : undefined), [holdHost, wantsHost])
|
||||
|
||||
const presented = next.presented
|
||||
if (presented === null) {
|
||||
if (presented === null || host?.shown === false) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMarkdownInlineMatcher } from './markdown-inline-matcher'
|
||||
import { isIntrawordUnderscoreToken } from './markdown-inline-token-rules'
|
||||
import { parseInline } from './pr-sidebar/markdown-blocks'
|
||||
|
||||
const ORIGINAL_CHAT =
|
||||
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
|
||||
const CHAT_OTHER =
|
||||
/(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g
|
||||
const ORIGINAL_REVIEW =
|
||||
/(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g
|
||||
const REVIEW_OTHER = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g
|
||||
|
||||
function tokens(text: string, chat: boolean, original: boolean) {
|
||||
const pattern = new RegExp(chat ? ORIGINAL_CHAT : ORIGINAL_REVIEW)
|
||||
function tokens(text: string, original: boolean) {
|
||||
const pattern = new RegExp(ORIGINAL_REVIEW)
|
||||
const matcher = original
|
||||
? {
|
||||
get lastIndex() {
|
||||
@@ -24,21 +19,17 @@ function tokens(text: string, chat: boolean, original: boolean) {
|
||||
},
|
||||
exec: () => pattern.exec(text)
|
||||
}
|
||||
: createMarkdownInlineMatcher(text, new RegExp(chat ? CHAT_OTHER : REVIEW_OTHER), chat)
|
||||
: createMarkdownInlineMatcher(text, new RegExp(REVIEW_OTHER))
|
||||
const result: Array<{ text: string; index: number; end: number }> = []
|
||||
let match
|
||||
while ((match = matcher.exec())) {
|
||||
if (chat && isIntrawordUnderscoreToken(text, match.index, match[0])) {
|
||||
matcher.lastIndex = match.index + 1
|
||||
continue
|
||||
}
|
||||
result.push({ text: match[0], index: match.index, end: matcher.lastIndex })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
describe('mobile inline link scanning', () => {
|
||||
it.each([false, true])('preserves original token order (chat=%s)', (chat) => {
|
||||
it('preserves original token order', () => {
|
||||
const fragments = [
|
||||
'[',
|
||||
']',
|
||||
@@ -71,7 +62,7 @@ describe('mobile inline link scanning', () => {
|
||||
{ length: 1 + (random() % 40) },
|
||||
() => fragments[random() % fragments.length]
|
||||
).join('')
|
||||
expect(tokens(text, chat, false), text).toEqual(tokens(text, chat, true))
|
||||
expect(tokens(text, false), text).toEqual(tokens(text, true))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ export type MarkdownInlineMatch = { 0: string; index: number; end: number }
|
||||
/** Merge a global non-link regex with links; search starts must advance between calls. */
|
||||
export function createMarkdownInlineMatcher(
|
||||
text: string,
|
||||
nonLinkPattern: RegExp,
|
||||
images = false
|
||||
nonLinkPattern: RegExp
|
||||
): { lastIndex: number; exec: () => MarkdownInlineMatch | null } {
|
||||
let nextOther: MarkdownInlineMatch | null | undefined
|
||||
let nextLink: MarkdownInlineMatch | null | undefined
|
||||
@@ -26,8 +25,7 @@ export function createMarkdownInlineMatcher(
|
||||
noMoreLabels = true
|
||||
return null
|
||||
}
|
||||
const image = images && open > from && text[open - 1] === '!'
|
||||
if ((image || labelEnd > open + 1) && text[labelEnd + 1] === '(') {
|
||||
if (labelEnd > open + 1 && text[labelEnd + 1] === '(') {
|
||||
if (destinationEnd < labelEnd + 2) {
|
||||
destinationEnd = text.indexOf(')', labelEnd + 2)
|
||||
}
|
||||
@@ -36,8 +34,7 @@ export function createMarkdownInlineMatcher(
|
||||
return null
|
||||
}
|
||||
if (destinationEnd > labelEnd + 2) {
|
||||
const index = image ? open - 1 : open
|
||||
return { 0: text.slice(index, destinationEnd + 1), index, end: destinationEnd + 1 }
|
||||
return { 0: text.slice(open, destinationEnd + 1), index: open, end: destinationEnd + 1 }
|
||||
}
|
||||
}
|
||||
// Every opener before this closing bracket shares the same invalid suffix.
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isIntrawordUnderscoreToken,
|
||||
trimAutolinkTrailingPunctuation
|
||||
} from './markdown-inline-token-rules'
|
||||
|
||||
describe('isIntrawordUnderscoreToken', () => {
|
||||
it('rejects snake_case emphasis spans', () => {
|
||||
const text = 'src/foo_bar.ts and src/baz_qux.ts'
|
||||
const index = text.indexOf('_')
|
||||
const token = text.slice(index, text.lastIndexOf('_') + 1)
|
||||
expect(isIntrawordUnderscoreToken(text, index, token)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps standalone emphasis', () => {
|
||||
expect(isIntrawordUnderscoreToken('say _hello_ now', 4, '_hello_')).toBe(false)
|
||||
expect(isIntrawordUnderscoreToken('_hello_.', 0, '_hello_')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects emphasis closed against a following word', () => {
|
||||
expect(isIntrawordUnderscoreToken('_foo_s bar', 0, '_foo_')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects dunder emphasis inside a path', () => {
|
||||
expect(isIntrawordUnderscoreToken('src/__init__.py', 4, '__init__')).toBe(true)
|
||||
expect(isIntrawordUnderscoreToken(String.raw`src\__init__.py`, 4, '__init__')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores non-underscore tokens', () => {
|
||||
expect(isIntrawordUnderscoreToken('a*b*c', 1, '*b*')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trimAutolinkTrailingPunctuation', () => {
|
||||
it('splits sentence punctuation off the URL', () => {
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a.')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: '.'
|
||||
})
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a,')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: ','
|
||||
})
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a?!')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: '?!'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps balanced parens and strips unbalanced ones', () => {
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a_(b)')).toEqual({
|
||||
url: 'https://x.com/a_(b)',
|
||||
trailing: ''
|
||||
})
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a).')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: ').'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles long unmatched closing-parenthesis tails', () => {
|
||||
const url = 'https://x.com/a_(b)'
|
||||
const trailing = ')'.repeat(4096)
|
||||
expect(trimAutolinkTrailingPunctuation(`${url}${trailing}`)).toEqual({ url, trailing })
|
||||
})
|
||||
|
||||
it('leaves clean URLs untouched', () => {
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
// Post-checks for inline markdown tokens that a single-pass tokenizer regex
|
||||
// cannot express on its own.
|
||||
|
||||
const INTRAWORD_FLANK_PATTERN = /[\w\\/]/
|
||||
|
||||
/**
|
||||
* True when a `_…_` / `__…__` token sits inside a word (snake_case, dunder
|
||||
* tails). CommonMark treats intraword underscores as literal text; path
|
||||
* separators count as flanks so dunder path segments also stay whole.
|
||||
*/
|
||||
export function isIntrawordUnderscoreToken(text: string, index: number, token: string): boolean {
|
||||
if (!token.startsWith('_')) {
|
||||
return false
|
||||
}
|
||||
const prev = index > 0 ? text[index - 1]! : ''
|
||||
const next = text[index + token.length] ?? ''
|
||||
return INTRAWORD_FLANK_PATTERN.test(prev) || INTRAWORD_FLANK_PATTERN.test(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split sentence punctuation off an autolinked URL tail ("see https://x.com/a."),
|
||||
* keeping a trailing ')' only when the URL itself opened a paren.
|
||||
*/
|
||||
export function trimAutolinkTrailingPunctuation(url: string): { url: string; trailing: string } {
|
||||
let end = url.length
|
||||
let parenthesisCountsReady = false
|
||||
let openParentheses = 0
|
||||
let closeParentheses = 0
|
||||
while (end > 0) {
|
||||
const char = url[end - 1]!
|
||||
if ('.,;:!?'.includes(char)) {
|
||||
end--
|
||||
continue
|
||||
}
|
||||
if (char === ')') {
|
||||
if (!parenthesisCountsReady) {
|
||||
for (let index = 0; index < end; index++) {
|
||||
if (url[index] === '(') {
|
||||
openParentheses++
|
||||
} else if (url[index] === ')') {
|
||||
closeParentheses++
|
||||
}
|
||||
}
|
||||
parenthesisCountsReady = true
|
||||
}
|
||||
if (closeParentheses > openParentheses) {
|
||||
end--
|
||||
closeParentheses--
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return { url: url.slice(0, end), trailing: url.slice(end) }
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { Code, List, RootContent, Table } from 'mdast'
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text as NativeText,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle
|
||||
} from 'react-native'
|
||||
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
|
||||
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
|
||||
import { mobileMarkdownTableColumnWidths } from './mobile-markdown-table-columns'
|
||||
import { mobileMarkdownVisualPlaceholderIndex } from './mobile-markdown-visual-lines'
|
||||
import {
|
||||
openMarkdownHref,
|
||||
renderInline,
|
||||
type MarkdownInlineContext
|
||||
} from './mobile-markdown-inline'
|
||||
import { styles } from './mobile-markdown-styles'
|
||||
import { MarkdownText } from './mobile-markdown-text'
|
||||
import { codeFenceClosed, plainText } from './mobile-markdown-tree'
|
||||
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
|
||||
|
||||
/** Prose base size — passed to MermaidDiagram fallback mono text. */
|
||||
const MERMAID_BASE = 13
|
||||
/** Containers nested deeper than this draw as plain text, so a pathological nesting cannot
|
||||
* exhaust native layout. */
|
||||
const MAX_NESTING = 8
|
||||
|
||||
export type MarkdownBlockContext = MarkdownInlineContext & {
|
||||
/** The text is still arriving: a diagram stays code until it stops. */
|
||||
growing: boolean
|
||||
listText: StyleProp<TextStyle>
|
||||
/** Interactive children own their touches and must forward the row action. */
|
||||
rowLongPress?: () => void
|
||||
directives: readonly NativeChatVisualDirective[]
|
||||
renderVisual?: (directive: NativeChatVisualDirective, index: number) => ReactNode
|
||||
mermaidOccurrences: Map<string, number>
|
||||
}
|
||||
|
||||
/** How a paragraph draws in the container holding it. */
|
||||
export type ParagraphText = { style: StyleProp<TextStyle>; selectable: boolean }
|
||||
|
||||
function renderCode(node: Code, key: number, ctx: MarkdownBlockContext): ReactNode {
|
||||
// Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar.
|
||||
// Growing or unclosed fences are still streaming: mounting the WebView per tick would
|
||||
// reload its document up to 20x/sec, so they stay raw code until done.
|
||||
if (isMobileMermaidLanguage(node.lang ?? undefined) && !ctx.growing && codeFenceClosed(node)) {
|
||||
const occurrence = ctx.mermaidOccurrences.get(node.value) ?? 0
|
||||
ctx.mermaidOccurrences.set(node.value, occurrence + 1)
|
||||
return (
|
||||
<MermaidDiagram key={`${node.value}:${occurrence}`} source={node.value} base={MERMAID_BASE} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View key={key} style={styles.codeBlock}>
|
||||
{node.lang ? <NativeText style={styles.codeLanguage}>{node.lang}</NativeText> : null}
|
||||
<MarkdownText selectable style={styles.codeText}>
|
||||
{node.value}
|
||||
</MarkdownText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function renderList(node: List, key: number, ctx: MarkdownBlockContext, depth: number): ReactNode {
|
||||
const itemText: ParagraphText = { style: ctx.listText, selectable: true }
|
||||
return (
|
||||
<View key={key} style={styles.list}>
|
||||
{node.children.map((item, itemIndex) => (
|
||||
<View key={itemIndex} style={styles.listItem}>
|
||||
<NativeText style={styles.listMarker}>
|
||||
{item.checked == null
|
||||
? node.ordered
|
||||
? `${(node.start ?? 1) + itemIndex}.`
|
||||
: '-'
|
||||
: item.checked
|
||||
? '[x]'
|
||||
: '[ ]'}
|
||||
</NativeText>
|
||||
<View style={styles.listItemBody}>
|
||||
{renderBlocks(item.children, ctx, itemText, depth + 1)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTable(node: Table, key: number, ctx: MarkdownBlockContext): ReactNode {
|
||||
const rows = node.children.map((row) => row.children)
|
||||
// Rows lay out independently, so a shared width per column is what lines them up.
|
||||
const columnWidths = mobileMarkdownTableColumnWidths(
|
||||
rows.map((cells) => cells.map((cell) => plainText(cell)))
|
||||
)
|
||||
return (
|
||||
<ScrollView key={key} horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.table}>
|
||||
{rows.map((cells, rowIndex) => (
|
||||
<View key={rowIndex} style={styles.tableRow}>
|
||||
{columnWidths.map((width, cellIndex) => (
|
||||
<MarkdownText
|
||||
key={cellIndex}
|
||||
selectable
|
||||
style={[
|
||||
styles.tableCell,
|
||||
rowIndex === 0 ? styles.tableHeader : null,
|
||||
{ width, textAlign: node.align?.[cellIndex] ?? 'left' }
|
||||
]}
|
||||
>
|
||||
{renderInline(cells[cellIndex]?.children ?? [], ctx)}
|
||||
</MarkdownText>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
function renderBlock(
|
||||
node: RootContent,
|
||||
key: number,
|
||||
ctx: MarkdownBlockContext,
|
||||
text: ParagraphText,
|
||||
depth: number
|
||||
): ReactNode {
|
||||
if (depth > MAX_NESTING && 'children' in node) {
|
||||
return (
|
||||
<MarkdownText key={key} selectable={text.selectable} style={text.style}>
|
||||
{plainText(node)}
|
||||
</MarkdownText>
|
||||
)
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'paragraph': {
|
||||
const [only] = node.children
|
||||
const visual =
|
||||
node.children.length === 1 && only?.type === 'text'
|
||||
? mobileMarkdownVisualPlaceholderIndex(only.value, ctx.directives.length)
|
||||
: null
|
||||
if (visual !== null) {
|
||||
const directive = ctx.directives[visual]
|
||||
return directive && ctx.renderVisual ? (
|
||||
<Fragment key={`visual:${visual}:${directive.file}`}>
|
||||
{ctx.renderVisual(directive, visual)}
|
||||
</Fragment>
|
||||
) : null
|
||||
}
|
||||
if (node.children.length === 1 && only?.type === 'image' && /^https?:\/\//i.test(only.url)) {
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
style={styles.imageFrame}
|
||||
onPress={() => openMarkdownHref(only.url, ctx.onOpenFile)}
|
||||
onLongPress={ctx.rowLongPress}
|
||||
>
|
||||
<NativeText style={styles.link}>{only.alt || 'Open image'}</NativeText>
|
||||
<NativeText style={styles.imageCaption} numberOfLines={1}>
|
||||
{only.url}
|
||||
</NativeText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MarkdownText key={key} selectable={text.selectable} style={text.style}>
|
||||
{renderInline(node.children, ctx)}
|
||||
</MarkdownText>
|
||||
)
|
||||
}
|
||||
case 'heading':
|
||||
return (
|
||||
<MarkdownText
|
||||
key={key}
|
||||
selectable
|
||||
style={[styles.heading, node.depth <= 2 ? styles.headingLarge : null]}
|
||||
>
|
||||
{renderInline(node.children, ctx)}
|
||||
</MarkdownText>
|
||||
)
|
||||
case 'blockquote':
|
||||
return (
|
||||
<View key={key} style={styles.quote}>
|
||||
{renderBlocks(
|
||||
node.children,
|
||||
ctx,
|
||||
{ style: styles.quoteText, selectable: true },
|
||||
depth + 1
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
case 'code':
|
||||
return renderCode(node, key, ctx)
|
||||
case 'list':
|
||||
return renderList(node, key, ctx, depth)
|
||||
case 'table':
|
||||
return renderTable(node, key, ctx)
|
||||
case 'thematicBreak':
|
||||
return <View key={key} style={styles.rule} />
|
||||
case 'footnoteDefinition':
|
||||
return (
|
||||
<View key={key} style={styles.listItem}>
|
||||
<NativeText style={styles.listMarker}>{`[${node.label ?? node.identifier}]`}</NativeText>
|
||||
<View style={styles.listItemBody}>
|
||||
{renderBlocks(node.children, ctx, { style: ctx.listText, selectable: true }, depth + 1)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
case 'html':
|
||||
return (
|
||||
<MarkdownText key={key} selectable={text.selectable} style={text.style}>
|
||||
{node.value}
|
||||
</MarkdownText>
|
||||
)
|
||||
// Inline content never sits at block level in a parsed tree; drawn as text if it does.
|
||||
case 'break':
|
||||
case 'delete':
|
||||
case 'emphasis':
|
||||
case 'footnoteReference':
|
||||
case 'image':
|
||||
case 'imageReference':
|
||||
case 'inlineCode':
|
||||
case 'link':
|
||||
case 'linkReference':
|
||||
case 'strong':
|
||||
case 'text':
|
||||
return (
|
||||
<MarkdownText key={key} selectable={text.selectable} style={text.style}>
|
||||
{renderInline([node], ctx)}
|
||||
</MarkdownText>
|
||||
)
|
||||
case 'definition':
|
||||
case 'listItem':
|
||||
case 'tableCell':
|
||||
case 'tableRow':
|
||||
case 'yaml':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Block content as views; `text` is how paragraphs draw in the container holding them. */
|
||||
export function renderBlocks(
|
||||
nodes: readonly RootContent[],
|
||||
ctx: MarkdownBlockContext,
|
||||
text: ParagraphText,
|
||||
depth = 0
|
||||
): ReactNode[] {
|
||||
return nodes.map((node, index) => renderBlock(node, index, ctx, text, depth))
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PhrasingContent } from 'mdast'
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
import type { TextProps } from 'react-native'
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import {
|
||||
detectFilePathSegments,
|
||||
isFilePathCodeSpan,
|
||||
normalizeFilePath
|
||||
} from './markdown-file-path-detection'
|
||||
import { routeMarkdownHref } from './markdown-href-routing'
|
||||
import { styles } from './mobile-markdown-styles'
|
||||
import { MarkdownText } from './mobile-markdown-text'
|
||||
import { plainText } from './mobile-markdown-tree'
|
||||
|
||||
export type MarkdownInlineContext = {
|
||||
/** When provided, detected file paths and file-target hrefs render as tappable and invoke
|
||||
* this with the path text; without it, paths render as plain text. */
|
||||
onOpenFile?: (pathText: string) => void
|
||||
}
|
||||
|
||||
type SpanStyle = TextProps['style']
|
||||
|
||||
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
|
||||
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
|
||||
export function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
|
||||
const route = routeMarkdownHref(href)
|
||||
if (route.kind === 'web') {
|
||||
// The seam, not react-native's `Linking`: this module is in the tasks page closure, and inside
|
||||
// the shell's WebView `openURL` resolves without opening anything.
|
||||
openExternalLink(route.url)
|
||||
return
|
||||
}
|
||||
if (route.kind === 'file' && onOpenFile) {
|
||||
onOpenFile(route.pathText)
|
||||
}
|
||||
}
|
||||
|
||||
// A plain text run, with tappable file paths split out when onOpenFile is provided.
|
||||
function renderTextRun(
|
||||
text: string,
|
||||
key: string,
|
||||
onOpenFile: ((pathText: string) => void) | undefined,
|
||||
outer: SpanStyle
|
||||
): ReactNode {
|
||||
if (!onOpenFile) {
|
||||
return text
|
||||
}
|
||||
const segments = detectFilePathSegments(text)
|
||||
if (segments.length === 1 && segments[0]!.type === 'text') {
|
||||
return text
|
||||
}
|
||||
return segments.map((segment, segmentIndex) =>
|
||||
segment.type === 'file' ? (
|
||||
<MarkdownText
|
||||
key={`${key}:${segmentIndex}`}
|
||||
style={[outer, styles.link]}
|
||||
onPress={() => onOpenFile(segment.path)}
|
||||
>
|
||||
{segment.value}
|
||||
</MarkdownText>
|
||||
) : (
|
||||
<Fragment key={`${key}:${segmentIndex}`}>{segment.value}</Fragment>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function renderLink(
|
||||
key: string,
|
||||
label: string,
|
||||
url: string,
|
||||
ctx: MarkdownInlineContext,
|
||||
outer: SpanStyle
|
||||
): ReactNode {
|
||||
return (
|
||||
<MarkdownText
|
||||
key={key}
|
||||
style={[outer, styles.link]}
|
||||
onPress={() => openMarkdownHref(url, ctx.onOpenFile)}
|
||||
>
|
||||
{label}
|
||||
</MarkdownText>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPhrasing(
|
||||
node: PhrasingContent,
|
||||
key: string,
|
||||
ctx: MarkdownInlineContext,
|
||||
outer: SpanStyle
|
||||
): ReactNode {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return <Fragment key={key}>{renderTextRun(node.value, key, ctx.onOpenFile, outer)}</Fragment>
|
||||
case 'break':
|
||||
return <Fragment key={key}>{'\n'}</Fragment>
|
||||
case 'emphasis':
|
||||
case 'strong':
|
||||
case 'delete': {
|
||||
const emphasis =
|
||||
node.type === 'strong'
|
||||
? styles.bold
|
||||
: node.type === 'emphasis'
|
||||
? styles.italic
|
||||
: styles.strike
|
||||
const spanStyle = [outer, emphasis]
|
||||
return (
|
||||
<MarkdownText key={key} style={spanStyle}>
|
||||
{renderInline(node.children, ctx, spanStyle, `${key}.`)}
|
||||
</MarkdownText>
|
||||
)
|
||||
}
|
||||
case 'inlineCode':
|
||||
return ctx.onOpenFile && isFilePathCodeSpan(node.value) ? (
|
||||
<MarkdownText
|
||||
key={key}
|
||||
style={[outer, styles.inlineCode, styles.inlineCodeLink]}
|
||||
onPress={() => ctx.onOpenFile?.(normalizeFilePath(node.value.trim()))}
|
||||
>
|
||||
{node.value}
|
||||
</MarkdownText>
|
||||
) : (
|
||||
<MarkdownText key={key} style={[outer, styles.inlineCode]}>
|
||||
{node.value}
|
||||
</MarkdownText>
|
||||
)
|
||||
case 'link':
|
||||
return renderLink(key, plainText(node), node.url, ctx, outer)
|
||||
case 'image':
|
||||
return renderLink(key, node.alt || 'image', node.url, ctx, outer)
|
||||
case 'footnoteReference':
|
||||
return (
|
||||
<MarkdownText key={key} style={[outer, styles.footnoteReference]}>
|
||||
{`[${node.label ?? node.identifier}]`}
|
||||
</MarkdownText>
|
||||
)
|
||||
case 'html':
|
||||
return <Fragment key={key}>{node.value}</Fragment>
|
||||
// markdown-it resolves references itself, so these only carry their text.
|
||||
case 'linkReference':
|
||||
case 'imageReference':
|
||||
return <Fragment key={key}>{plainText(node)}</Fragment>
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline content as spans. iOS range-selectable spans are styled from the root alone, so `outer`
|
||||
* restates the enclosing emphasis on every span inside it. */
|
||||
export function renderInline(
|
||||
nodes: readonly PhrasingContent[],
|
||||
ctx: MarkdownInlineContext,
|
||||
outer?: SpanStyle,
|
||||
keyPrefix = ''
|
||||
): ReactNode[] {
|
||||
return nodes.map((node, index) => renderPhrasing(node, `${keyPrefix}${index}`, ctx, outer))
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
|
||||
import { parseMobileMarkdown } from './mobile-markdown-parser'
|
||||
|
||||
function parseWithDeadline(input: string) {
|
||||
// A synchronous parser loop must fail without hanging the test worker.
|
||||
return runInNewContext('parse(input)', { parse: parseMobileMarkdown, input }, { timeout: 250 })
|
||||
}
|
||||
|
||||
describe('mobile Markdown parser progress', () => {
|
||||
it.each(['```c++', '```c#', '``` ts', '```ts title="file.ts"', '````', '```!'])(
|
||||
'retains unsupported fence %s as paragraph text',
|
||||
(fence) => {
|
||||
expect(parseWithDeadline(fence)).toEqual([{ type: 'paragraph', text: fence }])
|
||||
expect(parseWithDeadline(`before\n${fence}\nafter`)).toEqual([
|
||||
{ type: 'paragraph', text: `before\n${fence}\nafter` }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['# ', '## ', '###### ', '#\t'])('consumes incomplete heading %s', (heading) => {
|
||||
expect(parseWithDeadline(`${heading}\nnext`)).toEqual([
|
||||
{ type: 'paragraph', text: `${heading}\nnext` }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles unsupported fences through the production preview normalization', () => {
|
||||
const input = normalizeMobileMarkdownPreviewHtml('```c++\nint x = 1;')
|
||||
expect(parseWithDeadline(input)).toEqual([{ type: 'paragraph', text: input }])
|
||||
})
|
||||
|
||||
it('recognizes a supported fence after unsupported fence text', () => {
|
||||
expect(parseWithDeadline('```c++\n```ts\nconst x = 1\n```')).toEqual([
|
||||
{ type: 'paragraph', text: '```c++' },
|
||||
{ type: 'code', text: 'const x = 1', language: 'ts', closed: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves supported fences and their streaming state', () => {
|
||||
expect(parseWithDeadline('before\n```ts\nconst x = 1')).toEqual([
|
||||
{ type: 'paragraph', text: 'before' },
|
||||
{ type: 'code', text: 'const x = 1', language: 'ts', closed: false }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
import { isTableSeparator, splitTableRow } from './rich-markdown/markdown-table-rows'
|
||||
import { mobileMarkdownVisualPlaceholderIndex } from './mobile-markdown-visual-lines'
|
||||
|
||||
export type MobileMarkdownBlock =
|
||||
| { type: 'paragraph'; text: string }
|
||||
| { type: 'heading'; level: number; text: string }
|
||||
| { type: 'quote'; text: string }
|
||||
| { type: 'code'; text: string; language?: string; closed: boolean }
|
||||
| { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> }
|
||||
| { type: 'image'; alt: string; url: string }
|
||||
| { type: 'table'; headers: string[]; rows: string[][] }
|
||||
| { type: 'rule' }
|
||||
/** A native-chat visual directive; `index` is its position in the protected directive list. */
|
||||
| { type: 'visual'; index: number }
|
||||
|
||||
const HEADING = /^(#{1,6})\s+(.+)$/
|
||||
const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/
|
||||
|
||||
/** `visualCount`: how many directive placeholders `protectMobileMarkdownVisualLines` left in
|
||||
* `content`; zero (the default) leaves every line to the ordinary rules. */
|
||||
export function parseMobileMarkdown(content: string, visualCount = 0): MobileMarkdownBlock[] {
|
||||
const visualIndex = (line: string): number | null =>
|
||||
visualCount > 0 ? mobileMarkdownVisualPlaceholderIndex(line, visualCount) : null
|
||||
const lines = content.replace(/\r\n?/g, '\n').split('\n')
|
||||
const blocks: MobileMarkdownBlock[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
if (!line.trim()) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const fence = line.match(CODE_FENCE)
|
||||
if (fence) {
|
||||
index += 1
|
||||
const code: string[] = []
|
||||
while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) {
|
||||
code.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
// closed=false means the fence is still streaming in (no terminator yet).
|
||||
const closed = index < lines.length
|
||||
if (closed) {
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1], closed })
|
||||
continue
|
||||
}
|
||||
|
||||
const visual = visualIndex(line)
|
||||
if (visual !== null) {
|
||||
blocks.push({ type: 'visual', index: visual })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
||||
blocks.push({ type: 'rule' })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const standaloneImage = line.match(/^!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)\s*$/i)
|
||||
if (standaloneImage) {
|
||||
blocks.push({ type: 'image', alt: standaloneImage[1] ?? '', url: standaloneImage[2]! })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
line.includes('|') &&
|
||||
index + 1 < lines.length &&
|
||||
isTableSeparator(lines[index + 1] ?? '')
|
||||
) {
|
||||
const headers = splitTableRow(line)
|
||||
index += 2
|
||||
const rows: string[][] = []
|
||||
while (index < lines.length && (lines[index] ?? '').includes('|') && lines[index]?.trim()) {
|
||||
rows.push(splitTableRow(lines[index] ?? ''))
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'table', headers, rows })
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = line.match(HEADING)
|
||||
if (heading) {
|
||||
blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(line)) {
|
||||
const quote: string[] = []
|
||||
while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) {
|
||||
quote.push((lines[index] ?? '').replace(/^>\s?/, ''))
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'quote', text: quote.join('\n').trim() })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) {
|
||||
const items: Array<{ text: string; checked?: boolean }> = []
|
||||
let ordered = false
|
||||
while (index < lines.length && /^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '')) {
|
||||
const current = lines[index] ?? ''
|
||||
const orderedMatch = current.match(/^\s*\d+[.)]\s+(.+)$/)
|
||||
const unorderedMatch = current.match(/^\s*[-*+]\s+(.+)$/)
|
||||
ordered ||= Boolean(orderedMatch)
|
||||
const rawText = (orderedMatch?.[1] ?? unorderedMatch?.[1] ?? '').trim()
|
||||
const task = rawText.match(/^\[([ xX])\]\s+(.+)$/)
|
||||
items.push({
|
||||
text: task?.[2] ?? rawText,
|
||||
checked: task ? task[1]?.toLowerCase() === 'x' : undefined
|
||||
})
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'list', ordered, items })
|
||||
continue
|
||||
}
|
||||
|
||||
const paragraph: string[] = []
|
||||
while (
|
||||
index < lines.length &&
|
||||
lines[index]?.trim() &&
|
||||
!CODE_FENCE.test(lines[index] ?? '') &&
|
||||
!HEADING.test(lines[index] ?? '') &&
|
||||
!/^>\s?/.test(lines[index] ?? '') &&
|
||||
!/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') &&
|
||||
!/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '') &&
|
||||
visualIndex(lines[index] ?? '') === null
|
||||
) {
|
||||
paragraph.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'paragraph', text: paragraph.join('\n').trim() })
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
@@ -49,7 +49,8 @@ export const styles = StyleSheet.create({
|
||||
quote: {
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.borderSubtle,
|
||||
paddingLeft: spacing.sm
|
||||
paddingLeft: spacing.sm,
|
||||
gap: spacing.xs
|
||||
},
|
||||
quoteText: {
|
||||
fontSize: 13,
|
||||
@@ -101,8 +102,6 @@ export const styles = StyleSheet.create({
|
||||
flexDirection: 'row'
|
||||
},
|
||||
tableCell: {
|
||||
minWidth: 112,
|
||||
maxWidth: 220,
|
||||
borderRightWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
@@ -116,11 +115,6 @@ export const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
tableTruncated: {
|
||||
padding: spacing.sm,
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
list: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
@@ -130,19 +124,26 @@ export const styles = StyleSheet.create({
|
||||
gap: spacing.sm
|
||||
},
|
||||
listMarker: {
|
||||
width: 22,
|
||||
// A floor, not a width: "10." would wrap inside a fixed 22.
|
||||
minWidth: 22,
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
listText: {
|
||||
listItemBody: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: spacing.xs
|
||||
},
|
||||
listText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
footnoteReference: {
|
||||
color: colors.textSecondary
|
||||
},
|
||||
rule: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { spacing } from '../theme/mobile-theme'
|
||||
|
||||
// Per-glyph advances at the 12px table font, measured on agent tables: 6.4 regular and 6.9 bold
|
||||
// on average, and up to 8.7 for a bold word. A wrong guess wraps text; it cannot misalign columns.
|
||||
const AVERAGE_GLYPH_WIDTH = 7
|
||||
const WIDEST_GLYPH_WIDTH = 9
|
||||
// Horizontal padding plus the right border of `styles.tableCell`.
|
||||
const CELL_CHROME = spacing.sm * 2 + 1
|
||||
const MIN_COLUMN_WIDTH = 48
|
||||
const MAX_COLUMN_WIDTH = 220
|
||||
const WHITESPACE = /\s/
|
||||
|
||||
function cellContentWidth(text: string): number {
|
||||
let columns = 0
|
||||
let word = 0
|
||||
let longestWord = 0
|
||||
for (const char of text) {
|
||||
// CJK, kana, Hangul, fullwidth forms and emoji draw about two Latin glyphs wide.
|
||||
const wide = char.codePointAt(0)! >= 0x2e80
|
||||
columns += wide ? 2 : 1
|
||||
// Lines break at spaces and around wide glyphs; only a longer unbroken run splits mid-word.
|
||||
word = wide || WHITESPACE.test(char) ? 0 : word + 1
|
||||
longestWord = Math.max(longestWord, word)
|
||||
}
|
||||
return Math.max(columns * AVERAGE_GLYPH_WIDTH, longestWord * WIDEST_GLYPH_WIDTH)
|
||||
}
|
||||
|
||||
/** One width per header column, sized to the column's widest cell. `rows` holds each cell's
|
||||
* drawn text, header row first. */
|
||||
export function mobileMarkdownTableColumnWidths(rows: readonly string[][]): number[] {
|
||||
return (rows[0] ?? []).map((_, column) => {
|
||||
let widest = 0
|
||||
for (const row of rows) {
|
||||
widest = Math.max(widest, cellContentWidth(row[column] ?? ''))
|
||||
}
|
||||
return Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, widest + CELL_CHROME))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MobileMarkdown } from './MobileMarkdown'
|
||||
import { styles } from './mobile-markdown-styles'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: () => Promise.resolve() },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
|
||||
|
||||
/** Each rendered table row's cell styles, header row first. */
|
||||
function renderTableCellStyles(content: string): unknown[][] {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileMarkdown, { content }))
|
||||
})
|
||||
const rows = renderer!.root.findAll((node) => node.props.style === styles.tableRow)
|
||||
const cellStyles = rows.map((row) =>
|
||||
row.children.map((cell) => (typeof cell === 'string' ? undefined : cell.props.style))
|
||||
)
|
||||
act(() => renderer!.unmount())
|
||||
return cellStyles
|
||||
}
|
||||
|
||||
function cellWidth(style: unknown): number {
|
||||
for (const entry of [style].flat()) {
|
||||
if (entry && typeof entry === 'object' && 'width' in entry && typeof entry.width === 'number') {
|
||||
return entry.width
|
||||
}
|
||||
}
|
||||
throw new Error('table cell has no width')
|
||||
}
|
||||
|
||||
function markdownTable(headers: string[], rows: string[][]): string {
|
||||
return [headers, headers.map(() => '---'), ...rows]
|
||||
.map((row) => `| ${row.join(' | ')} |`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('MobileMarkdown tables', () => {
|
||||
it('renders every row and column with one width per column', () => {
|
||||
const headers = Array.from({ length: 10 }, (_, column) => `Column ${column}`)
|
||||
const rows = Array.from({ length: 50 }, (_, row) =>
|
||||
headers.map((_, column) => (row === 7 && column === 3 ? 'a much longer cell value' : 'x'))
|
||||
)
|
||||
const cellStyles = renderTableCellStyles(markdownTable(headers, rows))
|
||||
|
||||
expect(cellStyles.map((row) => row.length)).toEqual(Array.from({ length: 51 }, () => 10))
|
||||
const widths = cellStyles.map((row) => row.map(cellWidth))
|
||||
for (const row of widths) {
|
||||
expect(row).toEqual(widths[0])
|
||||
}
|
||||
expect(widths[0]![3]).toBeGreaterThan(widths[0]![2]!)
|
||||
})
|
||||
|
||||
it('sizes a column by the text it draws, not its markdown source', () => {
|
||||
const [widths] = renderTableCellStyles(
|
||||
markdownTable(
|
||||
['A', 'B', 'C'],
|
||||
[['docs', '[docs](https://example.com/a/very/long/path)', '文档文档']]
|
||||
)
|
||||
).map((row) => row.map(cellWidth))
|
||||
|
||||
expect(widths![1]).toBe(widths![0])
|
||||
expect(widths![2]).toBeGreaterThan(widths![0]!)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext, type ComponentType } from 'react'
|
||||
import { Text as NativeText, type TextProps } from 'react-native'
|
||||
|
||||
export type MarkdownTextSetup = {
|
||||
TextComponent: ComponentType<TextProps>
|
||||
/** Disable native selection only within the Android transcript. */
|
||||
androidTranscript: boolean
|
||||
onLongPress?: () => void
|
||||
}
|
||||
|
||||
export const MarkdownTextContext = createContext<MarkdownTextSetup>({
|
||||
TextComponent: NativeText,
|
||||
androidTranscript: false
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createElement, useContext } from 'react'
|
||||
import type { TextProps } from 'react-native'
|
||||
import { MarkdownTextContext } from './mobile-markdown-text-setup'
|
||||
|
||||
export function MarkdownText(props: TextProps): React.JSX.Element {
|
||||
const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext)
|
||||
if (!androidTranscript) {
|
||||
return createElement(TextComponent, props)
|
||||
}
|
||||
// Override selection without changing the nested spans' inherited behavior.
|
||||
return createElement(TextComponent, {
|
||||
...props,
|
||||
...(props.selectable === true ? { selectable: false } : {}),
|
||||
...(onLongPress && props.onPress ? { onLongPress } : {})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import footnote from 'markdown-it-footnote'
|
||||
import type {
|
||||
AlignType,
|
||||
Code,
|
||||
Nodes,
|
||||
Parent,
|
||||
PhrasingContent,
|
||||
Root,
|
||||
RootContent,
|
||||
Table
|
||||
} from 'mdast'
|
||||
import remend from 'remend'
|
||||
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
|
||||
|
||||
// CommonMark with GitHub's tables, strikethrough, autolinks and footnotes: the rules desktop's
|
||||
// renderer follows, read by a parser fast enough for the phone's JavaScript engine.
|
||||
const markdown = new MarkdownIt({ html: true, linkify: true }).use(footnote)
|
||||
// Every href parses as a link, file: included; routeMarkdownHref decides what a tap opens.
|
||||
markdown.validateLink = () => true
|
||||
|
||||
type Token = ReturnType<MarkdownIt['parse']>[number]
|
||||
|
||||
const PATH_FLANK = /[\w\\/]/
|
||||
const TASK_MARKER = /^\[([ xX])\](?:\s+|$)/
|
||||
const LINE_BREAK_TAG = /^<br\s*\/?>$/i
|
||||
const HEADING_DEPTHS = [1, 2, 3, 4, 5, 6] as const
|
||||
/** Parents that hold blocks; any other parent holds inline content. */
|
||||
const BLOCK_PARENTS = new Set(['root', 'blockquote', 'listItem', 'footnoteDefinition'])
|
||||
const CLOSING_TOKENS = new Set([
|
||||
'paragraph_close',
|
||||
'heading_close',
|
||||
'blockquote_close',
|
||||
'bullet_list_close',
|
||||
'ordered_list_close',
|
||||
'list_item_close',
|
||||
'table_close',
|
||||
'tr_close',
|
||||
'th_close',
|
||||
'td_close',
|
||||
'footnote_close'
|
||||
])
|
||||
|
||||
/** The text a node draws, as mdast-util-to-string reads it. */
|
||||
export function plainText(node: Nodes): string {
|
||||
if ('value' in node) {
|
||||
return node.value
|
||||
}
|
||||
if (node.type === 'image') {
|
||||
return node.alt ?? ''
|
||||
}
|
||||
return 'children' in node ? node.children.map(plainText).join('') : ''
|
||||
}
|
||||
|
||||
function pushText(nodes: PhrasingContent[], value: string): void {
|
||||
const previous = nodes.at(-1)
|
||||
if (previous?.type === 'text') {
|
||||
previous.value += value
|
||||
} else {
|
||||
nodes.push({ type: 'text', value })
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline tokens up to `close`, and the index of that closing token. */
|
||||
function phrasing(
|
||||
tokens: readonly Token[],
|
||||
start: number,
|
||||
close?: string
|
||||
): { nodes: PhrasingContent[]; next: number } {
|
||||
const nodes: PhrasingContent[] = []
|
||||
let index = start
|
||||
while (index < tokens.length && tokens[index]!.type !== close) {
|
||||
const token = tokens[index]!
|
||||
index += 1
|
||||
if (token.type === 'text') {
|
||||
pushText(nodes, token.content)
|
||||
} else if (token.type === 'softbreak') {
|
||||
pushText(nodes, '\n')
|
||||
} else if (token.type === 'hardbreak') {
|
||||
nodes.push({ type: 'break' })
|
||||
} else if (token.type === 'code_inline') {
|
||||
nodes.push({ type: 'inlineCode', value: token.content })
|
||||
} else if (token.type === 'html_inline') {
|
||||
nodes.push({ type: 'html', value: token.content })
|
||||
} else if (token.type === 'image') {
|
||||
nodes.push({ type: 'image', url: token.attrGet('src') ?? '', alt: token.content || null })
|
||||
} else if (token.type === 'footnote_ref') {
|
||||
const label = String(token.meta?.label ?? '')
|
||||
nodes.push({ type: 'footnoteReference', identifier: label, label })
|
||||
} else if (token.nesting === 1) {
|
||||
const inner = phrasing(tokens, index, token.type.replace(/_open$/, '_close'))
|
||||
index = inner.next + 1
|
||||
const previous = nodes.at(-1)
|
||||
const before = previous?.type === 'text' ? previous.value.slice(-1) : ''
|
||||
const after = tokens[index]?.type === 'text' ? (tokens[index]?.content[0] ?? '') : ''
|
||||
if (token.type === 'link_open') {
|
||||
nodes.push({ type: 'link', url: token.attrGet('href') ?? '', children: inner.nodes })
|
||||
} else if (
|
||||
// `_` emphasis against a path separator stays literal, so a dunder segment
|
||||
// (`a/__tests__/x.ts`) keeps its path whole and tappable.
|
||||
token.markup.startsWith('_') &&
|
||||
(PATH_FLANK.test(before) || PATH_FLANK.test(after))
|
||||
) {
|
||||
const literal = inner.nodes.map(plainText).join('')
|
||||
pushText(nodes, `${token.markup}${literal}${token.markup}`)
|
||||
} else if (token.type === 'strong_open') {
|
||||
nodes.push({ type: 'strong', children: inner.nodes })
|
||||
} else if (token.type === 'em_open') {
|
||||
nodes.push({ type: 'emphasis', children: inner.nodes })
|
||||
} else if (token.type === 's_open') {
|
||||
nodes.push({ type: 'delete', children: inner.nodes })
|
||||
} else {
|
||||
nodes.push(...inner.nodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { nodes, next: index }
|
||||
}
|
||||
|
||||
/** Whether a code block's closing fence has arrived; an unclosed one is still streaming in. */
|
||||
export function codeFenceClosed(node: Code): boolean {
|
||||
return !('closed' in node) || node.closed !== false
|
||||
}
|
||||
|
||||
function codeNode(token: Token, lines: readonly string[]): Code & { closed: boolean } {
|
||||
if (token.type !== 'fence') {
|
||||
return { type: 'code', lang: null, value: token.content.replace(/\n$/, ''), closed: true }
|
||||
}
|
||||
const [start, end] = token.map ?? [0, 0]
|
||||
// A fence inside a quote carries the quote's marker on its closing line.
|
||||
const closing = end - start > 1 ? (lines[end - 1] ?? '').replace(/^[\s>]*/, '').trim() : ''
|
||||
return {
|
||||
type: 'code',
|
||||
lang: token.info.trim().split(/\s+/)[0] || null,
|
||||
value: token.content.replace(/\n$/, ''),
|
||||
closed:
|
||||
closing.length >= token.markup.length &&
|
||||
[...closing].every((char) => char === token.markup[0])
|
||||
}
|
||||
}
|
||||
|
||||
function alignOf(token: Token): AlignType {
|
||||
const align = /text-align:\s*(left|right|center)/.exec(token.attrGet('style') ?? '')?.[1]
|
||||
return align === 'left' || align === 'right' || align === 'center' ? align : null
|
||||
}
|
||||
|
||||
/** GitHub task list items: `[ ]` or `[x]` opening the item's first paragraph. */
|
||||
function readTaskMarker(item: Parent & { checked?: boolean | null }): void {
|
||||
const paragraph = item.children[0]
|
||||
const text = paragraph?.type === 'paragraph' ? paragraph.children[0] : undefined
|
||||
const marker = text?.type === 'text' ? TASK_MARKER.exec(text.value) : null
|
||||
if (text?.type === 'text' && marker) {
|
||||
item.checked = marker[1] !== ' '
|
||||
text.value = text.value.slice(marker[0].length)
|
||||
}
|
||||
}
|
||||
|
||||
/** markdown-it's flat token stream as the mdast tree the renderer draws. */
|
||||
function toTree(tokens: readonly Token[], source: string): Root {
|
||||
const root: Root = { type: 'root', children: [] }
|
||||
const lines = source.split('\n')
|
||||
const stack: Parent[] = [root]
|
||||
let table: Table | undefined
|
||||
const open = (node: Parent & RootContent): void => {
|
||||
stack.at(-1)!.children.push(node)
|
||||
stack.push(node)
|
||||
}
|
||||
for (const token of tokens) {
|
||||
const parent = stack.at(-1)!
|
||||
switch (token.type) {
|
||||
case 'paragraph_open':
|
||||
open({ type: 'paragraph', children: [] })
|
||||
break
|
||||
case 'heading_open':
|
||||
open({
|
||||
type: 'heading',
|
||||
depth: HEADING_DEPTHS.find((depth) => token.tag === `h${depth}`) ?? 6,
|
||||
children: []
|
||||
})
|
||||
break
|
||||
case 'blockquote_open':
|
||||
open({ type: 'blockquote', children: [] })
|
||||
break
|
||||
case 'bullet_list_open':
|
||||
case 'ordered_list_open':
|
||||
open({
|
||||
type: 'list',
|
||||
ordered: token.type === 'ordered_list_open',
|
||||
start: Number(token.attrGet('start') ?? 1),
|
||||
children: []
|
||||
})
|
||||
break
|
||||
case 'list_item_open':
|
||||
open({ type: 'listItem', checked: null, children: [] })
|
||||
break
|
||||
case 'table_open':
|
||||
table = { type: 'table', align: [], children: [] }
|
||||
open(table)
|
||||
break
|
||||
case 'tr_open':
|
||||
open({ type: 'tableRow', children: [] })
|
||||
break
|
||||
case 'th_open':
|
||||
case 'td_open':
|
||||
if (token.type === 'th_open') {
|
||||
table?.align?.push(alignOf(token))
|
||||
}
|
||||
open({ type: 'tableCell', children: [] })
|
||||
break
|
||||
case 'footnote_open': {
|
||||
const label = String(token.meta?.label ?? '')
|
||||
open({ type: 'footnoteDefinition', identifier: label, label, children: [] })
|
||||
break
|
||||
}
|
||||
case 'inline':
|
||||
parent.children.push(...phrasing(token.children ?? [], 0).nodes)
|
||||
break
|
||||
case 'fence':
|
||||
case 'code_block':
|
||||
parent.children.push(codeNode(token, lines))
|
||||
break
|
||||
case 'hr':
|
||||
parent.children.push({ type: 'thematicBreak' })
|
||||
break
|
||||
case 'html_block':
|
||||
parent.children.push({ type: 'html', value: token.content })
|
||||
break
|
||||
default:
|
||||
if (CLOSING_TOKENS.has(token.type)) {
|
||||
const closed = stack.pop()
|
||||
if (closed?.type === 'listItem') {
|
||||
readTaskMarker(closed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HTML the parser found, and only that: code is never an `html` node, so nothing inside a
|
||||
* code block or span is touched. A block is read as the Markdown the preview cleanup makes of
|
||||
* it (README headers and badges); an inline tag becomes the text the cleanup leaves, if any.
|
||||
* `nested`: a block the cleanup produced, where leftover block HTML stays text.
|
||||
*/
|
||||
function withoutHtml(parent: Parent, nested: boolean): void {
|
||||
const inline = !BLOCK_PARENTS.has(parent.type)
|
||||
parent.children = parent.children.flatMap((child): RootContent[] => {
|
||||
if (child.type !== 'html') {
|
||||
if ('children' in child) {
|
||||
withoutHtml(child, nested)
|
||||
}
|
||||
return [child]
|
||||
}
|
||||
if (inline && LINE_BREAK_TAG.test(child.value.trim())) {
|
||||
return [{ type: 'break' }]
|
||||
}
|
||||
const text = normalizeMobileMarkdownPreviewHtml(child.value)
|
||||
if (!text) {
|
||||
return []
|
||||
}
|
||||
if (inline) {
|
||||
return [{ type: 'text', value: text }]
|
||||
}
|
||||
if (nested) {
|
||||
return [{ type: 'paragraph', children: [{ type: 'text', value: text }] }]
|
||||
}
|
||||
const blocks = toTree(markdown.parse(text, {}), text)
|
||||
withoutHtml(blocks, true)
|
||||
return blocks.children
|
||||
})
|
||||
}
|
||||
|
||||
/** The text with the markup its last top-level block leaves open closed, as desktop closes it.
|
||||
* Only that block: a lone `*` earlier in the reply is no opener, and remend would close it. */
|
||||
function withLastBlockClosed(source: string, tokens: readonly Token[]): string {
|
||||
const lastBlock = tokens.findLast((token) => token.level === 0 && token.map)?.map?.[0] ?? 0
|
||||
const lines = source.split('\n')
|
||||
const tail = lines.slice(lastBlock).join('\n')
|
||||
const closed = remend(tail, { linkMode: 'text-only' })
|
||||
return closed === tail ? source : [...lines.slice(0, lastBlock), closed].join('\n')
|
||||
}
|
||||
|
||||
/** `growing`: the text is still arriving, so markup its end leaves open is closed first, instead
|
||||
* of flashing raw markers and half-written links. */
|
||||
export function parseMobileMarkdownTree(source: string, growing: boolean): Root {
|
||||
let tokens = markdown.parse(source, {})
|
||||
let text = source
|
||||
if (growing) {
|
||||
text = withLastBlockClosed(source, tokens)
|
||||
tokens = text === source ? tokens : markdown.parse(text, {})
|
||||
}
|
||||
const root = toTree(tokens, text)
|
||||
withoutHtml(root, false)
|
||||
return root
|
||||
}
|
||||
|
||||
/** The 0-based lines markdown-it reads as code, at any depth. */
|
||||
export function mobileMarkdownCodeLines(source: string): Set<number> {
|
||||
const lines = new Set<number>()
|
||||
for (const token of markdown.parse(source, {})) {
|
||||
if ((token.type === 'fence' || token.type === 'code_block') && token.map) {
|
||||
for (let line = token.map[0]; line < token.map[1]; line += 1) {
|
||||
lines.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE } from '../../../src/shared/native-chat-visual-directive'
|
||||
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
|
||||
import { parseMobileMarkdown } from './mobile-markdown-parser'
|
||||
import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines'
|
||||
import { parseMobileMarkdownTree, plainText } from './mobile-markdown-tree'
|
||||
import {
|
||||
mobileMarkdownVisualPlaceholderIndex,
|
||||
protectMobileMarkdownVisualLines
|
||||
} from './mobile-markdown-visual-lines'
|
||||
|
||||
/** The top-level blocks drawn, with a lone placeholder paragraph read as its visual. */
|
||||
function topLevelBlocks(text: string, visualCount: number) {
|
||||
return parseMobileMarkdownTree(text, false).children.map((node) => {
|
||||
const visual =
|
||||
node.type === 'paragraph'
|
||||
? mobileMarkdownVisualPlaceholderIndex(plainText(node), visualCount)
|
||||
: null
|
||||
if (visual !== null) {
|
||||
return { type: 'visual', index: visual }
|
||||
}
|
||||
return node.type === 'code'
|
||||
? { type: 'code', text: node.value, language: node.lang }
|
||||
: { type: node.type, text: plainText(node) }
|
||||
})
|
||||
}
|
||||
|
||||
/** The same steps MobileMarkdown runs when a transcript renders visuals. */
|
||||
function blocksOf(content: string) {
|
||||
const { text, directives } = protectMobileMarkdownVisualLines(content)
|
||||
return {
|
||||
directives,
|
||||
blocks: parseMobileMarkdown(normalizeMobileMarkdownPreviewHtml(text), directives.length)
|
||||
}
|
||||
return { directives, blocks: topLevelBlocks(text, directives.length) }
|
||||
}
|
||||
|
||||
describe('native-chat visual lines in mobile markdown', () => {
|
||||
@@ -40,7 +55,7 @@ describe('native-chat visual lines in mobile markdown', () => {
|
||||
const fenced = blocksOf('```md\n::orca-visual{file="a.html"}\n```')
|
||||
expect(fenced.directives).toEqual([])
|
||||
expect(fenced.blocks).toEqual([
|
||||
{ type: 'code', text: '::orca-visual{file="a.html"}', language: 'md', closed: true }
|
||||
{ type: 'code', text: '::orca-visual{file="a.html"}', language: 'md' }
|
||||
])
|
||||
expect(blocksOf('> ::orca-visual{file="a.html"}').directives).toEqual([])
|
||||
expect(blocksOf('- ::orca-visual{file="a.html"}').directives).toEqual([])
|
||||
@@ -83,7 +98,7 @@ describe('native-chat visual lines in mobile markdown', () => {
|
||||
})
|
||||
|
||||
it('never treats a placeholder-looking line as a visual when none were protected', () => {
|
||||
expect(parseMobileMarkdown('\uE000ORCA_VISUAL_0\uE000')).toEqual([
|
||||
expect(topLevelBlocks('\uE000ORCA_VISUAL_0\uE000', 0)).toEqual([
|
||||
{ type: 'paragraph', text: '\uE000ORCA_VISUAL_0\uE000' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER,
|
||||
NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE,
|
||||
parseNativeChatVisualDirectiveLine,
|
||||
type NativeChatVisualDirective
|
||||
} from '../../../src/shared/native-chat-visual-directive'
|
||||
|
||||
/** Same fence shapes the block parser opens and closes on, so a directive inside code stays code. */
|
||||
const CODE_FENCE_OPEN = /^```([A-Za-z0-9_-]+)?\s*$/
|
||||
const CODE_FENCE_CLOSE = /^```\s*$/
|
||||
import { mobileMarkdownCodeLines } from './mobile-markdown-tree'
|
||||
// Private-use delimiters, as the preview normalizer's own code placeholders use.
|
||||
const PLACEHOLDER_PREFIX = '\uE000ORCA_VISUAL_'
|
||||
const PLACEHOLDER = /^\uE000ORCA_VISUAL_(\d+)\uE000$/
|
||||
@@ -22,29 +20,24 @@ function placeholderFor(index: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts native-chat visual directive lines out of assistant prose before preview normalization,
|
||||
* which decodes entities and strips tags and would otherwise rewrite a title. Only top-level lines
|
||||
* outside fenced code count; quotes and list items never start with the marker. Past the per-message
|
||||
* cap a directive stays literal text.
|
||||
* Lifts native-chat visual directive lines out of assistant prose before it is parsed, which
|
||||
* decodes entities and reads tags and would otherwise rewrite a title. Only lines outside code
|
||||
* count, as the parser reads it; quotes and list items never start with the marker. Past the
|
||||
* per-message cap a directive stays literal text.
|
||||
*/
|
||||
export function protectMobileMarkdownVisualLines(content: string): MobileMarkdownVisualLines {
|
||||
// Text that already spells a placeholder would alias a real one; it renders no visuals at all.
|
||||
if (content.includes(PLACEHOLDER_PREFIX)) {
|
||||
if (
|
||||
content.includes(PLACEHOLDER_PREFIX) ||
|
||||
!content.includes(NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER)
|
||||
) {
|
||||
return { text: content, directives: [] }
|
||||
}
|
||||
const lines = content.replace(/\r\n?/g, '\n').split('\n')
|
||||
const normalized = content.replace(/\r\n?/g, '\n')
|
||||
const code = mobileMarkdownCodeLines(normalized)
|
||||
const directives: NativeChatVisualDirective[] = []
|
||||
let inFence = false
|
||||
const next = lines.map((line) => {
|
||||
if (inFence) {
|
||||
inFence = !CODE_FENCE_CLOSE.test(line)
|
||||
return line
|
||||
}
|
||||
if (CODE_FENCE_OPEN.test(line)) {
|
||||
inFence = true
|
||||
return line
|
||||
}
|
||||
if (directives.length >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) {
|
||||
const next = normalized.split('\n').map((line, index) => {
|
||||
if (code.has(index) || directives.length >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) {
|
||||
return line
|
||||
}
|
||||
const directive = parseNativeChatVisualDirectiveLine(line)
|
||||
|
||||
@@ -16,7 +16,10 @@ import { resolveBottomDrawerFillHeight } from './bottom-drawer-fill-height'
|
||||
import { resolveBottomDrawerKeyboardInset } from './bottom-drawer-keyboard-inset'
|
||||
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
|
||||
import { bottomDrawerStyles as styles } from './bottom-drawer-styles'
|
||||
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
|
||||
import {
|
||||
useBottomDrawerModalHost,
|
||||
useBottomDrawerModalHostBackClaim
|
||||
} from './bottom-drawer-modal-host-scope'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import { useWindowSafeAreaInsets } from '../layout/side-safe-area'
|
||||
import { useBackClaim } from '../navigation/use-back-claim'
|
||||
@@ -73,7 +76,7 @@ export function MountedBottomDrawer({
|
||||
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
|
||||
// transforms below) is unchanged, so phone behavior stays identical.
|
||||
const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
|
||||
const insideModalHost = useInsideBottomDrawerModalHost()
|
||||
const insideModalHost = useBottomDrawerModalHost() !== null
|
||||
const fillHeight = fillAvailable
|
||||
? resolveBottomDrawerFillHeight({
|
||||
screenHeight,
|
||||
@@ -202,6 +205,7 @@ export function MountedBottomDrawer({
|
||||
}
|
||||
: null
|
||||
)
|
||||
useBottomDrawerModalHostBackClaim(visible && interactive ? dismiss : null)
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler((event) => {
|
||||
scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
|
||||
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
|
||||
import { parseMobileMarkdown, type MobileMarkdownBlock } from './mobile-markdown-parser'
|
||||
import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines'
|
||||
|
||||
const NO_DIRECTIVES: NativeChatVisualDirective[] = []
|
||||
|
||||
/** The blocks MobileMarkdown draws; with `visuals`, directive lines become `visual` blocks. */
|
||||
export function useMobileMarkdownBlocks(
|
||||
text: string,
|
||||
visuals: boolean
|
||||
): { blocks: MobileMarkdownBlock[]; directives: NativeChatVisualDirective[] } {
|
||||
const visualLines = useMemo(
|
||||
() => (visuals ? protectMobileMarkdownVisualLines(text) : null),
|
||||
[text, visuals]
|
||||
)
|
||||
const previewText = useMemo(
|
||||
() => normalizeMobileMarkdownPreviewHtml(visualLines?.text ?? text),
|
||||
[visualLines, text]
|
||||
)
|
||||
const directives = visualLines?.directives ?? NO_DIRECTIVES
|
||||
const blocks = useMemo(
|
||||
() => parseMobileMarkdown(previewText, directives.length),
|
||||
[previewText, directives.length]
|
||||
)
|
||||
return { blocks, directives }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Root } from 'mdast'
|
||||
import { useMemo } from 'react'
|
||||
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
|
||||
import { parseMobileMarkdownTree } from './mobile-markdown-tree'
|
||||
import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines'
|
||||
|
||||
const NO_DIRECTIVES: NativeChatVisualDirective[] = []
|
||||
|
||||
/** The tree MobileMarkdown draws; with `visuals`, directive lines become placeholders. */
|
||||
export function useMobileMarkdownTree(
|
||||
text: string,
|
||||
visuals: boolean,
|
||||
growing: boolean
|
||||
): { root: Root; directives: NativeChatVisualDirective[] } {
|
||||
const visualLines = useMemo(
|
||||
() => (visuals ? protectMobileMarkdownVisualLines(text) : null),
|
||||
[text, visuals]
|
||||
)
|
||||
const source = visualLines?.text ?? text
|
||||
const root = useMemo(() => parseMobileMarkdownTree(source, growing), [source, growing])
|
||||
return { root, directives: visualLines?.directives ?? NO_DIRECTIVES }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SectionRepo } from '../worktree/workspace-list-types'
|
||||
import { sectionRepoColor } from '../worktree/workspace-filter-repos'
|
||||
import { readMobileRepoIcon, type MobileHostRepoIcon } from './host-screen-reply-schema'
|
||||
|
||||
/** The colour and icon a repo's header and rows draw. */
|
||||
export type RepoLook = { color: string; icon: MobileHostRepoIcon | null }
|
||||
|
||||
/** Decoded once per catalog, so a row's props keep their identity across polls. */
|
||||
export function buildRepoLookById(
|
||||
repoById: ReadonlyMap<string, SectionRepo>
|
||||
): ReadonlyMap<string, RepoLook> {
|
||||
return new Map(
|
||||
[...repoById].map(([id, repo]) => [
|
||||
id,
|
||||
{ color: sectionRepoColor(repo), icon: readMobileRepoIcon(repo.repoIcon) }
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
<Text style={styles.filterSectionLabel}>Repositories</Text>
|
||||
<View style={styles.filterGroup}>
|
||||
{controller.sectionsResult.uniqueRepos.map((repo, i) => (
|
||||
<View key={repo.id}>
|
||||
<View key={repo.key}>
|
||||
{i > 0 && <View style={styles.filterSeparator} />}
|
||||
<Pressable
|
||||
style={styles.filterRow}
|
||||
|
||||
@@ -30,6 +30,17 @@ const repoIconBranches = {
|
||||
label: salvagedOptional('label', z.string())
|
||||
})
|
||||
} satisfies Readonly<Record<RepoIcon['type'], z.ZodType>>
|
||||
const mobileRepoIconSchema = z.union([
|
||||
repoIconBranches.lucide,
|
||||
repoIconBranches.emoji,
|
||||
repoIconBranches.image
|
||||
])
|
||||
|
||||
/** A catalog repo's icon as MobileRepoIcon draws it; null for an arm this build cannot read. */
|
||||
export function readMobileRepoIcon(value: unknown): MobileHostRepoIcon | null {
|
||||
const parsed = mobileRepoIconSchema.safeParse(value)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
// What the host screen reads to label its rows and to mirror the desktop's workspace view store.
|
||||
// Checked against src/main/runtime/rpc/methods/repo.ts:29, ssh.ts:55, host-capabilities.ts:8 and
|
||||
@@ -37,12 +48,12 @@ const repoIconBranches = {
|
||||
// PersistedUIState's workspace-view subset in mobile/src/worktree/workspace-view-settings.ts:14.
|
||||
|
||||
/**
|
||||
* The host's repo catalog, narrowed to what the label maps are built from.
|
||||
* The host's repo catalog, narrowed to what the host-label maps and the repo cache are built from.
|
||||
*
|
||||
* `id` and `displayName` are required and a row without either drops: `displayName` is the key of
|
||||
* four Maps and the argument `repoColor` hashes with `name.charCodeAt` — a row without one was a
|
||||
* TypeError that took the whole metadata refresh with it — and `id` is the Map value the workspace
|
||||
* rows resolve their host through.
|
||||
* `id` and `displayName` are required and a row without either drops: `displayName` is what the
|
||||
* new-workspace picker shows from the repo cache, and `id` is the Map key the workspace rows
|
||||
* resolve their host through. `path` labels an older desktop's repo sections (the newer one serves
|
||||
* project-sections-operations.ts instead).
|
||||
*
|
||||
* `badgeColor` sits behind main's own `||` fallback to `repoColor`, so a salvaged member draws the
|
||||
* same swatch. `repoIcon` is declared as the three arms MobileRepoIcon renders, and an arm this
|
||||
@@ -64,11 +75,9 @@ export const hostRepoCatalogSchema = z
|
||||
z.looseObject({
|
||||
id: z.string(),
|
||||
displayName: z.string(),
|
||||
path: salvagedOptional('path', z.string()),
|
||||
badgeColor: salvagedOptional('badgeColor', z.string()),
|
||||
repoIcon: salvagedOptional(
|
||||
'repoIcon',
|
||||
z.union([repoIconBranches.lucide, repoIconBranches.emoji, repoIconBranches.image])
|
||||
),
|
||||
repoIcon: salvagedOptional('repoIcon', mobileRepoIconSchema),
|
||||
connectionId: salvagedOptional('connectionId', z.string().nullable()),
|
||||
executionHostId: salvagedOptional('executionHostId', z.string().nullable())
|
||||
})
|
||||
@@ -184,6 +193,8 @@ export const hostViewSettingsSchema = z
|
||||
*/
|
||||
export const hostScreenUnreadReplySchema = z.unknown()
|
||||
|
||||
export type HostRepoCatalogRow = z.output<typeof hostRepoCatalogSchema>[number]
|
||||
|
||||
/** One decoded catalog icon: the members MobileRepoIcon reads, with the rest passed through. */
|
||||
export type MobileHostRepoIcon = NonNullable<
|
||||
z.output<typeof hostRepoCatalogSchema>[number]['repoIcon']
|
||||
|
||||
@@ -48,6 +48,7 @@ function screenState(clientRef: ClientRef, applied: boolean[]) {
|
||||
[
|
||||
'setActionError',
|
||||
'setCatalogError',
|
||||
'setDesktopRepos',
|
||||
'setError',
|
||||
'setHostLabelById',
|
||||
'setHostHealthById',
|
||||
@@ -57,9 +58,8 @@ function screenState(clientRef: ClientRef, applied: boolean[]) {
|
||||
'setLastKnownWorktrees',
|
||||
'setOptimisticActiveWorktreeIdentity',
|
||||
'setPinnedIds',
|
||||
'setRepoColorsByName',
|
||||
'setRepoHostIdByRepoId',
|
||||
'setRepoIconsByName',
|
||||
'setServedProjectSections',
|
||||
'setSleptIds',
|
||||
'setWorktrees',
|
||||
'setWorktreesLoaded'
|
||||
|
||||
@@ -60,7 +60,7 @@ function listWith(fields: { actionError: string; setActionError: (value: string)
|
||||
relayRecovery: { pairingRejected: false },
|
||||
routeNotice: '',
|
||||
router: { push: () => {} },
|
||||
sectionsResult: { rawSections: [], sections: [], uniqueRepoColors: new Map() },
|
||||
sectionsResult: { rawSections: [], sections: [], repoById: new Map() },
|
||||
setDismissedNotice: () => {},
|
||||
settings: { activeFilterCount: 0 },
|
||||
state: {
|
||||
@@ -69,7 +69,6 @@ function listWith(fields: { actionError: string; setActionError: (value: string)
|
||||
catalogError: null,
|
||||
collapsedGroups: new Set(),
|
||||
groupMode: 'none',
|
||||
repoIconsByName: new Map(),
|
||||
search: '',
|
||||
showSearch: false,
|
||||
worktreesLoaded: true
|
||||
|
||||
@@ -68,7 +68,7 @@ function worktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
const stable = { ref: { current: null }, repoColors: new Map(), repoIcons: new Map() }
|
||||
const stable = { ref: { current: null }, repoById: new Map() }
|
||||
|
||||
function listProps(worktrees: Worktree[]) {
|
||||
// Each poll re-parses the payload, so rows are fresh objects even when nothing changed.
|
||||
@@ -91,13 +91,12 @@ function listProps(worktrees: Worktree[]) {
|
||||
now: 1_000,
|
||||
reconnectAttempts: 0,
|
||||
relayRecovery: { pairingRejected: false },
|
||||
sectionsResult: { rawSections: sections, sections, uniqueRepoColors: stable.repoColors },
|
||||
sectionsResult: { rawSections: sections, sections, repoById: stable.repoById },
|
||||
settings: { activeFilterCount: 0, toggleWorktreeLineage: noop },
|
||||
state: {
|
||||
actionError: '',
|
||||
setActionTarget: noop,
|
||||
groupMode: 'none',
|
||||
repoIconsByName: stable.repoIcons,
|
||||
search: '',
|
||||
showSearch: false,
|
||||
worktreesLoaded: true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Platform, Pressable, RefreshControl, SectionList, Text, View } from 'react-native'
|
||||
import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native'
|
||||
import { AuthFailedBanner } from '../components/AuthFailedBanner'
|
||||
@@ -13,6 +14,7 @@ import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
|
||||
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
import { buildRepoLookById } from './host-repo-looks'
|
||||
import type { HostScreenController } from './use-host-screen-controller'
|
||||
|
||||
export function HostWorkspaceList({ controller }: { controller: HostScreenController }) {
|
||||
@@ -40,7 +42,8 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
settings,
|
||||
state
|
||||
} = controller
|
||||
const { rawSections, sections, uniqueRepoColors } = sectionsResult
|
||||
const { rawSections, sections, repoById } = sectionsResult
|
||||
const repoLookById = useMemo(() => buildRepoLookById(repoById), [repoById])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -140,10 +143,11 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
const isCollapsed = state.collapsedGroups.has(section.key)
|
||||
const rawSection = rawSections.find((s) => s.key === section.key)
|
||||
const count = rawSection?.data.length ?? 0
|
||||
const repoSectionColor =
|
||||
state.groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
|
||||
const repoSectionIcon =
|
||||
state.groupMode === 'repo' ? state.repoIconsByName.get(section.title) : null
|
||||
// Why: the desktop's header draws its anchor repo's colour and icon.
|
||||
const repoSectionLook =
|
||||
state.groupMode === 'repo' && section.repo ? repoLookById.get(section.repo.id) : null
|
||||
const repoSectionColor = repoSectionLook?.color ?? null
|
||||
const repoSectionIcon = repoSectionLook?.icon ?? null
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.sectionHeader}
|
||||
@@ -187,8 +191,8 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
isReadOnly={isReadOnly}
|
||||
now={now}
|
||||
status={getWorktreeStatus(item)}
|
||||
repoColor={uniqueRepoColors.get(item.repo) ?? repoColor(item.repo)}
|
||||
repoIcon={state.repoIconsByName.get(item.repo) ?? null}
|
||||
repoColor={repoLookById.get(item.repoId)?.color ?? repoColor(item.repo)}
|
||||
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
|
||||
hideRepo={state.groupMode === 'repo'}
|
||||
onPress={actions.openWorktreeSession}
|
||||
onLongPress={
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const request = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../worktree/project-sections-operations', () => ({
|
||||
projectSectionsRead: { request },
|
||||
readServedProjectSections: () => ({ kind: 'answered', value: null })
|
||||
}))
|
||||
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useHostProjectSections } from './use-host-project-sections'
|
||||
|
||||
function clientDouble(): RpcClient {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only compares the client by identity and hands it to the mocked read.
|
||||
return {} as RpcClient
|
||||
}
|
||||
|
||||
describe('useHostProjectSections', () => {
|
||||
it('fetches the catalog at once when the screen moves to another host client', async () => {
|
||||
request.mockResolvedValue({ ok: true })
|
||||
const clientRef: { current: RpcClient | null } = { current: null }
|
||||
let refresh: (options?: { force?: boolean }) => Promise<void> = async () => {}
|
||||
function Probe({ client, hostId }: { client: RpcClient; hostId: string }) {
|
||||
refresh = useHostProjectSections({
|
||||
client,
|
||||
connState: 'connected',
|
||||
hostId,
|
||||
state: { clientRef, setServedProjectSections: () => {} }
|
||||
})
|
||||
return null
|
||||
}
|
||||
const first = clientDouble()
|
||||
const second = clientDouble()
|
||||
clientRef.current = first
|
||||
let tree!: ReturnType<typeof create>
|
||||
act(() => {
|
||||
tree = create(createElement(Probe, { client: first, hostId: 'a' }))
|
||||
})
|
||||
await act(async () => refresh())
|
||||
await act(async () => refresh())
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
clientRef.current = second
|
||||
act(() => tree.update(createElement(Probe, { client: second, hostId: 'b' })))
|
||||
await act(async () => refresh())
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(request).toHaveBeenLastCalledWith(second)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
projectSectionsRead,
|
||||
readServedProjectSections
|
||||
} from '../worktree/project-sections-operations'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
const PROJECT_SECTIONS_REFRESH_MS = 60_000
|
||||
|
||||
/**
|
||||
* The desktop sidebar's Project catalog, refreshed with repo metadata but as its own read, so a
|
||||
* slow or missing answer never holds the repo labels back.
|
||||
*/
|
||||
export function useHostProjectSections(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
hostId: string | undefined
|
||||
state: Pick<HostScreenState, 'clientRef' | 'setServedProjectSections'>
|
||||
}): (options?: { force?: boolean }) => Promise<void> {
|
||||
const { client, connState, hostId, state } = args
|
||||
const { clientRef, setServedProjectSections } = state
|
||||
const inFlight = useRef(new WeakSet<RpcClient>())
|
||||
// Per client, so a reused screen for another host fetches its catalog at once.
|
||||
const fetchedAt = useRef(new WeakMap<RpcClient, number>())
|
||||
|
||||
return useCallback(
|
||||
async (options: { force?: boolean } = {}) => {
|
||||
if (!client || connState !== 'connected' || !hostId || inFlight.current.has(client)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!options.force &&
|
||||
Date.now() - (fetchedAt.current.get(client) ?? 0) < PROJECT_SECTIONS_REFRESH_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
inFlight.current.add(client)
|
||||
try {
|
||||
const reply = await projectSectionsRead.request(client).catch(() => null)
|
||||
if (clientRef.current !== client) {
|
||||
return
|
||||
}
|
||||
const sections = readServedProjectSections(reply)
|
||||
if (sections.kind === 'answered') {
|
||||
fetchedAt.current.set(client, Date.now())
|
||||
setServedProjectSections(sections.value)
|
||||
}
|
||||
} finally {
|
||||
inFlight.current.delete(client)
|
||||
}
|
||||
},
|
||||
[client, clientRef, connState, hostId, setServedProjectSections]
|
||||
)
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import { setCachedRepos } from '../cache/repo-cache'
|
||||
import type { RpcAcceptedResult } from '../transport/rpc-accepted-result'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, RpcResponse } from '../transport/types'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
import {
|
||||
buildHostLabelById,
|
||||
buildRepoHostIdByRepoId
|
||||
} from '../worktree/worktree-host-context-labels'
|
||||
import { buildSshHostHealthById } from '../worktree/worktree-host-health'
|
||||
import type { SectionRepo } from '../worktree/workspace-list-types'
|
||||
import type { HostRepoCatalogRow } from './host-screen-reply-schema'
|
||||
import {
|
||||
hostPlatformRead,
|
||||
hostRepoCatalogRead,
|
||||
@@ -41,6 +42,18 @@ function acceptedMetadata<Value>(
|
||||
return verdict.accepted ? verdict.value : null
|
||||
}
|
||||
|
||||
/** A desktop repo.list row as Project sections read it, for a desktop that serves no catalog. */
|
||||
function desktopSectionRepo(repo: HostRepoCatalogRow): SectionRepo {
|
||||
return {
|
||||
id: repo.id,
|
||||
displayName: repo.displayName,
|
||||
path: repo.path ?? repo.id,
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
...(repo.badgeColor ? { badgeColor: repo.badgeColor } : {}),
|
||||
...(repo.repoIcon ? { repoIcon: repo.repoIcon } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function readHostSettingOverrides(result: unknown): unknown {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
return (result as { hostSettingOverrides?: unknown } | null)?.hostSettingOverrides
|
||||
@@ -60,11 +73,9 @@ export function useHostRepoMetadata(args: {
|
||||
repoMetadataFetchedAtRef,
|
||||
setHostLabelById,
|
||||
setHostHealthById,
|
||||
setDesktopRepos,
|
||||
setHostPlatform,
|
||||
setRepoColorsByName,
|
||||
setRepoHostIdByRepoId,
|
||||
setRepoIconsByName,
|
||||
setRepoIdsByName
|
||||
setRepoHostIdByRepoId
|
||||
} = state
|
||||
|
||||
const fetchRepoMetadata = useCallback(
|
||||
@@ -101,22 +112,7 @@ export function useHostRepoMetadata(args: {
|
||||
const catalog = repos.value
|
||||
repoMetadataFetchedAtRef.current = Date.now()
|
||||
setCachedRepos(requestHostId, catalog)
|
||||
setRepoColorsByName(
|
||||
new Map(
|
||||
catalog.map((repo) => [
|
||||
repo.displayName,
|
||||
repo.badgeColor || repoColor(repo.displayName)
|
||||
])
|
||||
)
|
||||
)
|
||||
setRepoIconsByName(
|
||||
new Map(
|
||||
catalog.flatMap((repo) =>
|
||||
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
|
||||
)
|
||||
)
|
||||
)
|
||||
setRepoIdsByName(new Map(catalog.map((repo) => [repo.displayName, repo.id])))
|
||||
setDesktopRepos(catalog.map(desktopSectionRepo))
|
||||
setRepoHostIdByRepoId(buildRepoHostIdByRepoId(catalog))
|
||||
// Why: rows name their host when the list spans hosts or an SSH host may be unhealthy,
|
||||
// so a local-only catalog never pays for the label lookups. Counted over repos, not the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useLocalSearchParams, usePathname } from 'expo-router'
|
||||
import { useRouteHandoff } from '../navigation/route-handoff'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
} from '../transport/client-context-connection-metrics'
|
||||
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
|
||||
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
|
||||
import { useHostRepoMetadata } from './use-host-repo-metadata'
|
||||
import { buildProjectSectionCatalog } from '../worktree/project-section-catalog'
|
||||
import { useHostProjectSections } from './use-host-project-sections'
|
||||
import { useHostRepoMetadata, type FetchHostRepoMetadata } from './use-host-repo-metadata'
|
||||
import { useHostScreenIdentity } from './use-host-screen-identity'
|
||||
import { useHostScreenState } from './use-host-screen-state'
|
||||
import { useHostViewSettings } from './use-host-view-settings'
|
||||
@@ -72,7 +74,15 @@ export function useHostScreenController({
|
||||
)
|
||||
|
||||
useHostScreenIdentity({ client, hostId, state })
|
||||
const fetchRepoMetadata = useHostRepoMetadata({ client, connState, hostId, state })
|
||||
const fetchRepoMetadataOnly = useHostRepoMetadata({ client, connState, hostId, state })
|
||||
const fetchProjectSections = useHostProjectSections({ client, connState, hostId, state })
|
||||
const fetchRepoMetadata = useCallback<FetchHostRepoMetadata>(
|
||||
(options) => {
|
||||
void fetchProjectSections(options)
|
||||
return fetchRepoMetadataOnly(options)
|
||||
},
|
||||
[fetchProjectSections, fetchRepoMetadataOnly]
|
||||
)
|
||||
const catalog = useHostWorktreeCatalog({
|
||||
client,
|
||||
connState,
|
||||
@@ -104,6 +114,15 @@ export function useHostScreenController({
|
||||
const showNewWorktree = resolvedRouteActionState.showNewWorktree
|
||||
|
||||
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId)
|
||||
const projectCatalog = useMemo(
|
||||
() =>
|
||||
buildProjectSectionCatalog(
|
||||
state.servedProjectSections,
|
||||
state.desktopRepos,
|
||||
state.visibleHostIds
|
||||
),
|
||||
[state.servedProjectSections, state.desktopRepos, state.visibleHostIds]
|
||||
)
|
||||
const sectionsResult = useWorkspaceSections({
|
||||
displayWorktrees,
|
||||
sortMode: state.sortMode,
|
||||
@@ -112,8 +131,8 @@ export function useHostScreenController({
|
||||
groupMode: state.groupMode,
|
||||
pinnedIds: state.pinnedIds,
|
||||
showPinnedInGroups: state.showPinnedInGroups,
|
||||
repoIdsByName: state.repoIdsByName,
|
||||
repoColorsByName: state.repoColorsByName,
|
||||
projectCatalog,
|
||||
filterCatalogRepos: state.servedProjectSections?.repos ?? state.desktopRepos,
|
||||
collapsedGroups: state.collapsedGroups,
|
||||
workspaceStatuses: state.workspaceStatuses
|
||||
})
|
||||
|
||||
@@ -18,15 +18,15 @@ export function useHostScreenIdentity(args: {
|
||||
setCatalogError,
|
||||
setError,
|
||||
setHostLabelById,
|
||||
setServedProjectSections,
|
||||
setDesktopRepos,
|
||||
setHostHealthById,
|
||||
setHostName,
|
||||
setHostPlatform,
|
||||
setHostStoredDescriptor,
|
||||
setLastKnownWorktrees,
|
||||
setPinnedIds,
|
||||
setRepoColorsByName,
|
||||
setRepoHostIdByRepoId,
|
||||
setRepoIconsByName,
|
||||
setShowPinnedInGroups,
|
||||
setWorktrees,
|
||||
setWorktreesLoaded
|
||||
@@ -59,10 +59,10 @@ export function useHostScreenIdentity(args: {
|
||||
setHostName('')
|
||||
setHostStoredDescriptor(null)
|
||||
setError('')
|
||||
setRepoColorsByName(new Map())
|
||||
setRepoIconsByName(new Map())
|
||||
setRepoHostIdByRepoId(new Map())
|
||||
setHostLabelById(new Map())
|
||||
setServedProjectSections(null)
|
||||
setDesktopRepos([])
|
||||
setHostHealthById(new Map())
|
||||
setHostPlatform(null)
|
||||
setShowPinnedInGroups(false)
|
||||
|
||||
@@ -13,7 +13,8 @@ import type {
|
||||
MobileViewState
|
||||
} from '../worktree/workspace-view-settings'
|
||||
import type { FilterState, Worktree } from '../worktree/workspace-list-sections'
|
||||
import type { MobileHostRepoIcon } from './host-screen-reply-schema'
|
||||
import type { ServedProjectSections } from '../worktree/project-sections-operations'
|
||||
import type { SectionRepo } from '../worktree/workspace-list-types'
|
||||
import { NO_SERVER_WORKSPACES, type ServerWorkspaces } from '../worktree/server-workspaces'
|
||||
|
||||
export function useHostScreenState(hostId: string | undefined, action: string | undefined) {
|
||||
@@ -39,8 +40,6 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [repoColorsByName, setRepoColorsByName] = useState<Map<string, string>>(new Map())
|
||||
const [repoIconsByName, setRepoIconsByName] = useState<Map<string, MobileHostRepoIcon>>(new Map())
|
||||
const [hostName, setHostName] = useState('')
|
||||
// The stored name identity, loaded with the name so the header can label an offline host.
|
||||
const [hostStoredDescriptor, setHostStoredDescriptor] = useState<{
|
||||
@@ -66,8 +65,12 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
|
||||
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
||||
)
|
||||
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
|
||||
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
|
||||
// The desktop sidebar's Project grouping inputs; null for a desktop that serves none.
|
||||
const [servedProjectSections, setServedProjectSections] = useState<ServedProjectSections | null>(
|
||||
null
|
||||
)
|
||||
// The desktop's own repos from repo.list: an older desktop's rows group by these.
|
||||
const [desktopRepos, setDesktopRepos] = useState<readonly SectionRepo[]>([])
|
||||
// Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform.
|
||||
const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState<Map<string, ExecutionHostId>>(
|
||||
new Map()
|
||||
@@ -133,10 +136,9 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
newWorktreeModalVisibleRef,
|
||||
optimisticActiveWorktreeIdentity,
|
||||
pinnedIds,
|
||||
repoColorsByName,
|
||||
repoHostIdByRepoId,
|
||||
repoIconsByName,
|
||||
repoIdsByName,
|
||||
servedProjectSections,
|
||||
desktopRepos,
|
||||
repoMetadataFetchedAtRef,
|
||||
routeActionState,
|
||||
search,
|
||||
@@ -159,10 +161,9 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
setLastKnownWorktrees,
|
||||
setOptimisticActiveWorktreeIdentity,
|
||||
setPinnedIds,
|
||||
setRepoColorsByName,
|
||||
setRepoHostIdByRepoId,
|
||||
setRepoIconsByName,
|
||||
setRepoIdsByName,
|
||||
setServedProjectSections,
|
||||
setDesktopRepos,
|
||||
setRouteActionState,
|
||||
setSearch,
|
||||
setServerNotice,
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { Pressable, View } from 'react-native'
|
||||
|
||||
// A held press selects text natively; claiming it keeps that release from counting as a tap.
|
||||
function ignoreLongPress(): void {}
|
||||
|
||||
/** A message body that opens the actions sheet on long press (Android, which has no inline
|
||||
* selection) or reveals its copy button on tap (platforms with inline selection). */
|
||||
* selection); elsewhere a plain view, so a held press selects text natively. */
|
||||
export function MobileNativeChatLongPressContent({
|
||||
onLongPress,
|
||||
onPress,
|
||||
style,
|
||||
children
|
||||
}: {
|
||||
onLongPress?: () => void
|
||||
onPress?: () => void
|
||||
style: ComponentProps<typeof View>['style']
|
||||
children: ReactNode
|
||||
}): React.JSX.Element {
|
||||
return onLongPress || onPress ? (
|
||||
return onLongPress ? (
|
||||
// Not one accessible element: screen readers keep reaching the text and controls inside.
|
||||
<Pressable
|
||||
onLongPress={onLongPress ?? ignoreLongPress}
|
||||
onPress={onPress}
|
||||
accessible={false}
|
||||
style={style}
|
||||
>
|
||||
<Pressable onLongPress={onLongPress} accessible={false} style={style}>
|
||||
{children}
|
||||
</Pressable>
|
||||
) : (
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('MobileNativeChatMessage on Android', () => {
|
||||
expect(byType('MessageActionsSheet')).toHaveLength(0)
|
||||
expect(
|
||||
byType('Pressable').filter((node) => node.props.accessibilityLabel === 'Copy message')
|
||||
).toHaveLength(0)
|
||||
).toHaveLength(1)
|
||||
|
||||
const [bubble] = byType('Pressable')
|
||||
expect(typeof bubble!.props.onLongPress).toBe('function')
|
||||
@@ -155,7 +155,7 @@ describe('MobileNativeChatMessage on Android', () => {
|
||||
}
|
||||
expect(
|
||||
byType('Pressable').filter((node) => node.props.accessibilityLabel === 'Copy message')
|
||||
).toHaveLength(0)
|
||||
).toHaveLength(1)
|
||||
act(() => byType('Pressable')[0]!.props.onLongPress())
|
||||
expect(byType('MessageActionsSheet')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -69,18 +69,10 @@ describe('iOS whole-message copy', () => {
|
||||
}
|
||||
return node
|
||||
}
|
||||
function tapMessage() {
|
||||
const body = renderer!.root
|
||||
.findByType(MobileNativeChatLongPressContent)
|
||||
.findAll((node) => String(node.type) === 'Pressable')[0]
|
||||
act(() => body?.props.onPress?.())
|
||||
}
|
||||
// Every copy case starts from a tapped message, the only state that shows the button.
|
||||
function render(value = message) {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message: value }))
|
||||
})
|
||||
tapMessage()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -122,11 +114,8 @@ describe('iOS whole-message copy', () => {
|
||||
for (const node of nodes('MobileMarkdown')) {
|
||||
expect(node.props.onLongPress).toBeUndefined()
|
||||
}
|
||||
// A held press selects text natively: no actions sheet, and no copy toggle.
|
||||
const press = body.findAll((node) => String(node.type) === 'Pressable')[0]
|
||||
act(() => press?.props.onLongPress())
|
||||
expect(nodes('MessageActionsSheet')).toHaveLength(0)
|
||||
expect(copyButton()).toBeDefined()
|
||||
// A held press selects text natively: the body claims no press of its own.
|
||||
expect(body.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0)
|
||||
await act(async () => copy.props.onPress())
|
||||
expect(writeText.mock.calls).toEqual([[first + '\n\n' + last]])
|
||||
expect(nodes('MessageActionsSheet')).toHaveLength(0)
|
||||
@@ -141,25 +130,13 @@ describe('iOS whole-message copy', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('shows the copy button only after a tap on the message, and hides it on the next', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message }))
|
||||
})
|
||||
const hasCopy = () =>
|
||||
nodes('Pressable').some((node) => node.props.accessibilityLabel === 'Copy message')
|
||||
const body = renderer!.root
|
||||
.findByType(MobileNativeChatLongPressContent)
|
||||
.findAll((node) => String(node.type) === 'Pressable')[0]
|
||||
expect(hasCopy()).toBe(false)
|
||||
expect(body?.props).toMatchObject({ accessible: false })
|
||||
act(() => body?.props.onLongPress())
|
||||
expect(hasCopy()).toBe(false)
|
||||
|
||||
tapMessage()
|
||||
expect(hasCopy()).toBe(true)
|
||||
tapMessage()
|
||||
expect(hasCopy()).toBe(false)
|
||||
})
|
||||
it.each(['user', 'assistant'] as const)(
|
||||
'shows the %s message copy button without a tap',
|
||||
(role) => {
|
||||
render({ ...message, role })
|
||||
expect(copyButton()).toBeDefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'Please repair this chart:\n::orca-visual{file="usage.html"}\nKeep this text.',
|
||||
|
||||
@@ -211,9 +211,6 @@ function MobileNativeChatMessageImpl({
|
||||
// Keep the memoized Markdown context stable as the message streams.
|
||||
const openActions = useCallback(() => setActionsOpen(true), [])
|
||||
const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions
|
||||
// Like desktop's hover-only copy: a tap on the message shows it, another tap hides it.
|
||||
const [copyShown, setCopyShown] = useState(false)
|
||||
const toggleCopy = useCallback(() => setCopyShown((shown) => !shown), [])
|
||||
const renderVisual = useContext(MobileNativeChatVisualContext) ?? undefined
|
||||
// Structured replies grow in place: only the last block of the newest row may still be typing.
|
||||
const growingBlock =
|
||||
@@ -262,8 +259,9 @@ function MobileNativeChatMessageImpl({
|
||||
</>
|
||||
)
|
||||
}
|
||||
// Always shown, so the row never shifts when copy appears.
|
||||
const copyText =
|
||||
INLINE_TEXT_SELECTION && (message.role === 'user' || message.role === 'assistant')
|
||||
message.role === 'user' || message.role === 'assistant'
|
||||
? nativeChatMessagePlainText(message)
|
||||
: ''
|
||||
return (
|
||||
@@ -273,7 +271,6 @@ function MobileNativeChatMessageImpl({
|
||||
<View style={[styles.row, isUser && styles.rowUser]}>
|
||||
<Content
|
||||
onLongPress={onLongPress}
|
||||
onPress={copyText.length > 0 ? toggleCopy : undefined}
|
||||
style={[
|
||||
styles.content,
|
||||
isUser && styles.userBubble,
|
||||
@@ -322,7 +319,7 @@ function MobileNativeChatMessageImpl({
|
||||
) : null}
|
||||
</Content>
|
||||
{unsentNotice ? <NativeText style={styles.unsentNotice}>{unsentNotice}</NativeText> : null}
|
||||
{copyShown && copyText.length > 0 ? (
|
||||
{copyText.length > 0 ? (
|
||||
<MobileSourceCopyButton
|
||||
key={message.id}
|
||||
text={copyText}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AlertTriangle,
|
||||
colors,
|
||||
ActionSheetModal,
|
||||
BottomDrawerModalHost,
|
||||
GitBranch,
|
||||
ConfirmModal
|
||||
} from './mobile-tasks-dependencies'
|
||||
@@ -180,202 +181,210 @@ export function MobileTasksLegacySurface({ model }: { model: ConnectionPresentat
|
||||
|
||||
{renderMobileTasksListSurface(model)}
|
||||
|
||||
{renderMobileTasksProviderPicker(model)}
|
||||
{/* Why: Tasks stacks sheets (an issue detail under Create Workspace or a confirm), and iOS
|
||||
refuses a native modal over a presented sibling, so they share one Modal, ordered by zIndex. */}
|
||||
<BottomDrawerModalHost>
|
||||
{renderMobileTasksProviderPicker(model)}
|
||||
|
||||
{renderMobileTasksRepoPicker(model)}
|
||||
{renderMobileTasksRepoPicker(model)}
|
||||
|
||||
{renderMobileTasksGitHubIssueSourcePicker(model)}
|
||||
{renderMobileTasksGitHubIssueSourcePicker(model)}
|
||||
|
||||
{renderMobileTasksGitHubViewPicker(model)}
|
||||
{renderMobileTasksGitHubViewPicker(model)}
|
||||
|
||||
{renderMobileTasksGitHubPresetPicker(model)}
|
||||
{renderMobileTasksGitHubPresetPicker(model)}
|
||||
|
||||
{renderMobileTasksPagePicker(model)}
|
||||
{renderMobileTasksPagePicker(model)}
|
||||
|
||||
{renderMobileTasksGitHubProjectPicker(model)}
|
||||
{renderMobileTasksGitHubProjectPicker(model)}
|
||||
|
||||
{renderMobileTasksGitHubProjectViewPicker(model)}
|
||||
{renderMobileTasksGitHubProjectViewPicker(model)}
|
||||
|
||||
{renderMobileTasksGitHubProjectSortPicker(model)}
|
||||
{renderMobileTasksGitHubProjectSortPicker(model)}
|
||||
|
||||
{renderMobileTasksGitHubProjectFieldsPicker(model)}
|
||||
{renderMobileTasksGitHubProjectFieldsPicker(model)}
|
||||
|
||||
{renderMobileTasksGitLabViewPicker(model)}
|
||||
{renderMobileTasksGitLabViewPicker(model)}
|
||||
|
||||
{renderMobileTasksGitLabFilterPicker(model)}
|
||||
{renderMobileTasksGitLabFilterPicker(model)}
|
||||
|
||||
{renderMobileTasksLinearFilterPicker(model)}
|
||||
{renderMobileTasksJiraFilterPicker(model)}
|
||||
{renderMobileTasksJiraSitePicker(model)}
|
||||
{renderMobileTasksLinearFilterPicker(model)}
|
||||
{renderMobileTasksJiraFilterPicker(model)}
|
||||
{renderMobileTasksJiraSitePicker(model)}
|
||||
|
||||
{renderMobileTasksLinearWorkspacePicker(model)}
|
||||
{renderMobileTasksLinearWorkspacePicker(model)}
|
||||
|
||||
{renderMobileTasksLinearTeamPicker(model)}
|
||||
{renderMobileTasksLinearTeamPicker(model)}
|
||||
|
||||
{renderMobileTasksLinearStatusPicker(model)}
|
||||
{renderMobileTasksLinearStatusPicker(model)}
|
||||
|
||||
{renderMobileTasksLinearViewPicker(model)}
|
||||
{renderMobileTasksLinearViewPicker(model)}
|
||||
|
||||
{renderMobileTasksLinearGroupPicker(model)}
|
||||
{renderMobileTasksLinearGroupPicker(model)}
|
||||
|
||||
{renderMobileTasksLinearOrderPicker(model)}
|
||||
{renderMobileTasksLinearOrderPicker(model)}
|
||||
|
||||
{renderMobileTasksLinearDisplayPicker(model)}
|
||||
{renderMobileTasksLinearDisplayPicker(model)}
|
||||
|
||||
{renderMobileTasksSortPicker(model)}
|
||||
{renderMobileTasksSortPicker(model)}
|
||||
|
||||
{renderMobileTasksCreateDrawer(model)}
|
||||
{renderMobileTasksCreateDrawer(model)}
|
||||
|
||||
{renderMobileTasksCreateTargetPicker(model)}
|
||||
{renderMobileTasksCreateTargetPicker(model)}
|
||||
|
||||
{renderMobileTasksLinearConnectDrawer(model)}
|
||||
{renderMobileTasksLinearConnectDrawer(model)}
|
||||
|
||||
{renderMobileTasksWorkspaceCreateTargetPicker(model)}
|
||||
{renderMobileTasksWorkspaceCreateTargetPicker(model)}
|
||||
|
||||
{renderMobileTasksWorkspaceCreateDrawer(model)}
|
||||
{renderMobileTasksWorkspaceCreateDrawer(model)}
|
||||
|
||||
{renderMobileTasksWorkspaceCreateRepoPicker(model)}
|
||||
{renderMobileTasksWorkspaceCreateRepoPicker(model)}
|
||||
|
||||
{renderMobileTasksWorkspaceAgentPicker(model)}
|
||||
{renderMobileTasksWorkspaceAgentPicker(model)}
|
||||
|
||||
{renderMobileTasksWorkspaceBaseBranchPicker(model)}
|
||||
{renderMobileTasksWorkspaceBaseBranchPicker(model)}
|
||||
|
||||
{renderMobileTasksWorkspaceSparsePicker(model)}
|
||||
{renderMobileTasksWorkspaceSparsePicker(model)}
|
||||
|
||||
{renderMobileTasksWorkspaceSparseDrawer(model)}
|
||||
{renderMobileTasksWorkspaceSparseDrawer(model)}
|
||||
|
||||
{renderMobileTasksSetupTrustDrawer(model)}
|
||||
{renderMobileTasksSetupTrustDrawer(model)}
|
||||
|
||||
{renderMobileTasksOrcaYamlTrustDrawer(model)}
|
||||
{renderMobileTasksOrcaYamlTrustDrawer(model)}
|
||||
|
||||
{renderMobileTasksProjectMissingRepoDrawer(model)}
|
||||
{renderMobileTasksProjectMissingRepoDrawer(model)}
|
||||
|
||||
{renderMobileTasksProjectDetailDrawer(model)}
|
||||
{renderMobileTasksProjectDetailDrawer(model)}
|
||||
|
||||
{renderMobileTasksItemDetailDrawer(model)}
|
||||
{renderMobileTasksItemDetailDrawer(model)}
|
||||
|
||||
<ActionSheetModal
|
||||
visible={taskUiReady && mergeMethodProjectRow != null}
|
||||
title="Merge method"
|
||||
message="Choose how this pull request should be merged."
|
||||
actions={
|
||||
mergeMethodProjectRow
|
||||
? (['squash', 'merge', 'rebase'] as const).map((method) => ({
|
||||
label: getHostedReviewMergeMethodLabel(method),
|
||||
icon: GitBranch,
|
||||
onPress: () => {
|
||||
setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method })
|
||||
}
|
||||
}))
|
||||
: []
|
||||
}
|
||||
onClose={() => setMergeMethodProjectRow(null)}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={taskUiReady && mergeMethodTaskItem != null}
|
||||
title="Merge method"
|
||||
message={
|
||||
mergeMethodTaskItem?.provider === 'gitlab'
|
||||
? 'Choose how this merge request should be merged.'
|
||||
: 'Choose how this pull request should be merged.'
|
||||
}
|
||||
actions={
|
||||
mergeMethodTaskItem
|
||||
? (mergeMethodTaskItem.provider === 'gitlab'
|
||||
? (['merge', 'squash', 'rebase'] as const)
|
||||
: (['squash', 'merge', 'rebase'] as const)
|
||||
).map((method) => ({
|
||||
label:
|
||||
mergeMethodTaskItem.provider === 'gitlab' && method === 'merge'
|
||||
? 'Merge'
|
||||
: getHostedReviewMergeMethodLabel(method),
|
||||
icon: GitBranch,
|
||||
onPress: () => {
|
||||
const item = mergeMethodTaskItem
|
||||
setPendingHostedMerge({ item, method })
|
||||
}
|
||||
}))
|
||||
: []
|
||||
}
|
||||
onClose={() => setMergeMethodTaskItem(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={taskUiReady && pendingHostedMerge != null}
|
||||
title={
|
||||
pendingHostedMerge?.item.provider === 'gitlab' ? 'Merge Request' : 'Merge Pull Request'
|
||||
}
|
||||
message={pendingHostedMerge ? getHostedMergeConfirmMessage(pendingHostedMerge) : undefined}
|
||||
confirmLabel={
|
||||
pendingHostedMerge ? getHostedReviewMergeMethodLabel(pendingHostedMerge.method) : 'Merge'
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (!taskUiReady || !pendingHostedMerge) {
|
||||
return
|
||||
<ActionSheetModal
|
||||
visible={taskUiReady && mergeMethodProjectRow != null}
|
||||
title="Merge method"
|
||||
message="Choose how this pull request should be merged."
|
||||
actions={
|
||||
mergeMethodProjectRow
|
||||
? (['squash', 'merge', 'rebase'] as const).map((method) => ({
|
||||
label: getHostedReviewMergeMethodLabel(method),
|
||||
icon: GitBranch,
|
||||
onPress: () => {
|
||||
setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method })
|
||||
}
|
||||
}))
|
||||
: []
|
||||
}
|
||||
void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method)
|
||||
}}
|
||||
onCancel={() => setPendingHostedMerge(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={taskUiReady && pendingProjectGitHubMerge != null}
|
||||
title="Merge Pull Request"
|
||||
message={
|
||||
pendingProjectGitHubMerge
|
||||
? getProjectGitHubMergeConfirmMessage(pendingProjectGitHubMerge)
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={
|
||||
pendingProjectGitHubMerge
|
||||
? getHostedReviewMergeMethodLabel(pendingProjectGitHubMerge.method)
|
||||
: 'Merge'
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (!taskUiReady || !pendingProjectGitHubMerge) {
|
||||
return
|
||||
onClose={() => setMergeMethodProjectRow(null)}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={taskUiReady && mergeMethodTaskItem != null}
|
||||
title="Merge method"
|
||||
message={
|
||||
mergeMethodTaskItem?.provider === 'gitlab'
|
||||
? 'Choose how this merge request should be merged.'
|
||||
: 'Choose how this pull request should be merged.'
|
||||
}
|
||||
void mergeProjectGitHubPullRequest(
|
||||
pendingProjectGitHubMerge.row,
|
||||
pendingProjectGitHubMerge.method
|
||||
)
|
||||
}}
|
||||
onCancel={() => setPendingProjectGitHubMerge(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={taskUiReady && pendingHostedStateChange != null}
|
||||
title={
|
||||
pendingHostedStateChange
|
||||
? getHostedStateConfirmTitle(pendingHostedStateChange)
|
||||
: 'Update Item'
|
||||
}
|
||||
message={
|
||||
pendingHostedStateChange
|
||||
? getHostedStateConfirmMessage(pendingHostedStateChange)
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={
|
||||
pendingHostedStateChange
|
||||
? getHostedStateConfirmLabel(pendingHostedStateChange)
|
||||
: 'Confirm'
|
||||
}
|
||||
destructive={pendingHostedStateChange?.nextState === 'closed'}
|
||||
onConfirm={() => {
|
||||
if (!taskUiReady || !pendingHostedStateChange) {
|
||||
return
|
||||
actions={
|
||||
mergeMethodTaskItem
|
||||
? (mergeMethodTaskItem.provider === 'gitlab'
|
||||
? (['merge', 'squash', 'rebase'] as const)
|
||||
: (['squash', 'merge', 'rebase'] as const)
|
||||
).map((method) => ({
|
||||
label:
|
||||
mergeMethodTaskItem.provider === 'gitlab' && method === 'merge'
|
||||
? 'Merge'
|
||||
: getHostedReviewMergeMethodLabel(method),
|
||||
icon: GitBranch,
|
||||
onPress: () => {
|
||||
const item = mergeMethodTaskItem
|
||||
setPendingHostedMerge({ item, method })
|
||||
}
|
||||
}))
|
||||
: []
|
||||
}
|
||||
if (pendingHostedStateChange.source === 'task') {
|
||||
if (pendingHostedStateChange.item.provider === 'gitlab') {
|
||||
void toggleGitLabStatus(pendingHostedStateChange.item)
|
||||
onClose={() => setMergeMethodTaskItem(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={taskUiReady && pendingHostedMerge != null}
|
||||
title={
|
||||
pendingHostedMerge?.item.provider === 'gitlab' ? 'Merge Request' : 'Merge Pull Request'
|
||||
}
|
||||
message={
|
||||
pendingHostedMerge ? getHostedMergeConfirmMessage(pendingHostedMerge) : undefined
|
||||
}
|
||||
confirmLabel={
|
||||
pendingHostedMerge
|
||||
? getHostedReviewMergeMethodLabel(pendingHostedMerge.method)
|
||||
: 'Merge'
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (!taskUiReady || !pendingHostedMerge) {
|
||||
return
|
||||
}
|
||||
void toggleGitHubStatus(pendingHostedStateChange.item)
|
||||
return
|
||||
void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method)
|
||||
}}
|
||||
onCancel={() => setPendingHostedMerge(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={taskUiReady && pendingProjectGitHubMerge != null}
|
||||
title="Merge Pull Request"
|
||||
message={
|
||||
pendingProjectGitHubMerge
|
||||
? getProjectGitHubMergeConfirmMessage(pendingProjectGitHubMerge)
|
||||
: undefined
|
||||
}
|
||||
void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, {
|
||||
state: pendingHostedStateChange.nextState
|
||||
})
|
||||
}}
|
||||
onCancel={() => setPendingHostedStateChange(null)}
|
||||
/>
|
||||
confirmLabel={
|
||||
pendingProjectGitHubMerge
|
||||
? getHostedReviewMergeMethodLabel(pendingProjectGitHubMerge.method)
|
||||
: 'Merge'
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (!taskUiReady || !pendingProjectGitHubMerge) {
|
||||
return
|
||||
}
|
||||
void mergeProjectGitHubPullRequest(
|
||||
pendingProjectGitHubMerge.row,
|
||||
pendingProjectGitHubMerge.method
|
||||
)
|
||||
}}
|
||||
onCancel={() => setPendingProjectGitHubMerge(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={taskUiReady && pendingHostedStateChange != null}
|
||||
title={
|
||||
pendingHostedStateChange
|
||||
? getHostedStateConfirmTitle(pendingHostedStateChange)
|
||||
: 'Update Item'
|
||||
}
|
||||
message={
|
||||
pendingHostedStateChange
|
||||
? getHostedStateConfirmMessage(pendingHostedStateChange)
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={
|
||||
pendingHostedStateChange
|
||||
? getHostedStateConfirmLabel(pendingHostedStateChange)
|
||||
: 'Confirm'
|
||||
}
|
||||
destructive={pendingHostedStateChange?.nextState === 'closed'}
|
||||
onConfirm={() => {
|
||||
if (!taskUiReady || !pendingHostedStateChange) {
|
||||
return
|
||||
}
|
||||
if (pendingHostedStateChange.source === 'task') {
|
||||
if (pendingHostedStateChange.item.provider === 'gitlab') {
|
||||
void toggleGitLabStatus(pendingHostedStateChange.item)
|
||||
return
|
||||
}
|
||||
void toggleGitHubStatus(pendingHostedStateChange.item)
|
||||
return
|
||||
}
|
||||
void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, {
|
||||
state: pendingHostedStateChange.nextState
|
||||
})
|
||||
}}
|
||||
onCancel={() => setPendingHostedStateChange(null)}
|
||||
/>
|
||||
</BottomDrawerModalHost>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export { classifyConnection } from '../transport/connection-health'
|
||||
export { StatusDot } from '../components/StatusDot'
|
||||
export { ActionSheetModal } from '../components/ActionSheetModal'
|
||||
export { BottomDrawer } from '../components/BottomDrawer'
|
||||
export { BottomDrawerModalHost } from '../components/bottom-drawer-modal-host'
|
||||
export { ConfirmModal } from '../components/ConfirmModal'
|
||||
export { MobileMarkdown } from '../components/MobileMarkdown'
|
||||
export { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
SHOW_MOBILE_PROJECT_METADATA_EDITORS,
|
||||
githubProjectOptionColor,
|
||||
canCreateWorkspaceFromProjectRow,
|
||||
projectRowType
|
||||
projectRowType,
|
||||
TASK_SECONDARY_DRAWER_Z_INDEX
|
||||
} from './mobile-tasks-legacy-foundation'
|
||||
import { renderMobileTasksProjectFieldEditors } from './mobile-tasks-project-field-editors'
|
||||
import {
|
||||
@@ -45,6 +46,8 @@ export function renderMobileTasksProjectMissingRepoDrawer(model: ConnectionPrese
|
||||
onClose={() => {
|
||||
setProjectRepoNotInOrca(null)
|
||||
}}
|
||||
// Why: opens over the project detail, which renders after it in the shared host.
|
||||
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX}
|
||||
>
|
||||
{projectRepoNotInOrca ? (
|
||||
<View>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildProjectSectionCatalog } from './project-section-catalog'
|
||||
import type { ServedProjectSections } from './project-sections-operations'
|
||||
|
||||
const uploaded = { type: 'image', src: 'data:image/png;base64,AAAA' }
|
||||
|
||||
type ServedRepo = ServedProjectSections['repos'][number]
|
||||
|
||||
function repo(
|
||||
id: string,
|
||||
executionHostId?: ServedRepo['executionHostId'],
|
||||
repoIcon?: unknown
|
||||
): ServedRepo {
|
||||
return {
|
||||
id,
|
||||
displayName: 'orca',
|
||||
path: `/repos/${id}`,
|
||||
badgeColor: undefined,
|
||||
addedAt: undefined,
|
||||
connectionId: undefined,
|
||||
executionHostId,
|
||||
...(repoIcon ? { repoIcon } : {})
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildProjectSectionCatalog', () => {
|
||||
it("restores an uploaded icon from the desktop's repo.list only for the same host's repo", () => {
|
||||
const served: ServedProjectSections = {
|
||||
repos: [repo('r1'), repo('r1', 'runtime:m4air')],
|
||||
projects: [],
|
||||
setups: [],
|
||||
projectOrderBy: 'manual'
|
||||
}
|
||||
const catalog = buildProjectSectionCatalog(served, [repo('r1', undefined, uploaded)], null)
|
||||
expect(catalog.repos.map((entry) => entry.repoIcon)).toEqual([uploaded, undefined])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { getLogicalRepoOrderRankById } from '../../../src/shared/project-section-order'
|
||||
import type { ServedProjectSections } from './project-sections-operations'
|
||||
import { NO_PROJECT_SECTION_CATALOG, type ProjectSectionCatalog } from './workspace-list-sections'
|
||||
import type { SectionRepo } from './workspace-list-types'
|
||||
|
||||
/**
|
||||
* Project grouping's inputs: the desktop sidebar's own catalog when it serves one, else (an older
|
||||
* desktop) its own repos with no projects, so rows group by repo. Only shown hosts' repos open
|
||||
* empty sections; the grouping itself spans every host, as the desktop's does.
|
||||
*/
|
||||
export function buildProjectSectionCatalog(
|
||||
served: ServedProjectSections | null,
|
||||
desktopRepos: readonly SectionRepo[],
|
||||
visibleHostIds: ReadonlySet<ExecutionHostId> | null
|
||||
): ProjectSectionCatalog {
|
||||
const shown = (repos: readonly SectionRepo[]) =>
|
||||
visibleHostIds
|
||||
? repos.filter((repo) => visibleHostIds.has(getRepoExecutionHostId(repo)))
|
||||
: repos
|
||||
if (!served) {
|
||||
return { ...NO_PROJECT_SECTION_CATALOG, repos: shown(desktopRepos) }
|
||||
}
|
||||
// Why: the desktop withholds uploaded image icons from the snapshot; its repo.list still carries them.
|
||||
const iconKey = (repo: SectionRepo) => `${getRepoExecutionHostId(repo)}|${repo.id}`
|
||||
const iconByKey = new Map(desktopRepos.map((repo) => [iconKey(repo), repo.repoIcon]))
|
||||
const repos = served.repos.map((repo) => {
|
||||
const icon = repo.repoIcon ? undefined : iconByKey.get(iconKey(repo))
|
||||
return icon ? { ...repo, repoIcon: icon } : repo
|
||||
})
|
||||
return {
|
||||
repos: shown(repos),
|
||||
grouping: { projects: served.projects, projectHostSetups: served.setups },
|
||||
// Why: served repos come in the sidebar's header order, cross-host manual order included.
|
||||
repoOrder: getLogicalRepoOrderRankById(served.repos.map((repo) => repo.id)),
|
||||
projectOrderBy: served.projectOrderBy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { z } from 'zod'
|
||||
import { MOBILE_PROJECT_SECTIONS_METHOD } from '../../../src/shared/mobile-project-sections-contract'
|
||||
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { ProjectHostSetupMethod } from '../../../src/shared/project-types'
|
||||
import type { RepoKind } from '../../../src/shared/repo-types'
|
||||
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
|
||||
import { hostUnionArms, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
|
||||
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
|
||||
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
|
||||
import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import type { SectionRepo } from './workspace-list-types'
|
||||
|
||||
const SETUP_METHOD_ARMS = hostUnionArms<ProjectHostSetupMethod>({
|
||||
'legacy-repo': true,
|
||||
'imported-existing-folder': true,
|
||||
cloned: true,
|
||||
provisioned: true
|
||||
})
|
||||
const REPO_KIND_ARMS = hostUnionArms<RepoKind>({ git: true, folder: true })
|
||||
const PROJECT_ORDER_BY_ARMS = hostUnionArms<ProjectOrderBy>({ manual: true, recent: true })
|
||||
|
||||
// A host id the desktop spells; normalized, so a spelling this build cannot read drops.
|
||||
const hostIdSchema = z.string().transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
ctx.addIssue({ code: 'custom', message: 'unknown host id' })
|
||||
return z.NEVER
|
||||
}
|
||||
return hostId
|
||||
})
|
||||
|
||||
const repoSchema = z.object({
|
||||
id: z.string(),
|
||||
displayName: z.string(),
|
||||
path: z.string(),
|
||||
badgeColor: salvagedOptional('badgeColor', z.string()),
|
||||
addedAt: salvagedOptional('addedAt', z.number()),
|
||||
connectionId: salvagedOptional('connectionId', z.string().nullable()),
|
||||
executionHostId: salvagedOptional('executionHostId', hostIdSchema),
|
||||
// Decoded where drawn (readMobileRepoIcon), so an arm this build lacks still keeps the row.
|
||||
repoIcon: z.unknown().optional()
|
||||
}) satisfies z.ZodType<SectionRepo, unknown>
|
||||
|
||||
const setupSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
hostId: hostIdSchema,
|
||||
repoId: z.string(),
|
||||
path: z.string(),
|
||||
displayName: z.string(),
|
||||
// Why the fallback: only 'provisioned' changes grouping, so a newer arm groups as a checkout.
|
||||
setupMethod: z.enum(SETUP_METHOD_ARMS).catch('legacy-repo'),
|
||||
kind: salvagedOptional('kind', z.enum(REPO_KIND_ARMS)),
|
||||
connectionId: salvagedOptional('connectionId', z.string().nullable()),
|
||||
executionHostId: salvagedOptional('executionHostId', hostIdSchema)
|
||||
})
|
||||
|
||||
const catalogSchema = z.object({
|
||||
repos: salvagingArray(repoSchema),
|
||||
projects: salvagingArray(z.object({ id: z.string(), displayName: z.string() })),
|
||||
setups: salvagingArray(setupSchema),
|
||||
projectOrderBy: z.enum(PROJECT_ORDER_BY_ARMS).catch('manual')
|
||||
})
|
||||
|
||||
/** The desktop sidebar's Project grouping inputs, as this phone reads them. */
|
||||
export type ServedProjectSections = z.output<typeof catalogSchema>
|
||||
|
||||
/** The sidebar's Project grouping inputs; null until a desktop window has published them. */
|
||||
export const projectSectionsRead = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'mobileRelay.projectSections-or-skip',
|
||||
method: MOBILE_PROJECT_SECTIONS_METHOD,
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcResultVariant(
|
||||
'project-sections',
|
||||
z.object({ catalog: catalogSchema.nullable() }).transform(({ catalog }) => catalog)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* The desktop's sidebar catalog: absent (null) on a desktop that predates the read, and left
|
||||
* as it was after a transient failure, so a dropped reply never regroups the list.
|
||||
*/
|
||||
export function readServedProjectSections(
|
||||
reply: RpcResponse | null
|
||||
): { kind: 'answered'; value: ServedProjectSections | null } | { kind: 'kept' } {
|
||||
if (!reply) {
|
||||
return { kind: 'kept' }
|
||||
}
|
||||
if (!reply.ok) {
|
||||
return isMobileMethodUnavailableError(reply.error.code, reply.error.message)
|
||||
? { kind: 'answered', value: null }
|
||||
: { kind: 'kept' }
|
||||
}
|
||||
try {
|
||||
const verdict = projectSectionsRead.interpret(reply)
|
||||
return verdict.accepted ? { kind: 'answered', value: verdict.value } : { kind: 'kept' }
|
||||
} catch {
|
||||
return { kind: 'kept' }
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,15 @@ import { useMemo } from 'react'
|
||||
import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types'
|
||||
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
|
||||
import {
|
||||
buildSectionRepoMap,
|
||||
buildSections,
|
||||
type FilterState,
|
||||
type ProjectSectionCatalog,
|
||||
type Section,
|
||||
type SectionRepo,
|
||||
type Worktree
|
||||
} from './workspace-list-sections'
|
||||
import { repoColor } from './repo-color'
|
||||
|
||||
export type WorkspaceSectionRepo = {
|
||||
name: string
|
||||
id: string
|
||||
color: string
|
||||
}
|
||||
import { buildFilterRepos, type WorkspaceSectionRepo } from './workspace-filter-repos'
|
||||
|
||||
export function useWorkspaceSections(args: {
|
||||
displayWorktrees: Worktree[]
|
||||
@@ -23,15 +20,17 @@ export function useWorkspaceSections(args: {
|
||||
groupMode: MobileGroupMode
|
||||
pinnedIds: Set<string>
|
||||
showPinnedInGroups: boolean
|
||||
repoIdsByName: Map<string, string>
|
||||
repoColorsByName: Map<string, string>
|
||||
projectCatalog: ProjectSectionCatalog
|
||||
/** Every catalog repo on every host, which is what the desktop's filter lists. */
|
||||
filterCatalogRepos: readonly SectionRepo[]
|
||||
collapsedGroups: Set<string>
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
}): {
|
||||
sections: Section[]
|
||||
rawSections: Section[]
|
||||
uniqueRepos: WorkspaceSectionRepo[]
|
||||
uniqueRepoColors: Map<string, string>
|
||||
/** Each row's repo by id, as the desktop's cards look theirs up for colour and icon. */
|
||||
repoById: ReadonlyMap<string, SectionRepo>
|
||||
} {
|
||||
const {
|
||||
displayWorktrees,
|
||||
@@ -41,28 +40,20 @@ export function useWorkspaceSections(args: {
|
||||
groupMode,
|
||||
pinnedIds,
|
||||
showPinnedInGroups,
|
||||
repoIdsByName,
|
||||
repoColorsByName,
|
||||
projectCatalog,
|
||||
filterCatalogRepos,
|
||||
collapsedGroups,
|
||||
workspaceStatuses
|
||||
} = args
|
||||
|
||||
const uniqueRepos = useMemo(() => {
|
||||
const repos = new Map<string, { id: string; color: string }>()
|
||||
for (const w of displayWorktrees) {
|
||||
if (!repos.has(w.repo)) {
|
||||
repos.set(w.repo, {
|
||||
id: repoIdsByName.get(w.repo) ?? w.repoId,
|
||||
color: repoColorsByName.get(w.repo) ?? repoColor(w.repo)
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...repos.entries()].map(([name, { id, color }]) => ({ name, id, color }))
|
||||
}, [displayWorktrees, repoColorsByName, repoIdsByName])
|
||||
const uniqueRepos = useMemo(
|
||||
() => buildFilterRepos(filterCatalogRepos, displayWorktrees),
|
||||
[filterCatalogRepos, displayWorktrees]
|
||||
)
|
||||
|
||||
const uniqueRepoColors = useMemo(
|
||||
() => new Map(uniqueRepos.map((repo) => [repo.name, repo.color])),
|
||||
[uniqueRepos]
|
||||
const repoById = useMemo(
|
||||
() => buildSectionRepoMap(projectCatalog.repos, displayWorktrees),
|
||||
[projectCatalog.repos, displayWorktrees]
|
||||
)
|
||||
|
||||
const rawSections = useMemo(
|
||||
@@ -74,7 +65,7 @@ export function useWorkspaceSections(args: {
|
||||
search,
|
||||
groupMode,
|
||||
pinnedIds,
|
||||
repoIdsByName,
|
||||
projectCatalog,
|
||||
workspaceStatuses,
|
||||
collapsedGroups,
|
||||
showPinnedInGroups
|
||||
@@ -86,7 +77,7 @@ export function useWorkspaceSections(args: {
|
||||
search,
|
||||
groupMode,
|
||||
pinnedIds,
|
||||
repoIdsByName,
|
||||
projectCatalog,
|
||||
workspaceStatuses,
|
||||
collapsedGroups,
|
||||
showPinnedInGroups
|
||||
@@ -102,5 +93,5 @@ export function useWorkspaceSections(args: {
|
||||
[rawSections, collapsedGroups]
|
||||
)
|
||||
|
||||
return { sections, rawSections, uniqueRepos, uniqueRepoColors }
|
||||
return { sections, rawSections, uniqueRepos, repoById }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user