mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8eec44c1 | ||
|
|
0b820d729a |
@@ -8,6 +8,4 @@ user-invocable: true
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag.
|
||||
Key rules the doc enforces — each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion (never leave a stale `-beta.N` heading behind), and betas publish npm only with the explicit `beta` dist-tag.
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
---
|
||||
name: release-stable
|
||||
description: Cut a stable release of Paseo (fresh patch or minor, or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:minor", "release:promote", or "/release-stable".
|
||||
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release stable
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
|
||||
For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps.
|
||||
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
|
||||
|
||||
@@ -7,34 +7,229 @@ on:
|
||||
- "android-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch, tag, or commit to build. Leave blank to build the selected workflow ref."
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
publish:
|
||||
description: "Upload the APK to an existing or new GitHub release."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
tag:
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
required: true
|
||||
description: "Release tag to rebuild and publish. Required when publish is enabled."
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && (inputs.ref || inputs.tag || github.ref) || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-android-apk:
|
||||
build-apk:
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
artifact-name: ${{ steps.mode.outputs.artifact-name }}
|
||||
publish: ${{ steps.mode.outputs.publish }}
|
||||
release-tag: ${{ steps.mode.outputs.release-tag }}
|
||||
source-tag: ${{ steps.mode.outputs.source-tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.publish && inputs.tag || inputs.ref || github.ref) || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
- name: Resolve build mode
|
||||
id: mode
|
||||
env:
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
publish=false
|
||||
release_tag=""
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
|
||||
publish=true
|
||||
source_tag="${GITHUB_REF_NAME}"
|
||||
elif [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
|
||||
if [[ -z "${INPUT_TAG:-}" ]]; then
|
||||
echo "::error::tag is required when publish is enabled."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${INPUT_REF:-}" ]]; then
|
||||
echo "::error::ref cannot be combined with publish; the release tag is always the source."
|
||||
exit 1
|
||||
fi
|
||||
publish=true
|
||||
source_tag="${INPUT_TAG}"
|
||||
fi
|
||||
|
||||
if [[ "${publish}" == "true" ]]; then
|
||||
release_env="$(node scripts/emit-release-env.mjs --source-tag "${source_tag}")"
|
||||
printf '%s\n' "${release_env}" >> "$GITHUB_ENV"
|
||||
release_tag="$(printf '%s\n' "${release_env}" | sed -n 's/^RELEASE_TAG=//p')"
|
||||
artifact_name="paseo-${release_tag}-android.apk"
|
||||
else
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
short_sha="$(git rev-parse --short=12 HEAD)"
|
||||
artifact_name="paseo-v${package_version}-${short_sha}-android.apk"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "artifact-name=${artifact_name}"
|
||||
echo "publish=${publish}"
|
||||
echo "release-tag=${release_tag}"
|
||||
echo "source-tag=${source_tag:-}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Building ${artifact_name}; publish=${publish}"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Restore production Android credentials
|
||||
env:
|
||||
ANDROID_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_BASE64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
required=(
|
||||
ANDROID_GOOGLE_SERVICES_JSON_BASE64
|
||||
ANDROID_KEY_ALIAS
|
||||
ANDROID_KEY_PASSWORD
|
||||
ANDROID_KEYSTORE_BASE64
|
||||
ANDROID_KEYSTORE_PASSWORD
|
||||
)
|
||||
for name in "${required[@]}"; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "::error::Missing required repository secret: ${name}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
google_services_file="$RUNNER_TEMP/google-services.prod.json"
|
||||
keystore_file="$RUNNER_TEMP/paseo-upload.jks"
|
||||
|
||||
printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > "$google_services_file"
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore_file"
|
||||
jq -n \
|
||||
--arg keystorePath "$keystore_file" \
|
||||
--arg keystorePassword "$ANDROID_KEYSTORE_PASSWORD" \
|
||||
--arg keyAlias "$ANDROID_KEY_ALIAS" \
|
||||
--arg keyPassword "$ANDROID_KEY_PASSWORD" \
|
||||
'{android: {keystore: {keystorePath: $keystorePath, keystorePassword: $keystorePassword, keyAlias: $keyAlias, keyPassword: $keyPassword}}}' \
|
||||
> packages/app/credentials.json
|
||||
|
||||
echo "GOOGLE_SERVICES_FILE_PROD=$google_services_file" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build signed production APK on GitHub
|
||||
env:
|
||||
APP_VARIANT: production
|
||||
EAS_BUILD: "1"
|
||||
EXPO_UPDATES_CHANNEL: production
|
||||
GRADLE_OPTS: >-
|
||||
-Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||
-Dorg.gradle.parallel=false
|
||||
-Dorg.gradle.workers.max=1
|
||||
-Dorg.gradle.daemon=false
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
npm run build:app-deps
|
||||
npm --prefix packages/app run build:terminal-webview
|
||||
|
||||
cd packages/app
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
node -e '(async () => require("expo/node_modules/@expo/config-plugins").AndroidConfig.EasBuild.configureEasBuildAsync(process.cwd()))().catch((error) => { console.error(error); process.exitCode = 1; })'
|
||||
|
||||
cd android
|
||||
./gradlew \
|
||||
:app:assembleRelease \
|
||||
--no-daemon \
|
||||
--max-workers=1 \
|
||||
-Dorg.gradle.parallel=false \
|
||||
-x lint \
|
||||
-x lintVitalAnalyzeRelease \
|
||||
-x lintVitalRelease \
|
||||
-x generateReleaseLintModel \
|
||||
-x generateReleaseLintVitalModel
|
||||
|
||||
artifact="$RUNNER_TEMP/${{ steps.mode.outputs.artifact-name }}"
|
||||
cp app/build/outputs/apk/release/app-release.apk "$artifact"
|
||||
|
||||
apksigner="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -n 1)"
|
||||
if [[ -z "$apksigner" ]]; then
|
||||
echo "::error::Unable to locate apksigner."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_sha256="421698bdca5bb9168c970e24539781509b5aa23fab8ab406a15b3f9c5f04c647"
|
||||
actual_sha256="$($apksigner verify --print-certs "$artifact" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | tr -d ':[:space:]' | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$actual_sha256" != "$expected_sha256" ]]; then
|
||||
echo "::error::APK signing certificate does not match the production key."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified production signing certificate: $actual_sha256"
|
||||
|
||||
- name: Upload APK to workflow artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.mode.outputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/${{ steps.mode.outputs.artifact-name }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish-apk:
|
||||
needs: build-apk
|
||||
if: needs.build-apk.outputs.publish == 'true'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ needs.build-apk.outputs.source-tag }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "${{ needs.build-apk.outputs.source-tag }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
@@ -62,71 +257,19 @@ jobs:
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download APK workflow artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Expo and EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Build Android APK on EAS
|
||||
id: eas_build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd packages/app
|
||||
|
||||
build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)"
|
||||
echo "$build_json" > "$RUNNER_TEMP/eas-build.json"
|
||||
|
||||
build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")"
|
||||
if [ -z "$build_id" ]; then
|
||||
echo "Failed to determine EAS build ID."
|
||||
cat "$RUNNER_TEMP/eas-build.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve APK artifact URL
|
||||
id: artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd packages/app
|
||||
|
||||
build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)"
|
||||
echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json"
|
||||
|
||||
artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")"
|
||||
if [ -z "$artifact_url" ]; then
|
||||
echo "Failed to determine APK artifact URL."
|
||||
cat "$RUNNER_TEMP/eas-build-view.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
asset_name="paseo-${RELEASE_TAG}-android.apk"
|
||||
asset_path="$RUNNER_TEMP/$asset_name"
|
||||
|
||||
curl --fail --location --output "$asset_path" "$artifact_url"
|
||||
|
||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||
name: ${{ needs.build-apk.outputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/apk
|
||||
|
||||
- name: Upload APK to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
gh release upload \
|
||||
"$RELEASE_TAG" \
|
||||
"$RUNNER_TEMP/apk/${{ needs.build-apk.outputs.artifact-name }}" \
|
||||
--clobber \
|
||||
--repo "${{ github.repository }}"
|
||||
|
||||
@@ -160,29 +160,10 @@ jobs:
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Build app dependencies for desktop E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Install virtual display
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Run real Electron browser tab bridge E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
|
||||
env:
|
||||
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
|
||||
- name: Upload browser tab bridge diagnostics
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure() && matrix.os == 'ubuntu-latest'
|
||||
with:
|
||||
name: browser-tab-bridge-e2e
|
||||
path: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
|
||||
@@ -1,45 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0-beta.1 - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
|
||||
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
|
||||
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
|
||||
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
|
||||
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
|
||||
- Agent history errors now appear immediately instead of after a timeout ([#2124](https://github.com/getpaseo/paseo/pull/2124))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
|
||||
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
|
||||
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
|
||||
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
|
||||
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
|
||||
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
|
||||
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
|
||||
## 0.1.110 - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Kimi and other ACP agents now stay marked as running while a response is actively streaming ([#2148](https://github.com/getpaseo/paseo/pull/2148))
|
||||
|
||||
## 0.1.109 - 2026-07-16
|
||||
|
||||
> **Important update notice**
|
||||
>
|
||||
> If you installed Paseo Desktop 0.1.108, you need to [download and reinstall Paseo manually](https://paseo.sh/download) to get this fix. The bug in 0.1.108 prevents its automatic updater from installing 0.1.109. Users on 0.1.107 or earlier can update normally.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Paseo Desktop no longer gets stuck connecting or loses native window controls after updating ([#2111](https://github.com/getpaseo/paseo/pull/2111) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
@@ -37,7 +37,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
|
||||
@@ -68,10 +68,6 @@ Paseo validates the `Host` header on every HTTP request and every WebSocket upgr
|
||||
|
||||
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
|
||||
|
||||
## Forge host trust
|
||||
|
||||
Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||
|
||||
@@ -36,12 +36,6 @@ Users can also detach an existing subagent from the subagents track. Detach remo
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
|
||||
## Provider-managed child agents
|
||||
|
||||
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with `child_session` events; `AgentManager` imports the live provider handle, stamps `paseo.parent-agent-id`, and surfaces the result as a normal subagent in the parent's subagents track.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Archive
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
+9
-2
@@ -137,13 +137,20 @@ adb exec-out screencap -p > screenshot.png
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (locally built, production-signed APK asset on GitHub Release). The workflow runs Expo prebuild and Gradle directly on the GitHub runner, so it does not consume an EAS cloud build.
|
||||
|
||||
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores.
|
||||
|
||||
The GitHub APK workflow also supports `workflow_dispatch`:
|
||||
|
||||
- Leave `publish` disabled and optionally provide `ref` to build any branch, tag, or commit as a workflow artifact without changing a GitHub release.
|
||||
- Enable `publish` and provide an existing `tag` to rebuild that tag and upload the APK to its GitHub release. Manual publishes always build the tag itself; they cannot publish an arbitrary ref under a release tag.
|
||||
|
||||
The workflow restores the production keystore and Firebase configuration from protected GitHub repository secrets, configures the production EAS Update channel, and verifies the finished APK's signing certificate before upload. Keep the protected values synchronized with the production Android credentials in EAS. The AAB profile continues using EAS-managed credentials and store submission.
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
+11
-27
@@ -138,25 +138,9 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every `did-attach`, the renderer explicitly registers its browser id, workspace id, and current guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained `<webview>` can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
|
||||
>
|
||||
> **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
|
||||
>
|
||||
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
|
||||
>
|
||||
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
|
||||
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
|
||||
|
||||
```text
|
||||
Human key -> guest WebContents
|
||||
|-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
|
||||
`-- page keydown
|
||||
|-- page prevents ------> page owns it
|
||||
`-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolver
|
||||
|
||||
Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|
||||
|-- guest handles ------------> page owns it
|
||||
`-- guest does not handle ----> stop; never redispatch to the host window
|
||||
```
|
||||
> **In-app browser targets are not yet per-window.** Browser webviews are still tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus records the workspace-active browser for UI state and `list_tabs` reporting, while agent automation targets explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. Explicit attached-guest registration prevents concurrent windows from swapping different browser ids, but rendering the same saved browser tab in multiple windows can still make menu actions target the most recently registered guest. Making the registry window-scoped remains a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
@@ -189,7 +173,7 @@ Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a sess
|
||||
|
||||
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.forge.set_auto_merge.request` and `checkout.forge.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
@@ -271,14 +255,14 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
|
||||
|
||||
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
|
||||
|
||||
| Surface | Key | Source |
|
||||
| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Forge change request | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Change request timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
|
||||
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
|
||||
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||
| Surface | Key | Source |
|
||||
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
|
||||
| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
|
||||
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
|
||||
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||
|
||||
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**
|
||||
|
||||
|
||||
@@ -13,16 +13,7 @@ It validates the compositor behavior that unit tests cannot see:
|
||||
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
||||
the permanent production parking state;
|
||||
- guest background throttling can be disabled once at attach without per-capture
|
||||
renderer coordination;
|
||||
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
|
||||
host composer;
|
||||
- the automation group loads the compiled production keyboard boundary and guest
|
||||
preload, then proves that initial page window handlers get first refusal, unhandled
|
||||
shortcuts synchronously suppress editable browser defaults before crossing the host
|
||||
boundary, shortcuts marked unavailable in editable targets retain the browser field's
|
||||
native behavior, handlers registered after preload still get first refusal, focused
|
||||
iframes share the same boundary, digit wildcard shortcuts cross, and background automation
|
||||
stays in the guest.
|
||||
renderer coordination.
|
||||
|
||||
Run it with the repo Electron:
|
||||
|
||||
@@ -30,11 +21,9 @@ Run it with the repo Electron:
|
||||
npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Build the desktop main process before the automation group so its production guest
|
||||
preload is available:
|
||||
Run the browser automation fixture with:
|
||||
|
||||
```bash
|
||||
npm run build:main --workspace=@getpaseo/desktop
|
||||
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
@@ -54,9 +43,6 @@ ARIA-like snapshot text includes headings, static text, and controls; refs survi
|
||||
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
|
||||
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
|
||||
page-context evaluation, including passing a resolved ref element as the function argument.
|
||||
Keyboard containment runs last because the host-composer sentinel intentionally leaves
|
||||
native focus in the host. It reuses an existing fixture button: adding a test-only control
|
||||
changes the inline fixture geometry exercised by the earlier actionability checks.
|
||||
|
||||
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
|
||||
hide the Dock icon before creating any window. `showInactive()` only prevents window
|
||||
|
||||
@@ -347,9 +347,9 @@ Override the command used to launch any provider with the `command` field. This
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
### OMP profiles and Pi-compatible forks
|
||||
### Pi-compatible forks with their own session directory
|
||||
|
||||
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
|
||||
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -361,34 +361,6 @@ OMP ships as a first-class built-in provider option. It is disabled by default;
|
||||
}
|
||||
```
|
||||
|
||||
Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` approvals, native Paseo host tools, provider-managed subagents, and import behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"omp-work": {
|
||||
"extends": "omp",
|
||||
"label": "Oh My Pi (Work)",
|
||||
"command": ["omp"],
|
||||
"env": {
|
||||
"XDG_CONFIG_HOME": "~/.config/omp-work",
|
||||
"XDG_STATE_HOME": "~/.local/state/omp-work"
|
||||
},
|
||||
"params": {
|
||||
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
|
||||
"smolModel": "openai/gpt-5-mini",
|
||||
"slowModel": "anthropic/claude-opus-4-1",
|
||||
"planModel": "openai/o3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
```json
|
||||
@@ -408,7 +380,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
}
|
||||
```
|
||||
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ The rule, condensed: text that _names_ a surface or a group is `medium`. Text th
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# Adding a Git Forge to Paseo
|
||||
|
||||
Paseo's forge layer is a registry/manifest system. A forge is a runtime concern:
|
||||
shared protocol messages carry neutral/open facts, the server adapter owns
|
||||
behavior, and the app owns bundled presentation/runtime interpretation.
|
||||
|
||||
The maintainer litmus test is the rule of thumb:
|
||||
|
||||
> Adding a new forge means adding files in a new directory/module that implement
|
||||
> an interface, plus one entry in the centralized registry/manifest for that
|
||||
> package.
|
||||
|
||||
## The Three Registrations
|
||||
|
||||
For forge `acme`, the expected end state is:
|
||||
|
||||
1. **Protocol manifest** - optional, only when the forge should be presented by
|
||||
shared manifest data. Add one `ForgeDefinition` to
|
||||
`packages/protocol/src/forge-manifest.ts`.
|
||||
|
||||
2. **Server adapter** - add `packages/server/src/services/acme-service.ts`
|
||||
implementing `ForgeService`, any adapter-owned fact types/guards/constants
|
||||
beside it, and one `defaultForgeRegistry` entry in
|
||||
`packages/server/src/services/forge-registry.ts`.
|
||||
|
||||
3. **App modules** - a forge splits into a pure logic half and a view half so
|
||||
logic consumers (URL builders, merge-capability, native checks, and the
|
||||
Node-based e2e harness) never pull the client rendering stack:
|
||||
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
|
||||
optional `facts` (schema, merge-capability, native-check fallbacks). No
|
||||
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
|
||||
`packages/app/src/git/forges/index.ts`.
|
||||
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
|
||||
under `packages/app/src/components/icons/`), optional `brandColor`, optional
|
||||
`paneContributions`. Register in `CLIENT_FORGE_VIEW_MODULES` in
|
||||
`packages/app/src/git/forges/view.ts`.
|
||||
|
||||
There should be no protocol typed-union arm, no central app icon/color/url/facts
|
||||
map, and no central server union of known forge facts.
|
||||
|
||||
## Protocol
|
||||
|
||||
`forgeSpecific` on PR status is an open envelope:
|
||||
|
||||
```ts
|
||||
z.object({ forge: z.string() }).passthrough();
|
||||
```
|
||||
|
||||
The `forgeSpecific.forge` field is a **facts-family tag**, not the workspace
|
||||
brand id. Gitea, Forgejo, and Codeberg can all emit `forgeSpecific.forge ===
|
||||
"gitea"` when they share the same facts shape, while top-level `status.forge`
|
||||
keeps the brand id (`"gitea"`, `"forgejo"`, `"codeberg"`).
|
||||
|
||||
Protocol does not validate per-forge fact fields. Consumers that understand a
|
||||
facts family validate at runtime with their own schema/guard. Unknown or
|
||||
schema-mismatched facts render neutrally instead of failing the whole message
|
||||
parse. This is the version-skew win: an old client can receive facts from a
|
||||
newer forge and still show the PR/MR in a neutral state.
|
||||
|
||||
Shipped GitHub compatibility stays separate:
|
||||
|
||||
- `status.github` remains accepted for released peers.
|
||||
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
|
||||
into `status.github` for older clients.
|
||||
- Do not add a compatibility shim unless a released peer (<= 0.1.102) can
|
||||
actually produce the state.
|
||||
|
||||
## Server
|
||||
|
||||
The server-wide status type only promises:
|
||||
|
||||
```ts
|
||||
type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
|
||||
```
|
||||
|
||||
Adapter-owned files define the typed shapes and guards, for example
|
||||
`github-facts.ts`, `gitlab-facts.ts`, and `gitea-facts.ts`. The adapter can keep
|
||||
strong internal types for construction and command guards, but shared server
|
||||
code must not grow a central list of forge fact arms.
|
||||
|
||||
Register the adapter in `defaultForgeRegistry` with:
|
||||
|
||||
- `createService`
|
||||
- `matchesHost` from manifest `cloudHosts`
|
||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
||||
|
||||
Cloud hosts in the manifest are a bounded public-host list, not a self-host
|
||||
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
||||
host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
rendering stack:
|
||||
|
||||
`acme.ts` exports a `ClientForgeLogicModule`:
|
||||
|
||||
- `id`
|
||||
- optional `urlGrammar`
|
||||
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
||||
|
||||
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
||||
|
||||
- `id`
|
||||
- `icon`
|
||||
- `brandColor` (`null` for neutral; GitHub intentionally uses `null`)
|
||||
- optional `paneContributions`
|
||||
|
||||
Two registries live under `packages/app/src/git/forges/`:
|
||||
`CLIENT_FORGE_LOGIC_MODULES` (`index.ts`) drives URL grammar, merge-capability
|
||||
derivation, and native fallback checks; `CLIENT_FORGE_VIEW_MODULES` (`view.ts`)
|
||||
drives icon/color lookup and PR-pane contributions. Logic consumers must import
|
||||
the logic registry only — importing the view registry (or a `.view.tsx` module)
|
||||
from a logic path pulls react-native and breaks the Node-based e2e harness.
|
||||
|
||||
Per-forge brand colors live on the module, not in `styles/theme.ts`. Use the
|
||||
Unistyles-safe pattern from `docs/unistyles.md`: no `useUnistyles()`. Brand icon
|
||||
call sites use `withUnistyles` and a `uniProps` mapping such as:
|
||||
|
||||
```ts
|
||||
(theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
|
||||
```
|
||||
|
||||
Facts modules use one source of truth: a Zod schema. Helpers like
|
||||
`defineForgeFacts`, `defineNativeFallbackCheck`, and `definePaneContribution`
|
||||
derive guards from `schema.safeParse` and re-parse before invoking typed
|
||||
derivers/renderers. That keeps typed derivers away from the open wire envelope.
|
||||
|
||||
## Checklist
|
||||
|
||||
To add `acme`:
|
||||
|
||||
1. Add `acme` to `FORGE_DEFINITIONS` if the shared manifest should know its
|
||||
label, nouns, icon kind, sign-in CLI, or cloud hosts.
|
||||
2. Add `acme-service.ts` implementing `ForgeService`.
|
||||
3. Add `acme-facts.ts` beside the adapter if it reports native facts.
|
||||
4. Add one `defaultForgeRegistry` entry.
|
||||
5. Add `packages/app/src/git/forges/acme.ts` (logic) and
|
||||
`packages/app/src/git/forges/acme.view.tsx` (view).
|
||||
6. Add one `CLIENT_FORGE_LOGIC_MODULES` entry (`index.ts`) and one
|
||||
`CLIENT_FORGE_VIEW_MODULES` entry (`view.ts`).
|
||||
7. Add/update the icon component only if the client bundle should show a brand
|
||||
mark.
|
||||
8. If the forge's CI/data model does not fit an existing required
|
||||
`ForgeService` field, widen the shared interface (plus the protocol schema
|
||||
and its guards) instead of faking a value — e.g. Gitea Actions runs carry no
|
||||
check-run id, so `GetCheckDetailsOptions.checkRunId` became optional with
|
||||
`workflowRunId` as the alternative address. Expect this step to touch
|
||||
`forge-service.ts`, `messages.ts`, and the call-site guards of the other
|
||||
adapters. Widening a shared field is not forge-local: it also affects the
|
||||
already-shipped forges/GitHub call sites and the capability-gated RPC (e.g.
|
||||
`forgeCheckDetails`), so verify every consumer rather than assuming the change
|
||||
only reaches the new adapter.
|
||||
9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol
|
||||
checkout PR schema, app forge URL/presentation tests, app merge capability,
|
||||
and any PR-pane native data tests touched.
|
||||
|
||||
Run `npm run typecheck` after each implementation slice. If protocol or client
|
||||
declarations are stale, run `npm run build:client`; if server/CLI declarations
|
||||
are stale, run `npm run build:server`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- GitHub is a normal registry entry plus released compatibility shims. Keep all
|
||||
real shims tagged with `COMPAT(name)`.
|
||||
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
|
||||
top-level brand is Forgejo or Codeberg.
|
||||
- Brand icons are bundled React components, so they cannot come from protocol
|
||||
manifest data.
|
||||
- Source URL grammars are app-side because blob/tree path syntax is
|
||||
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
|
||||
rather than constructing a wrong URL.
|
||||
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
||||
not protocol.
|
||||
+3
-7
@@ -13,32 +13,28 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
|
||||
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
|
||||
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
|
||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
|
||||
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
|
||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
|
||||
@@ -37,15 +37,6 @@ npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
|
||||
|
||||
The parity test catches missing keys across English and every supported locale resource.
|
||||
|
||||
## Forge-Variant Copy
|
||||
|
||||
Strings that vary by git forge follow a two-tier rule:
|
||||
|
||||
- **Indeclinable tokens** — brand names ("GitHub", "GitLab"), the PR/MR initialism, number prefixes (`#`/`!`) — are interpolated into a single key (`"Refresh git and {{brand}} state"`). These tokens stay latin and uninflected in every supported locale, so one string per locale suffices. The value comes from the forge manifest via `getForgePresentation`.
|
||||
- **Sentences containing the full change-request noun** ("pull request" / "merge request" inflects and takes gender/case in translation) use the i18next `context` mechanism: the base key carries the pull-request wording and an `_mr` sibling carries the merge-request wording (`pullRequest` / `pullRequest_mr`). Call sites pass `t(key, { context: getForgePresentation(forge).changeRequestContext })`; an undefined or unknown context falls back to the base key.
|
||||
|
||||
Keys scale per vocabulary family (PR vs MR), not per forge: a new forge picks an existing family in its manifest entry and needs zero locale edits.
|
||||
|
||||
## Migration Order
|
||||
|
||||
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
@@ -32,9 +32,9 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
|
||||
+16
-45
@@ -12,8 +12,6 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
|
||||
if stale package-runner pins are intentional, say so explicitly, otherwise run
|
||||
`npm run acp:version-drift:update` and commit the updated catalog
|
||||
- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the
|
||||
target version and rationale to the user
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
@@ -38,40 +36,16 @@ There are two supported ways to ship from `main`:
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
|
||||
|
||||
## Release version decision
|
||||
## Standard release (patch)
|
||||
|
||||
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
|
||||
diff. The highest-impact change determines the version:
|
||||
|
||||
- **Minor** — a user would experience the release as a significant upgrade. This
|
||||
includes substantial new workflows, providers, forges, platforms, integrations,
|
||||
or meaningful expansions of existing capabilities. Foundational internal work
|
||||
also qualifies when it materially changes reliability, performance,
|
||||
compatibility, deployment, or operation; diff size alone does not.
|
||||
- **Patch** — fixes, polish, small enhancements, and reliability or performance
|
||||
improvements within existing capabilities. Follow-up corrections to a minor
|
||||
release are patches.
|
||||
|
||||
The release agent selects patch or minor during preparation and presents the
|
||||
target version with the changelog for approval. Agents never select a major
|
||||
version autonomously. A major release requires an explicit user instruction and
|
||||
approval; Paseo remains on major version zero until that deliberate decision.
|
||||
|
||||
Version bumps are never used to retry a failed build. Retry the existing version
|
||||
as described in **Fixing a failed release build**.
|
||||
|
||||
## Standard release (stable)
|
||||
|
||||
Before running any stable release command:
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
# Run exactly one, matching the approved decision:
|
||||
npm run release:patch
|
||||
npm run release:minor
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
@@ -80,6 +54,8 @@ The Docker workflow builds images from the checked-out source tree on pull reque
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
|
||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||
|
||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||
|
||||
## Manual step-by-step
|
||||
@@ -87,9 +63,7 @@ Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fl
|
||||
```bash
|
||||
npm run typecheck # Verify the exact commit you intend to release
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
# Run exactly one approved version command:
|
||||
npm run version:all:patch
|
||||
npm run version:all:minor
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
@@ -97,8 +71,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
## Beta flow
|
||||
|
||||
```bash
|
||||
npm run release:beta:patch # Start the next patch beta line
|
||||
npm run release:beta:minor # Start the next minor beta line
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
@@ -134,7 +107,7 @@ Updater clients only discover a release through those `.yml` manifests, so there
|
||||
|
||||
### Default behavior
|
||||
|
||||
`npm run release:patch` or `npm run release:minor` → tag push → 36h ramp. No extra action needed.
|
||||
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
@@ -154,7 +127,7 @@ gh workflow run desktop-rollout.yml \
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
|
||||
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
|
||||
|
||||
### Adjusting an already-published release
|
||||
|
||||
@@ -192,17 +165,17 @@ gh workflow run desktop-release.yml \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` or `npm run release:minor` — those paths always tag-push and stamp 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
|
||||
### Releasing during an active rollout
|
||||
|
||||
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest for up to five seconds before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. If revalidation times out, the app exits without installing the cached update.
|
||||
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
|
||||
|
||||
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
|
||||
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
|
||||
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
|
||||
@@ -490,8 +463,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
@@ -500,12 +472,11 @@ Betas are checkpoints along the way; the entry is the single record for the jump
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
|
||||
+12
-14
@@ -3,14 +3,14 @@
|
||||
New WebSocket session RPCs use dotted names with the direction as the final segment:
|
||||
|
||||
```ts
|
||||
checkout.forge.set_auto_merge.request;
|
||||
checkout.forge.set_auto_merge.response;
|
||||
checkout.github.set_auto_merge.request;
|
||||
checkout.github.set_auto_merge.response;
|
||||
```
|
||||
|
||||
The namespace reads left to right:
|
||||
|
||||
- Domain: `checkout`
|
||||
- Namespace segment: `forge`
|
||||
- Provider or subsystem: `github`
|
||||
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
|
||||
- Direction: `request` or `response`
|
||||
|
||||
@@ -21,8 +21,8 @@ Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or tran
|
||||
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
|
||||
|
||||
```ts
|
||||
checkout.forge.set_auto_merge.request;
|
||||
// -> checkout.forge.set_auto_merge.response
|
||||
checkout.github.set_auto_merge.request;
|
||||
// -> checkout.github.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
@@ -33,7 +33,7 @@ Requests keep their parameters at the top level:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.forge.set_auto_merge.request",
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
mergeMethod: "squash",
|
||||
@@ -45,7 +45,7 @@ Responses put correlated result data under `payload`:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.forge.set_auto_merge.response",
|
||||
type: "checkout.github.set_auto_merge.response",
|
||||
payload: {
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
@@ -58,16 +58,14 @@ Responses put correlated result data under `payload`:
|
||||
|
||||
Keep `requestId` in both request and response payloads. It is the correlation key.
|
||||
|
||||
## Forge Namespacing
|
||||
## Provider Namespacing
|
||||
|
||||
Forge-neutral behavior currently uses `checkout.forge.*` for checkout-scoped operations and `forge.search.*` for forge search; forge-specific names belong here only after schema and session handlers exist:
|
||||
Provider-specific behavior belongs under the provider segment:
|
||||
|
||||
- `checkout.forge.*` for operations whose request/response shape is genuinely
|
||||
forge-neutral and whose implementation dispatches through the forge resolver.
|
||||
- `checkout.github.*` for existing GitHub-specific compatibility RPCs while
|
||||
callers migrate to the neutral `checkout.forge.*` shape
|
||||
- `checkout.github.*` for GitHub-specific checkout operations
|
||||
- `checkout.gitlab.*` for future GitLab-specific checkout operations
|
||||
|
||||
Do not put GitHub-specific enums or semantics into `checkout.forge.*` RPC names. A generic forge RPC should only exist when the behavior is genuinely forge-neutral.
|
||||
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
|
||||
|
||||
## Compatibility
|
||||
|
||||
|
||||
@@ -124,16 +124,6 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
|
||||
npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
```
|
||||
|
||||
### Browser tab bridge regression
|
||||
|
||||
The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
|
||||
|
||||
Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
## Test organization
|
||||
|
||||
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-DL1LamUyFzJOkPYR7eeIefGhzP/mcWGO5oxld/Bt8n0=
|
||||
sha256-gD1zheuLINSpkqLoW0Kxmjbey5+MhjyogDjc3aHStVQ=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35162,7 +35162,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36181,12 +36181,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/server": "0.2.0-beta.1",
|
||||
"@getpaseo/client": "0.1.109",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/server": "0.1.109",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36432,10 +36432,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/relay": "0.1.109",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36446,7 +36446,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36689,7 +36689,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37585,7 +37585,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37817,7 +37817,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37830,7 +37830,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38048,15 +38048,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/highlight": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"@getpaseo/client": "0.1.109",
|
||||
"@getpaseo/highlight": "0.1.109",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/relay": "0.1.109",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38593,7 +38593,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -130,6 +130,13 @@ export default {
|
||||
},
|
||||
updates: {
|
||||
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
...(process.env.EXPO_UPDATES_CHANNEL
|
||||
? {
|
||||
requestHeaders: {
|
||||
"expo-channel-name": process.env.EXPO_UPDATES_CHANNEL,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...buildProfile.updates,
|
||||
},
|
||||
ios: {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||
|
||||
test("commit history shows dates and shares diff layout preferences", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "commit-diff-panel-" });
|
||||
await createFeatureCommit(workspace.repoPath);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
|
||||
const commitRow = page.locator('[data-testid^="commit-row-"]').filter({
|
||||
hasText: COMMIT_SUBJECT,
|
||||
});
|
||||
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
|
||||
await expect(commitRow).toContainText("Jan 15");
|
||||
await commitRow.click();
|
||||
|
||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await panel.getByTestId("commit-diff-layout-split").click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await commitRow.click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
|
||||
});
|
||||
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", COMMIT_SUBJECT], {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -59,7 +59,7 @@ async function closeTopSheet(page: Page) {
|
||||
|
||||
async function closeSheetByHeaderButton(page: Page, testId: string) {
|
||||
const sheet = page.getByTestId(testId);
|
||||
await sheet.getByLabel("Close", { exact: true }).click();
|
||||
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
|
||||
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
async function rowTestIds(rows: Locator) {
|
||||
return rows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")),
|
||||
);
|
||||
}
|
||||
|
||||
async function visibleBoundingBox(row: Locator) {
|
||||
const box = await row.boundingBox();
|
||||
if (!box) throw new Error("Expected a visible draggable row");
|
||||
return box;
|
||||
}
|
||||
|
||||
async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
await expect(rows).toHaveCount(2);
|
||||
const before = await rowTestIds(rows);
|
||||
const sourceBox = await visibleBoundingBox(rows.nth(0));
|
||||
const targetBox = await visibleBoundingBox(rows.nth(1));
|
||||
|
||||
const page = rows.page();
|
||||
const source = { x: sourceBox.x + sourceBox.width / 2, y: sourceBox.y + sourceBox.height / 2 };
|
||||
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
|
||||
|
||||
await page.mouse.move(source.x, source.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(source.x, source.y + 7);
|
||||
await page.mouse.move(target.x, target.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||
}
|
||||
|
||||
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
|
||||
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
|
||||
|
||||
try {
|
||||
const secondWorkspace = await firstProject.client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: firstProject.repoPath,
|
||||
projectId: firstProject.projectId,
|
||||
},
|
||||
title: "Second workspace",
|
||||
});
|
||||
if (!secondWorkspace.workspace) {
|
||||
throw new Error(secondWorkspace.error ?? "Failed to seed a second workspace");
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await quickDragFirstRowAfterSecond(page.locator('[data-testid^="sidebar-project-row-"]'));
|
||||
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
|
||||
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await firstProject.cleanup();
|
||||
await secondProject.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
|
||||
async function expectBorderHighlight(page: Page, testID: string) {
|
||||
const handle = page.getByTestId(testID);
|
||||
await expect(handle).toBeVisible();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await handle.hover();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
await expect(highlight).toHaveCSS("width", "1px");
|
||||
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await expectBorderHighlight(page, "left-sidebar-resize-handle");
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expectBorderHighlight(page, "explorer-sidebar-resize-handle");
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
|
||||
import { getTerminalBufferText } from "./helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
/**
|
||||
* Regression: a terminal created while the window is unfocused must still claim its PTY size
|
||||
* once focus returns.
|
||||
*
|
||||
* The PTY is only ever resized by an explicit client claim (`terminal_input` resize). A freshly
|
||||
* mounted terminal starts its claim from terminal-pane's pane-focus reflow effect. Previously, if
|
||||
* that claim was emitted while the app was not actively visible, `handleTerminalResize` dropped
|
||||
* it without a retry, leaving the PTY at 80x24 while xterm rendered the real pane size.
|
||||
*
|
||||
* The repro is the mundane user flow: with a workspace already showing a terminal, open another
|
||||
* one and switch to a different app while it spawns. We stage the blur deterministically by
|
||||
* stubbing `document.hasFocus()` (which `useAppVisible` consults on window focus/blur events)
|
||||
* instead of racing real OS focus.
|
||||
*
|
||||
* The assertion reads the PTY's own opinion of its size (`stty size`) with input written
|
||||
* daemon-side — clicking or typing in the pane would fire the focus-claim path and mask the bug.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simulates the window losing/regaining OS focus, which is what `useAppVisible` reads through
|
||||
* `document.hasFocus()` plus the window focus/blur events.
|
||||
*
|
||||
* This has to be stubbed: headless Chromium never actually blurs. Opening a second page and
|
||||
* calling bringToFront() leaves the first page at `hasFocus() === true`, `visibilityState
|
||||
* === "visible"`, and fires no focus/blur events at all — so there is no way to produce a real
|
||||
* blur from Playwright. This stubs the environment signal, not the app: the daemon, the
|
||||
* WebSocket, the terminal, and every code path under test stay real.
|
||||
*/
|
||||
async function setWindowFocused(page: Page, focused: boolean): Promise<void> {
|
||||
await page.evaluate((isFocused) => {
|
||||
Object.defineProperty(document, "hasFocus", {
|
||||
configurable: true,
|
||||
value: () => isFocused,
|
||||
});
|
||||
window.dispatchEvent(new Event(isFocused ? "focus" : "blur"));
|
||||
}, focused);
|
||||
}
|
||||
|
||||
interface RenderedTerminalSize {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
async function readRenderedTerminalSize(page: Page): Promise<RenderedTerminalSize | null> {
|
||||
return await page.evaluate(() => {
|
||||
const terminal = (window as Window & { __paseoTerminal?: { rows: number; cols: number } })
|
||||
.__paseoTerminal;
|
||||
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function createTerminalViaMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").click();
|
||||
}
|
||||
|
||||
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
||||
const listed = await harness.client.listTerminals(harness.tempRepo.path, undefined, {
|
||||
workspaceId: harness.workspaceId,
|
||||
});
|
||||
return listed.terminals.map((terminal) => terminal.id);
|
||||
}
|
||||
|
||||
// These narrow instead of asserting: an `expect(...).toBeTruthy()` plus an early return would let
|
||||
// the test pass without ever reaching the assertions that prove the fix.
|
||||
function exactlyOne<T>(values: T[], what: string): T {
|
||||
const [value] = values;
|
||||
if (values.length !== 1 || value === undefined) {
|
||||
throw new Error(`Expected exactly one ${what}, got ${values.length}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireTerminalSize(size: RenderedTerminalSize | null): RenderedTerminalSize {
|
||||
if (!size) {
|
||||
throw new Error("No xterm instance rendered on the page");
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
function parseLatestSttySize(bufferText: string): RenderedTerminalSize | null {
|
||||
const matches = [...bufferText.matchAll(/S\d+=(\d+) (\d+)=/g)];
|
||||
const match = matches.at(-1);
|
||||
return match?.[1] && match[2] ? { rows: Number(match[1]), cols: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-stuck-size" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await harness.cleanup();
|
||||
});
|
||||
|
||||
test("a terminal created while the window is blurred claims its size when focus returns", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
|
||||
await expect(page.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Wait for the daemon to know about the first terminal rather than sleeping: if it were
|
||||
// missing from this snapshot it would be misread as "new" below.
|
||||
await createTerminalViaMenu(page);
|
||||
await expect(harness.terminalSurface(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
const countTerminals = async () => (await listTerminalIds(harness)).length;
|
||||
await expect.poll(countTerminals, { timeout: 15_000 }).toBe(1);
|
||||
const knownIds = new Set(await listTerminalIds(harness));
|
||||
|
||||
// The user clicks away...
|
||||
await setWindowFocused(page, false);
|
||||
|
||||
// ...opens another terminal while the window is unfocused...
|
||||
await createTerminalViaMenu(page);
|
||||
const findNewTerminalIds = async () =>
|
||||
(await listTerminalIds(harness)).filter((id) => !knownIds.has(id));
|
||||
await expect.poll(async () => (await findNewTerminalIds()).length, { timeout: 15_000 }).toBe(1);
|
||||
const newTerminalId = exactlyOne(await findNewTerminalIds(), "new terminal");
|
||||
|
||||
// Keep the app blurred through the complete mount-time refit ladder, which runs out to 2s.
|
||||
await page.waitForTimeout(2_500);
|
||||
|
||||
await harness.client.subscribeTerminal(newTerminalId);
|
||||
|
||||
// Confirm the PTY is still at its spawn size before focus returns.
|
||||
harness.client.sendTerminalInput(newTerminalId, {
|
||||
type: "input",
|
||||
data: 'echo "S0=$(stty size)="\n',
|
||||
});
|
||||
await expect
|
||||
.poll(async () => getTerminalBufferText(page), { timeout: 15_000 })
|
||||
.toMatch(/S0=24 80=/);
|
||||
|
||||
// ...and comes back.
|
||||
await setWindowFocused(page, true);
|
||||
|
||||
// __paseoTerminal points at the most recently mounted xterm — the new terminal.
|
||||
const rendered = requireTerminalSize(await readRenderedTerminalSize(page));
|
||||
// Sanity: the pane really rendered at a desktop size, not the PTY default.
|
||||
expect(rendered.cols).toBeGreaterThan(80);
|
||||
|
||||
// The PTY itself must agree. Ask it via the daemon, never via the page: focusing or
|
||||
// typing in the pane triggers the focus-claim path and would mask the bug.
|
||||
let probe = 1;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
harness.client.sendTerminalInput(newTerminalId, {
|
||||
type: "input",
|
||||
data: `echo "S${probe++}=$(stty size)="\n`,
|
||||
});
|
||||
return parseLatestSttySize(await getTerminalBufferText(page));
|
||||
},
|
||||
{ timeout: 15_000, intervals: [100, 250, 500] },
|
||||
)
|
||||
.toEqual({ rows: rendered.rows, cols: rendered.cols });
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
|
||||
async function pressFocusModeShortcut(page: import("@playwright/test").Page) {
|
||||
await page.keyboard.press(`${modifier}+Shift+F`);
|
||||
}
|
||||
|
||||
async function pressSettingsShortcut(page: import("@playwright/test").Page) {
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
async function blurActiveElement(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
}
|
||||
|
||||
test("focus mode only applies to the active workspace screen", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "focus-mode-boundary-" });
|
||||
await workspace.navigateTo();
|
||||
const exitFocusMode = page.getByRole("button", { name: "Exit focus mode" });
|
||||
const settingsButton = page.getByRole("button", { name: "Settings", exact: true });
|
||||
const settingsSidebar = page.getByRole("navigation", { name: "Settings" });
|
||||
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
|
||||
await blurActiveElement(page);
|
||||
await pressFocusModeShortcut(page);
|
||||
|
||||
await expect(exitFocusMode).toBeVisible();
|
||||
await expect(settingsButton).toHaveCount(0);
|
||||
const workspaceUrl = page.url();
|
||||
|
||||
await pressSettingsShortcut(page);
|
||||
|
||||
await expect(settingsSidebar).toBeVisible();
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(settingsSidebar).toBeVisible();
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
|
||||
await pressFocusModeShortcut(page);
|
||||
await page.goto(workspaceUrl);
|
||||
|
||||
await expect(exitFocusMode).toBeVisible();
|
||||
await exitFocusMode.click();
|
||||
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
await expect(settingsButton).toBeVisible();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.1.109",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -40,7 +40,6 @@ import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
getIsElectronRuntimeMac,
|
||||
HEADER_INNER_HEIGHT,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
@@ -109,11 +108,7 @@ import {
|
||||
WindowChromeRegion,
|
||||
WindowChromeSafeArea,
|
||||
} from "@/utils/desktop-window";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import {
|
||||
@@ -430,6 +425,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
@@ -446,8 +442,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
useCompactWebViewportZoomLock(isCompactLayout);
|
||||
const pathname = usePathname();
|
||||
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
|
||||
const isWorkspaceFocusModeEnabled = isWorkspaceRoute && isFocusModeEnabled;
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const toggleDesktopSidebars = useCallback(() => {
|
||||
@@ -476,6 +470,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
isMobile: isCompactLayout,
|
||||
toggleAgentList,
|
||||
toggleBothSidebars: toggleDesktopSidebars,
|
||||
toggleFocusMode,
|
||||
cycleTheme,
|
||||
});
|
||||
|
||||
@@ -484,11 +479,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: pathname.includes("/settings"),
|
||||
isWorkspaceExplorerOpen: isWorkspaceRoute && isDesktopFileExplorerOpen,
|
||||
isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen,
|
||||
requestedExplorerWidth: explorerWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const desktopSidebarMounted = chromeEnabled && !isWorkspaceFocusModeEnabled;
|
||||
const desktopSidebarMounted = chromeEnabled && !isFocusModeEnabled;
|
||||
const desktopSidebarVisible =
|
||||
!isCompactLayout &&
|
||||
desktopSidebarMounted &&
|
||||
@@ -502,7 +497,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const appChromeLayout = resolveDesktopAppChromeLayout({
|
||||
desktopSidebarRendered: desktopSidebarVisible,
|
||||
hasTopLeftWindowControls,
|
||||
sidebarControlsEnabled: chromeEnabled && !isWorkspaceFocusModeEnabled,
|
||||
sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled,
|
||||
});
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
@@ -664,23 +659,16 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
|
||||
const { theme } = useUnistyles();
|
||||
const surface0 = theme.colors.surface0;
|
||||
const foreground = theme.colors.foreground;
|
||||
const pathname = usePathname();
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const liftTrafficLights =
|
||||
getIsElectronRuntimeMac() &&
|
||||
isFocusModeEnabled &&
|
||||
parseHostWorkspaceRouteFromPathname(pathname) !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || isNative) return;
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: surface0,
|
||||
foregroundColor: foreground,
|
||||
trafficLightOffsetY: liftTrafficLights ? -5 : 0.5,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [enabled, surface0, foreground, liftTrafficLights]);
|
||||
}, [enabled, surface0, foreground]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -999,7 +987,7 @@ const layoutStyles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
windowSidebarToggle: {
|
||||
position: "absolute",
|
||||
top: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 20,
|
||||
height: HEADER_INNER_HEIGHT,
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
import { getForgePresentation } from "@/git/forge";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export interface AttachmentPillContent {
|
||||
@@ -28,16 +27,10 @@ function getReviewSubtitle(count: number, t: TFunction): string {
|
||||
}
|
||||
|
||||
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
|
||||
if (
|
||||
attachment.kind === "forge.change_request_check" ||
|
||||
attachment.kind === "github.pull_request_check"
|
||||
) {
|
||||
if (attachment.kind === "github.pull_request_check") {
|
||||
return "Check logs";
|
||||
}
|
||||
if (
|
||||
attachment.kind === "forge.change_request_comment" ||
|
||||
attachment.kind === "github.pull_request_comment"
|
||||
) {
|
||||
if (attachment.kind === "github.pull_request_comment") {
|
||||
return "Comment";
|
||||
}
|
||||
return "Review";
|
||||
@@ -64,21 +57,12 @@ export function getAgentAttachmentPillContent(
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.comments.length, t),
|
||||
};
|
||||
case "forge_change_request": {
|
||||
const presentation = getForgePresentation(attachment.forge ?? "github");
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `${presentation.changeRequestAbbrev} ${presentation.numberPrefix}${attachment.number}`,
|
||||
};
|
||||
}
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "forge_issue":
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
AgentAttachment,
|
||||
ForgeSearchItem,
|
||||
GitHubSearchItem,
|
||||
UploadedFileAttachment,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -54,9 +54,6 @@ export interface BrowserElementAttachment {
|
||||
}
|
||||
|
||||
export type PullRequestContextAttachmentKind =
|
||||
| "forge.change_request_comment"
|
||||
| "forge.change_request_review"
|
||||
| "forge.change_request_check"
|
||||
| "github.pull_request_comment"
|
||||
| "github.pull_request_review"
|
||||
| "github.pull_request_check";
|
||||
@@ -70,9 +67,6 @@ interface PullRequestContextAttachmentFields {
|
||||
}
|
||||
|
||||
export type PullRequestContextAttachment =
|
||||
| ({ kind: "forge.change_request_comment" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "forge.change_request_review" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "forge.change_request_check" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_comment" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
|
||||
@@ -95,13 +89,10 @@ export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||
| { kind: "github_issue"; item: ForgeSearchItem }
|
||||
| { kind: "github_issue"; item: GitHubSearchItem }
|
||||
| {
|
||||
kind: "github_pr";
|
||||
item: ForgeSearchItem;
|
||||
item: GitHubSearchItem;
|
||||
owner?: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ export function isPullRequestContextAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is PullRequestContextAttachment {
|
||||
return (
|
||||
attachment?.kind === "forge.change_request_comment" ||
|
||||
attachment?.kind === "forge.change_request_review" ||
|
||||
attachment?.kind === "forge.change_request_check" ||
|
||||
attachment?.kind === "github.pull_request_comment" ||
|
||||
attachment?.kind === "github.pull_request_review" ||
|
||||
attachment?.kind === "github.pull_request_check"
|
||||
|
||||
@@ -81,15 +81,12 @@ function areWorkspaceAttachmentsEqual(
|
||||
}
|
||||
|
||||
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
|
||||
if (
|
||||
attachment.kind !== "chat_history" &&
|
||||
attachment.kind !== "forge.change_request_comment" &&
|
||||
attachment.kind !== "forge.change_request_review" &&
|
||||
attachment.kind !== "forge.change_request_check" &&
|
||||
attachment.kind !== "github.pull_request_comment" &&
|
||||
attachment.kind !== "github.pull_request_review" &&
|
||||
attachment.kind !== "github.pull_request_check"
|
||||
) {
|
||||
const isContextAttachment =
|
||||
attachment.kind === "chat_history" ||
|
||||
attachment.kind === "github.pull_request_comment" ||
|
||||
attachment.kind === "github.pull_request_review" ||
|
||||
attachment.kind === "github.pull_request_check";
|
||||
if (!isContextAttachment) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
|
||||
@@ -3,16 +3,11 @@ import {
|
||||
type BrowserWebviewProfileHost,
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
ensureResidentBrowserWebview,
|
||||
getResidentBrowserWebview,
|
||||
prepareBrowserWebview,
|
||||
releaseResidentBrowserWebview,
|
||||
removeResidentBrowserWebview,
|
||||
takeResidentBrowserWebview,
|
||||
} from "./browser-webview-resident";
|
||||
import {
|
||||
setCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "../utils/command-center-focus-restore";
|
||||
|
||||
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
|
||||
const attachedBrowsers: Array<{
|
||||
@@ -259,20 +254,6 @@ describe("resident browser webviews", () => {
|
||||
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds the originating browser webview for focus restoration", () => {
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-focus",
|
||||
workspaceId: "workspace-focus",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
setCommandCenterFocusRestoreElement(getResidentBrowserWebview("browser-focus"));
|
||||
|
||||
expect(takeCommandCenterFocusRestoreElement()).toBe(webview);
|
||||
|
||||
expect(getResidentBrowserWebview("browser-missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes a resident webview when its browser tab closes", () => {
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-closed",
|
||||
|
||||
@@ -54,20 +54,22 @@ function registerBrowserWhenAttached(
|
||||
identity: BrowserWebviewIdentity,
|
||||
browser: BrowserWebviewProfileHost,
|
||||
): void {
|
||||
// Reparenting a webview can replace its guest WebContents without replacing
|
||||
// this DOM element, so every attachment needs a fresh main-process registration.
|
||||
webview.addEventListener("did-attach", () => {
|
||||
const webContentsId = webview.getWebContentsId();
|
||||
void browser
|
||||
.registerAttachedBrowser({
|
||||
browserId: identity.browserId,
|
||||
workspaceId: identity.workspaceId,
|
||||
webContentsId,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[browser-webview] attached registration failed", error);
|
||||
});
|
||||
});
|
||||
webview.addEventListener(
|
||||
"did-attach",
|
||||
() => {
|
||||
const webContentsId = webview.getWebContentsId();
|
||||
void browser
|
||||
.registerAttachedBrowser({
|
||||
browserId: identity.browserId,
|
||||
workspaceId: identity.workspaceId,
|
||||
webContentsId,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[browser-webview] attached registration failed", error);
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -223,19 +225,6 @@ export function ensureResidentBrowserWebview(input: {
|
||||
return webview;
|
||||
}
|
||||
|
||||
export function getResidentBrowserWebview(browserId: string): HTMLElement | null {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return null;
|
||||
}
|
||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
if (resident?.isConnected) {
|
||||
return resident;
|
||||
}
|
||||
const ownerDocument = readDocument();
|
||||
return ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
|
||||
}
|
||||
|
||||
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export { reorderItemsOnDragEnd } from "./reorder-items";
|
||||
export type { DragEndInput } from "./reorder-items";
|
||||
export {
|
||||
getDragActivationConstraints,
|
||||
type DragActivationConfig,
|
||||
type DragActivationConstraints,
|
||||
getPointerActivationConstraint,
|
||||
type PointerActivationConfig,
|
||||
type PointerActivationConstraint,
|
||||
} from "./pointer-activation";
|
||||
export {
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDragActivationConstraints } from "./pointer-activation";
|
||||
import { getPointerActivationConstraint } from "./pointer-activation";
|
||||
|
||||
const config = { movementDistance: 6, touchHoldDelayMs: 180, touchHoldTolerance: 8 };
|
||||
const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 };
|
||||
|
||||
describe("getDragActivationConstraints", () => {
|
||||
it("starts mouse drags after deliberate pointer movement", () => {
|
||||
expect(getDragActivationConstraints(true, config).mouse).toEqual({ distance: 6 });
|
||||
describe("getPointerActivationConstraint", () => {
|
||||
it("uses distance activation for default draggable rows", () => {
|
||||
expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 });
|
||||
});
|
||||
|
||||
it("requires a short hold before starting touch drags", () => {
|
||||
expect(getDragActivationConstraints(true, config).touch).toEqual({
|
||||
delay: 180,
|
||||
tolerance: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("starts ordinary touch rows after deliberate movement", () => {
|
||||
expect(getDragActivationConstraints(false, config).touch).toEqual({ distance: 6 });
|
||||
it("requires a held pointer before activating handle-based drags", () => {
|
||||
expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,25 +2,18 @@ export type PointerActivationConstraint =
|
||||
| { distance: number }
|
||||
| { delay: number; tolerance: number };
|
||||
|
||||
export interface DragActivationConfig {
|
||||
movementDistance: number;
|
||||
touchHoldDelayMs: number;
|
||||
touchHoldTolerance: number;
|
||||
export interface PointerActivationConfig {
|
||||
defaultDistance: number;
|
||||
holdDelayMs: number;
|
||||
holdTolerance: number;
|
||||
}
|
||||
|
||||
export interface DragActivationConstraints {
|
||||
mouse: PointerActivationConstraint;
|
||||
touch: PointerActivationConstraint;
|
||||
}
|
||||
|
||||
export function getDragActivationConstraints(
|
||||
export function getPointerActivationConstraint(
|
||||
useDragHandle: boolean,
|
||||
config: DragActivationConfig,
|
||||
): DragActivationConstraints {
|
||||
const movement = { distance: config.movementDistance };
|
||||
const touch = useDragHandle
|
||||
? { delay: config.touchHoldDelayMs, tolerance: config.touchHoldTolerance }
|
||||
: movement;
|
||||
|
||||
return { mouse: movement, touch };
|
||||
config: PointerActivationConfig,
|
||||
): PointerActivationConstraint {
|
||||
if (useDragHandle) {
|
||||
return { delay: config.holdDelayMs, tolerance: config.holdTolerance };
|
||||
}
|
||||
return { distance: config.defaultDistance };
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
PointerSensor,
|
||||
type Modifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder";
|
||||
import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
@@ -28,10 +27,10 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
|
||||
});
|
||||
|
||||
const DND_MODIFIERS = [restrictToVerticalAxis];
|
||||
const DRAG_ACTIVATION_CONFIG = {
|
||||
movementDistance: 6,
|
||||
touchHoldDelayMs: 180,
|
||||
touchHoldTolerance: 8,
|
||||
const POINTER_ACTIVATION_CONFIG = {
|
||||
defaultDistance: 6,
|
||||
holdDelayMs: 250,
|
||||
holdTolerance: 8,
|
||||
};
|
||||
|
||||
interface SortableItemProps<T> {
|
||||
@@ -146,14 +145,14 @@ export function DraggableList<T>({
|
||||
onDragEnd,
|
||||
onDragBegin,
|
||||
});
|
||||
const activationConstraints = getDragActivationConstraints(useDragHandle, DRAG_ACTIVATION_CONFIG);
|
||||
const pointerActivationConstraint = getPointerActivationConstraint(
|
||||
useDragHandle,
|
||||
POINTER_ACTIVATION_CONFIG,
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: activationConstraints.mouse,
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: activationConstraints.touch,
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: pointerActivationConstraint,
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -33,7 +33,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
|
||||
@@ -212,11 +212,9 @@ export function ExplorerSidebar({
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
testID="explorer-sidebar-resize-handle"
|
||||
/>
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View style={RESIZE_HANDLE_STYLE} />
|
||||
</GestureDetector>
|
||||
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
@@ -349,7 +347,6 @@ function ExplorerSidebarContent({
|
||||
testID="explorer-tab-pr"
|
||||
>
|
||||
<PullRequestTabIcon
|
||||
forge={prPane.forge}
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
@@ -462,6 +459,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderLeftColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
resizeHandle: {
|
||||
position: "absolute",
|
||||
left: -5,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -518,3 +523,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }];
|
||||
const RESIZE_HANDLE_STYLE = [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)];
|
||||
|
||||
@@ -45,14 +45,12 @@ function MobileMenuIcon({ color }: { color: string }) {
|
||||
|
||||
function SidebarMenuToggleButton({
|
||||
isMobile,
|
||||
extraMutedIdleIcon = false,
|
||||
resolvedStyle,
|
||||
tooltipSide = "right",
|
||||
testID = "menu-button",
|
||||
nativeID = "menu-button",
|
||||
}: Omit<SidebarMenuToggleProps, "style"> & {
|
||||
isMobile: boolean;
|
||||
extraMutedIdleIcon?: boolean;
|
||||
resolvedStyle: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -85,12 +83,7 @@ function SidebarMenuToggleButton({
|
||||
accessibilityState={accessibilityState}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
let color = extraMutedIdleIcon
|
||||
? theme.colors.foregroundExtraMuted
|
||||
: theme.colors.foregroundMuted;
|
||||
if (hovered || pressed) {
|
||||
color = theme.colors.foreground;
|
||||
}
|
||||
const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return isMobile ? (
|
||||
<MobileMenuIcon color={color} />
|
||||
) : (
|
||||
@@ -128,14 +121,7 @@ export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps =
|
||||
|
||||
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
|
||||
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
|
||||
return (
|
||||
<SidebarMenuToggleButton
|
||||
{...props}
|
||||
isMobile={false}
|
||||
extraMutedIdleIcon
|
||||
resolvedStyle={resolvedStyle}
|
||||
/>
|
||||
);
|
||||
return <SidebarMenuToggleButton {...props} isMobile={false} resolvedStyle={resolvedStyle} />;
|
||||
}
|
||||
|
||||
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
|
||||
|
||||
const CODEBERG_ICON_PATH =
|
||||
"M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648z";
|
||||
|
||||
export function CodebergIcon(props: SvgPathIconProps) {
|
||||
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={CODEBERG_ICON_PATH} />;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
|
||||
|
||||
const FORGEJO_ICON_PATH =
|
||||
"M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z";
|
||||
|
||||
export function ForgejoIcon(props: SvgPathIconProps) {
|
||||
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={FORGEJO_ICON_PATH} />;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
|
||||
|
||||
const GITEA_ICON_PATH =
|
||||
"M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z";
|
||||
|
||||
export function GiteaIcon(props: SvgPathIconProps) {
|
||||
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITEA_ICON_PATH} />;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
|
||||
|
||||
const GITLAB_ICON_PATH =
|
||||
"M23.6004 9.5927l-.0337-.0862L20.3 .9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z";
|
||||
|
||||
export function GitLabIcon(props: SvgPathIconProps) {
|
||||
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITLAB_ICON_PATH} />;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ interface OmpIconProps {
|
||||
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
|
||||
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
|
||||
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" fill={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
export interface SvgPathIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface SvgPathIconInput extends SvgPathIconProps {
|
||||
path: string;
|
||||
viewBox: string;
|
||||
}
|
||||
|
||||
export function SvgPathIcon({
|
||||
size = 16,
|
||||
color = "currentColor",
|
||||
path,
|
||||
viewBox,
|
||||
}: SvgPathIconInput) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox={viewBox} fill={color}>
|
||||
<Path d={path} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getPromptPreview,
|
||||
getSessionTitle,
|
||||
resolveProvidersToFetch,
|
||||
requiresImportSessionsHostUpgrade,
|
||||
type SessionsQueryResult,
|
||||
sumFilteredAlreadyImportedCount,
|
||||
} from "@/components/import-session-sheet-view-model";
|
||||
@@ -71,35 +70,6 @@ describe("resolveProvidersToFetch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresImportSessionsHostUpgrade", () => {
|
||||
it("allows home imports on hosts without workspace targeting", () => {
|
||||
expect(
|
||||
requiresImportSessionsHostUpgrade({
|
||||
supportsSnapshot: true,
|
||||
workspaceId: null,
|
||||
supportsWorkspaceTarget: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires host support for imports opened from a workspace", () => {
|
||||
expect(
|
||||
requiresImportSessionsHostUpgrade({
|
||||
supportsSnapshot: true,
|
||||
workspaceId: "ws-current",
|
||||
supportsWorkspaceTarget: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresImportSessionsHostUpgrade({
|
||||
supportsSnapshot: true,
|
||||
workspaceId: "ws-current",
|
||||
supportsWorkspaceTarget: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderLabelMap", () => {
|
||||
it("returns an empty map when snapshot entries are missing", () => {
|
||||
expect(buildProviderLabelMap(undefined).size).toBe(0);
|
||||
|
||||
@@ -5,14 +5,6 @@ import { i18n } from "@/i18n/i18next";
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
export const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
export function requiresImportSessionsHostUpgrade(input: {
|
||||
supportsSnapshot: boolean;
|
||||
workspaceId?: string | null;
|
||||
supportsWorkspaceTarget: boolean;
|
||||
}): boolean {
|
||||
return !input.supportsSnapshot || (Boolean(input.workspaceId) && !input.supportsWorkspaceTarget);
|
||||
}
|
||||
|
||||
export interface SessionsQueryResult {
|
||||
data:
|
||||
| {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
aggregateSessionEntries,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
getSessionTitle,
|
||||
PER_PROVIDER_LIMIT,
|
||||
resolveProvidersToFetch,
|
||||
requiresImportSessionsHostUpgrade,
|
||||
sumFilteredAlreadyImportedCount,
|
||||
} from "@/components/import-session-sheet-view-model";
|
||||
|
||||
@@ -46,7 +44,6 @@ interface ImportSessionSheetProps {
|
||||
client: RecentProviderSessionsClient | null;
|
||||
serverId: string | null;
|
||||
cwd?: string | null;
|
||||
workspaceId?: string | null;
|
||||
onClose: () => void;
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
onImported?: (agent: ImportedAgent) => void;
|
||||
@@ -263,7 +260,6 @@ export function ImportSessionSheet({
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
workspaceId,
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
onImported,
|
||||
@@ -276,16 +272,10 @@ export function ImportSessionSheet({
|
||||
cwd,
|
||||
enabled: visible,
|
||||
});
|
||||
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
|
||||
const requiresHostUpgrade = requiresImportSessionsHostUpgrade({
|
||||
supportsSnapshot,
|
||||
workspaceId,
|
||||
supportsWorkspaceTarget,
|
||||
});
|
||||
|
||||
const providersToFetch = useMemo(
|
||||
() => (requiresHostUpgrade ? null : resolveProvidersToFetch(supportsSnapshot, snapshotEntries)),
|
||||
[requiresHostUpgrade, supportsSnapshot, snapshotEntries],
|
||||
() => resolveProvidersToFetch(supportsSnapshot, snapshotEntries),
|
||||
[supportsSnapshot, snapshotEntries],
|
||||
);
|
||||
|
||||
const providerLabelById = useMemo(
|
||||
@@ -418,7 +408,6 @@ export function ImportSessionSheet({
|
||||
providerId: entry.providerId,
|
||||
providerHandleId: entry.providerHandleId,
|
||||
cwd: entry.cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
});
|
||||
return agent;
|
||||
},
|
||||
@@ -461,7 +450,7 @@ export function ImportSessionSheet({
|
||||
[isRefreshing, handleRefresh, t],
|
||||
);
|
||||
|
||||
const isSnapshotUnsupported = requiresHostUpgrade;
|
||||
const isSnapshotUnsupported = !supportsSnapshot;
|
||||
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
|
||||
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
|
||||
const isQueryingProviders = queries.length > 0;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -30,10 +30,10 @@ import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
@@ -765,6 +765,11 @@ function DesktopSidebar({
|
||||
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
|
||||
[ownsTopLeft],
|
||||
);
|
||||
const resizeHandleStyle = useMemo(
|
||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
accessibilityElementsHidden={!active}
|
||||
@@ -839,11 +844,10 @@ function DesktopSidebar({
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
|
||||
<SidebarResizeHandle
|
||||
edge="right"
|
||||
gesture={resizeGesture}
|
||||
testID="left-sidebar-resize-handle"
|
||||
/>
|
||||
{/* Resize handle - absolutely positioned over right border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View style={resizeHandleStyle} />
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -994,6 +998,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
resizeHandle: {
|
||||
position: "absolute",
|
||||
right: -5,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { GestureDetector, type GestureType } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface SidebarResizeHandleProps {
|
||||
edge: "left" | "right";
|
||||
gesture: GestureType;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
const HIGHLIGHT_DELAY_MS = 100;
|
||||
|
||||
const webResizeCursorStyle = isWeb
|
||||
? ({
|
||||
cursor: "col-resize",
|
||||
} as object)
|
||||
: null;
|
||||
|
||||
export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) {
|
||||
const [highlighted, setHighlighted] = useState(false);
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hitAreaStyle = edge === "left" ? LEFT_HIT_AREA_STYLE : RIGHT_HIT_AREA_STYLE;
|
||||
|
||||
const cancelHighlightTimer = useCallback(() => {
|
||||
if (highlightTimerRef.current === null) return;
|
||||
clearTimeout(highlightTimerRef.current);
|
||||
highlightTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleHoverIn = useCallback(() => {
|
||||
cancelHighlightTimer();
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
highlightTimerRef.current = null;
|
||||
setHighlighted(true);
|
||||
}, HIGHLIGHT_DELAY_MS);
|
||||
}, [cancelHighlightTimer]);
|
||||
|
||||
const handleHoverOut = useCallback(() => {
|
||||
cancelHighlightTimer();
|
||||
setHighlighted(false);
|
||||
}, [cancelHighlightTimer]);
|
||||
|
||||
useEffect(() => cancelHighlightTimer, [cancelHighlightTimer]);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Pressable
|
||||
testID={testID}
|
||||
style={hitAreaStyle}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
{highlighted ? (
|
||||
<View pointerEvents="none" testID={`${testID}-highlight`} style={styles.highlight} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
hitArea: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
leftEdge: {
|
||||
left: -5,
|
||||
},
|
||||
rightEdge: {
|
||||
right: -5,
|
||||
},
|
||||
highlight: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 5,
|
||||
width: 1,
|
||||
backgroundColor: theme.colors.foreground,
|
||||
opacity: 0.25,
|
||||
},
|
||||
}));
|
||||
|
||||
const LEFT_HIT_AREA_STYLE = [styles.hitArea, styles.leftEdge, webResizeCursorStyle];
|
||||
const RIGHT_HIT_AREA_STYLE = [styles.hitArea, styles.rightEdge, webResizeCursorStyle];
|
||||
@@ -2,12 +2,15 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
StatusBar,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
@@ -81,9 +84,9 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
@@ -92,7 +95,6 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
@@ -317,14 +319,12 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
|
||||
const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
|
||||
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
|
||||
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
|
||||
number: hint.number,
|
||||
context: presentation.changeRequestContext,
|
||||
})}
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
@@ -339,7 +339,6 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
|
||||
)}
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{presentation.numberPrefix}
|
||||
{hint.number}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -945,6 +944,223 @@ function NewWorkspaceGhostRow({
|
||||
);
|
||||
}
|
||||
|
||||
function useLongPressDragInteraction(input: {
|
||||
drag: () => void;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
}) {
|
||||
const didLongPressRef = useRef(false);
|
||||
const dragArmedRef = useRef(false);
|
||||
const dragActivatedRef = useRef(false);
|
||||
const didStartDragRef = useRef(false);
|
||||
const scrollIntentRef = useRef(false);
|
||||
const menuOpenedRef = useRef(false);
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (dragArmTimerRef.current) {
|
||||
clearTimeout(dragArmTimerRef.current);
|
||||
dragArmTimerRef.current = null;
|
||||
}
|
||||
if (contextMenuTimerRef.current) {
|
||||
clearTimeout(contextMenuTimerRef.current);
|
||||
contextMenuTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openContextMenuAtStartPoint = useCallback(() => {
|
||||
if (!input.menuController || !touchStartRef.current) {
|
||||
return;
|
||||
}
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
input.menuController.setAnchorRect({
|
||||
x: touchStartRef.current.x,
|
||||
y: touchStartRef.current.y + statusBarHeight,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
input.menuController.setOpen(true);
|
||||
menuOpenedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
}, [input.menuController]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
// Manual timers own long-press behavior on mobile.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers();
|
||||
|
||||
const DRAG_ARM_DELAY_MS = 180;
|
||||
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
||||
const CONTEXT_MENU_DELAY_MS = 450;
|
||||
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
|
||||
|
||||
dragArmTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
dragArmedRef.current = true;
|
||||
dragActivatedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
input.drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!input.menuController || platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextMenuTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
openContextMenuAtStartPoint();
|
||||
}, CONTEXT_MENU_DELAY_MS);
|
||||
}, [clearTimers, input, openContextMenuAtStartPoint]);
|
||||
|
||||
const handleDragIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
if (!dragActivatedRef.current) {
|
||||
return;
|
||||
}
|
||||
didStartDragRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleScrollIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
scrollIntentRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleSwipeIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
didLongPressRef.current = false;
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
didStartDragRef.current = false;
|
||||
scrollIntentRef.current = false;
|
||||
menuOpenedRef.current = false;
|
||||
touchStartRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
touchCurrentRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
armTimers();
|
||||
},
|
||||
[armTimers],
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
const start = touchStartRef.current;
|
||||
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
||||
const x = touch?.pageX;
|
||||
const y = touch?.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = { x, y };
|
||||
touchCurrentRef.current = current;
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const decision = decideLongPressMove({
|
||||
dragArmed: dragArmedRef.current,
|
||||
didStartDrag: didStartDragRef.current,
|
||||
startPoint: start,
|
||||
currentPoint: current,
|
||||
});
|
||||
|
||||
if (decision === "vertical_scroll") {
|
||||
handleScrollIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
|
||||
handleSwipeIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "start_drag") {
|
||||
handleDragIntent({ dx, dy, distance });
|
||||
}
|
||||
},
|
||||
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
|
||||
);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
clearTimers();
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
touchStartRef.current = null;
|
||||
touchCurrentRef.current = null;
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
didLongPressRef,
|
||||
handleLongPress,
|
||||
handlePressIn,
|
||||
handleTouchMove,
|
||||
handlePressOut,
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectHeaderRow({
|
||||
project,
|
||||
displayName,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -19,14 +18,13 @@ import {
|
||||
Monitor,
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
@@ -54,6 +52,7 @@ const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purp
|
||||
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
@@ -63,10 +62,6 @@ const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
|
||||
function renderChecksBadgeForgeIcon(icon: string) {
|
||||
return <ForgeBrandIcon iconKind={icon} size={10} uniProps={redColorMapping} />;
|
||||
}
|
||||
|
||||
type SidebarWorkspaceScriptIconKind = "service" | "command";
|
||||
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
@@ -161,7 +156,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
{workspace.prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<PrBadge hint={workspace.prHint} />
|
||||
<ChecksBadge checks={workspace.prHint.checks} forge={workspace.prHint.forge} />
|
||||
<ChecksBadge checks={workspace.prHint.checks} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -295,7 +290,6 @@ function StatusDotOverlay({
|
||||
}
|
||||
|
||||
function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
@@ -309,7 +303,6 @@ function PrBadge({ hint }: { hint: PrHint }) {
|
||||
[isHovered],
|
||||
);
|
||||
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
|
||||
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
@@ -323,10 +316,7 @@ function PrBadge({ hint }: { hint: PrHint }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
|
||||
number: hint.number,
|
||||
context: presentation.changeRequestContext,
|
||||
})}
|
||||
accessibilityLabel={`Pull request #${hint.number}`}
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
@@ -340,21 +330,19 @@ function PrBadge({ hint }: { hint: PrHint }) {
|
||||
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
|
||||
)}
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{presentation.numberPrefix}
|
||||
{hint.number}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksBadge({ checks, forge }: { checks: PrHint["checks"]; forge: PrHint["forge"] }) {
|
||||
function ChecksBadge({ checks }: { checks: PrHint["checks"] }) {
|
||||
if (!checks || checks.length === 0) return null;
|
||||
const failed = checks.filter((check) => check.status === "failure").length;
|
||||
if (failed === 0) return null;
|
||||
const icon = getForgePresentation(normalizeForge(forge)).icon;
|
||||
return (
|
||||
<View style={checksBadgeStyles.badge}>
|
||||
{renderChecksBadgeForgeIcon(icon)}
|
||||
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
|
||||
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -59,9 +59,6 @@ export function useLongPressDragInteraction(input: {
|
||||
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers();
|
||||
if (platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
const DRAG_ARM_DELAY_MS = 180;
|
||||
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
||||
@@ -90,7 +87,7 @@ export function useLongPressDragInteraction(input: {
|
||||
input.drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!input.menuController) {
|
||||
if (!input.menuController || platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,6 @@ interface SplitContainerProps {
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
renderPaneEmptyState?: () => ReactNode;
|
||||
focusModeEnabled?: boolean;
|
||||
onExitFocusMode: () => void;
|
||||
}
|
||||
|
||||
interface WorkspaceTabDragData {
|
||||
@@ -391,7 +390,6 @@ export function SplitContainer({
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState = () => null,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
}: SplitContainerProps) {
|
||||
const inheritedWindowChromeCorners = useWindowChromeCorners();
|
||||
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
|
||||
@@ -613,8 +611,6 @@ export function SplitContainer({
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragTabId ? (
|
||||
@@ -759,8 +755,6 @@ function SplitNodeView({
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
windowChromeCorners,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
}: SplitNodeViewProps) {
|
||||
const groupId = node.kind === "group" ? node.group.id : null;
|
||||
const groupDirection = node.kind === "group" ? node.group.direction : null;
|
||||
@@ -814,8 +808,6 @@ function SplitNodeView({
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
);
|
||||
@@ -865,8 +857,6 @@ function SplitNodeView({
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={windowChromeCorners}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</SplitGroupChild>
|
||||
{index < node.group.children.length - 1 ? (
|
||||
@@ -918,8 +908,6 @@ function SplitPaneView({
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
}: SplitPaneViewProps) {
|
||||
const { theme: _theme } = useUnistyles();
|
||||
const paneRef = useRef<View | null>(null);
|
||||
@@ -1055,8 +1043,6 @@ function SplitPaneView({
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
focusModeEnabled={Boolean(focusModeEnabled)}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EMPTY_FOCUS_CLAIM_STATE,
|
||||
canRequestFocusClaim,
|
||||
reconcileFocusClaim,
|
||||
settleFocusClaim,
|
||||
type FocusClaimState,
|
||||
} from "./terminal-pane-focus-claim";
|
||||
|
||||
function request(
|
||||
state: FocusClaimState,
|
||||
input: { key: string | null; canRequest: boolean },
|
||||
): FocusClaimState {
|
||||
return reconcileFocusClaim(state, input).state;
|
||||
}
|
||||
|
||||
describe("terminal pane focus claim", () => {
|
||||
it("waits for both the client and renderer before requesting a claim", () => {
|
||||
const withoutClient = canRequestFocusClaim({
|
||||
isWorkspaceFocused: true,
|
||||
isAppVisible: true,
|
||||
isClientReady: false,
|
||||
isConnected: true,
|
||||
isRendererReady: true,
|
||||
});
|
||||
const withoutRenderer = canRequestFocusClaim({
|
||||
isWorkspaceFocused: true,
|
||||
isAppVisible: true,
|
||||
isClientReady: true,
|
||||
isConnected: true,
|
||||
isRendererReady: false,
|
||||
});
|
||||
|
||||
expect([withoutClient, withoutRenderer]).toEqual([false, false]);
|
||||
});
|
||||
|
||||
it("does not deliver a requested claim after the host disconnects", () => {
|
||||
const disconnected = canRequestFocusClaim({
|
||||
isWorkspaceFocused: true,
|
||||
isAppVisible: true,
|
||||
isClientReady: true,
|
||||
isConnected: false,
|
||||
isRendererReady: true,
|
||||
});
|
||||
|
||||
expect(disconnected).toBe(false);
|
||||
});
|
||||
|
||||
it("claims once per continuous pane-focus period after send", () => {
|
||||
const firstRequest = reconcileFocusClaim(EMPTY_FOCUS_CLAIM_STATE, {
|
||||
key: "ws:term-1",
|
||||
canRequest: true,
|
||||
});
|
||||
const sent = settleFocusClaim(firstRequest.state, {
|
||||
key: "ws:term-1",
|
||||
sent: true,
|
||||
});
|
||||
const repeated = reconcileFocusClaim(sent, {
|
||||
key: "ws:term-1",
|
||||
canRequest: true,
|
||||
});
|
||||
|
||||
expect(firstRequest.shouldRequest).toBe(true);
|
||||
expect(repeated).toEqual({
|
||||
state: { claimedKey: "ws:term-1", requestedKey: null },
|
||||
shouldRequest: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defers until the claim can be requested", () => {
|
||||
const unavailable = reconcileFocusClaim(EMPTY_FOCUS_CLAIM_STATE, {
|
||||
key: "ws:term-1",
|
||||
canRequest: false,
|
||||
});
|
||||
const available = reconcileFocusClaim(unavailable.state, {
|
||||
key: "ws:term-1",
|
||||
canRequest: true,
|
||||
});
|
||||
|
||||
expect(unavailable.shouldRequest).toBe(false);
|
||||
expect(available.shouldRequest).toBe(true);
|
||||
});
|
||||
|
||||
it("retries when readiness changes before the requested claim lands", () => {
|
||||
const requested = request(EMPTY_FOCUS_CLAIM_STATE, {
|
||||
key: "ws:term-1",
|
||||
canRequest: true,
|
||||
});
|
||||
const hiddenBeforeDelivery = request(requested, {
|
||||
key: "ws:term-1",
|
||||
canRequest: false,
|
||||
});
|
||||
const visibleAgain = reconcileFocusClaim(hiddenBeforeDelivery, {
|
||||
key: "ws:term-1",
|
||||
canRequest: true,
|
||||
});
|
||||
|
||||
expect(visibleAgain).toEqual({
|
||||
state: { claimedKey: null, requestedKey: "ws:term-1" },
|
||||
shouldRequest: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a requested claim that was not sent", () => {
|
||||
const requested = request(EMPTY_FOCUS_CLAIM_STATE, {
|
||||
key: "ws:term-1",
|
||||
canRequest: true,
|
||||
});
|
||||
const dropped = settleFocusClaim(requested, {
|
||||
key: "ws:term-1",
|
||||
sent: false,
|
||||
});
|
||||
const retry = reconcileFocusClaim(dropped, {
|
||||
key: "ws:term-1",
|
||||
canRequest: true,
|
||||
});
|
||||
|
||||
expect(retry.shouldRequest).toBe(true);
|
||||
});
|
||||
|
||||
it("re-arms after pane blur or terminal change", () => {
|
||||
const requested = request(EMPTY_FOCUS_CLAIM_STATE, {
|
||||
key: "ws:term-1",
|
||||
canRequest: true,
|
||||
});
|
||||
const sent = settleFocusClaim(requested, { key: "ws:term-1", sent: true });
|
||||
const blurred = request(sent, { key: null, canRequest: true });
|
||||
const refocused = reconcileFocusClaim(blurred, { key: "ws:term-1", canRequest: true });
|
||||
const changed = reconcileFocusClaim(sent, { key: "ws:term-2", canRequest: true });
|
||||
|
||||
expect(refocused.shouldRequest).toBe(true);
|
||||
expect(changed.shouldRequest).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
export interface FocusClaimState {
|
||||
claimedKey: string | null;
|
||||
requestedKey: string | null;
|
||||
}
|
||||
|
||||
export interface FocusClaimStep {
|
||||
state: FocusClaimState;
|
||||
shouldRequest: boolean;
|
||||
}
|
||||
|
||||
interface FocusClaimReadiness {
|
||||
isWorkspaceFocused: boolean;
|
||||
isAppVisible: boolean;
|
||||
isClientReady: boolean;
|
||||
isConnected: boolean;
|
||||
isRendererReady: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = {
|
||||
claimedKey: null,
|
||||
requestedKey: null,
|
||||
};
|
||||
|
||||
export function canRequestFocusClaim(input: FocusClaimReadiness): boolean {
|
||||
return (
|
||||
input.isWorkspaceFocused &&
|
||||
input.isAppVisible &&
|
||||
input.isClientReady &&
|
||||
input.isConnected &&
|
||||
input.isRendererReady
|
||||
);
|
||||
}
|
||||
|
||||
export function reconcileFocusClaim(
|
||||
state: FocusClaimState,
|
||||
input: { key: string | null; canRequest: boolean },
|
||||
): FocusClaimStep {
|
||||
if (input.key === null) {
|
||||
return { state: EMPTY_FOCUS_CLAIM_STATE, shouldRequest: false };
|
||||
}
|
||||
if (state.claimedKey === input.key) {
|
||||
return {
|
||||
state: { claimedKey: input.key, requestedKey: null },
|
||||
shouldRequest: false,
|
||||
};
|
||||
}
|
||||
if (!input.canRequest) {
|
||||
return {
|
||||
state: { claimedKey: state.claimedKey, requestedKey: null },
|
||||
shouldRequest: false,
|
||||
};
|
||||
}
|
||||
if (state.requestedKey === input.key) {
|
||||
return { state, shouldRequest: false };
|
||||
}
|
||||
return {
|
||||
state: { claimedKey: state.claimedKey, requestedKey: input.key },
|
||||
shouldRequest: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function settleFocusClaim(
|
||||
state: FocusClaimState,
|
||||
input: { key: string; sent: boolean },
|
||||
): FocusClaimState {
|
||||
if (state.requestedKey !== input.key) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
claimedKey: input.sent ? input.key : state.claimedKey,
|
||||
requestedKey: null,
|
||||
};
|
||||
}
|
||||
@@ -21,12 +21,7 @@ import {
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||
import {
|
||||
EMPTY_FOCUS_CLAIM_STATE,
|
||||
canRequestFocusClaim,
|
||||
reconcileFocusClaim,
|
||||
settleFocusClaim,
|
||||
} from "./terminal-pane-focus-claim";
|
||||
import { rememberTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache";
|
||||
import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerStatus,
|
||||
@@ -227,7 +222,7 @@ export function TerminalPane({
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
|
||||
const lastPaneFocusResizeKeyRef = useRef<string | null>(null);
|
||||
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -270,45 +265,38 @@ export function TerminalPane({
|
||||
lastAutoFocusKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFocusKey = `${scopeKey}:${terminalId}`;
|
||||
if (lastAutoFocusKeyRef.current === nextFocusKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||
if (!isWorkspaceFocused) {
|
||||
return;
|
||||
}
|
||||
const focusKey = `${scopeKey}:${terminalId}`;
|
||||
if (lastAutoFocusKeyRef.current !== focusKey) {
|
||||
lastAutoFocusKeyRef.current = focusKey;
|
||||
requestTerminalFocus();
|
||||
}
|
||||
|
||||
requestTerminalFocus();
|
||||
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
const canRequest = canRequestFocusClaim({
|
||||
isWorkspaceFocused,
|
||||
isAppVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||
});
|
||||
const step = reconcileFocusClaim(paneFocusResizeClaimRef.current, {
|
||||
key: !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`,
|
||||
canRequest,
|
||||
});
|
||||
paneFocusResizeClaimRef.current = step.state;
|
||||
if (step.shouldRequest) {
|
||||
lastSentTerminalSizeRef.current = null;
|
||||
requestTerminalReflow();
|
||||
if (!isPaneFocused || !terminalId) {
|
||||
lastPaneFocusResizeKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
client,
|
||||
isAppVisible,
|
||||
isConnected,
|
||||
isPaneFocused,
|
||||
isWorkspaceFocused,
|
||||
rendererReadyStreamKey,
|
||||
requestTerminalReflow,
|
||||
scopeKey,
|
||||
terminalId,
|
||||
terminalStreamKey,
|
||||
]);
|
||||
|
||||
const focusResizeKey = `${scopeKey}:${terminalId}`;
|
||||
if (lastPaneFocusResizeKeyRef.current === focusResizeKey) {
|
||||
return;
|
||||
}
|
||||
lastPaneFocusResizeKeyRef.current = focusResizeKey;
|
||||
if (!isWorkspaceFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSentTerminalSizeRef.current = null;
|
||||
requestTerminalReflow();
|
||||
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, scopeKey, terminalId]);
|
||||
|
||||
const handleTerminalFocus = useCallback(() => {
|
||||
if (isWorkspaceFocused && isPaneFocused) {
|
||||
@@ -648,40 +636,26 @@ export function TerminalPane({
|
||||
const normalizedCols = Math.floor(cols);
|
||||
const nextSize = { rows: normalizedRows, cols: normalizedCols };
|
||||
measuredTerminalSizeRef.current = nextSize;
|
||||
if (!input.shouldClaim) {
|
||||
// Seed future terminals in this workspace with the current pane size so they are born at
|
||||
// the right size instead of the daemon's 80x24 default (see terminal-size-cache).
|
||||
rememberTerminalViewportSize({ serverId, cwd, size: nextSize });
|
||||
if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
|
||||
return;
|
||||
}
|
||||
let sent = false;
|
||||
const canSend = canRequestFocusClaim({
|
||||
isWorkspaceFocused,
|
||||
isAppVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: true,
|
||||
const previousSent = lastSentTerminalSizeRef.current;
|
||||
if (
|
||||
previousSent &&
|
||||
previousSent.rows === normalizedRows &&
|
||||
previousSent.cols === normalizedCols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastSentTerminalSizeRef.current = nextSize;
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
});
|
||||
if (client && terminalId && canSend) {
|
||||
const previousSent = lastSentTerminalSizeRef.current;
|
||||
if (
|
||||
!previousSent ||
|
||||
previousSent.rows !== normalizedRows ||
|
||||
previousSent.cols !== normalizedCols
|
||||
) {
|
||||
lastSentTerminalSizeRef.current = nextSize;
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
});
|
||||
}
|
||||
sent = true;
|
||||
}
|
||||
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
|
||||
if (requestedKey) {
|
||||
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
|
||||
key: requestedKey,
|
||||
sent,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -23,8 +23,7 @@ import {
|
||||
GitBranch,
|
||||
Server,
|
||||
} from "lucide-react-native";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { Pressable } from "react-native";
|
||||
@@ -320,11 +319,7 @@ function WorkspaceHoverCardContent({
|
||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<ChecksSummaryPressable
|
||||
checks={prHint.checks}
|
||||
url={prHint.url}
|
||||
forge={prHint.forge}
|
||||
/>
|
||||
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
|
||||
</>
|
||||
) : null}
|
||||
</FloatingSurface>
|
||||
@@ -348,6 +343,7 @@ function HostRow({ serverId }: { serverId: string }): ReactElement | null {
|
||||
}
|
||||
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
@@ -379,10 +375,6 @@ function InfoRow({
|
||||
);
|
||||
}
|
||||
|
||||
function renderChecksSummaryForgeIcon(icon: string, iconUniProps: typeof foregroundColorMapping) {
|
||||
return <ForgeBrandIcon iconKind={icon} size={12} uniProps={iconUniProps} />;
|
||||
}
|
||||
|
||||
function CopyableInfoRow({
|
||||
icon: Icon,
|
||||
value,
|
||||
@@ -496,11 +488,9 @@ function ChecksSummaryPill({
|
||||
|
||||
function ChecksSummaryContent({
|
||||
checks,
|
||||
forge,
|
||||
hovered,
|
||||
}: {
|
||||
checks: NonNullable<PrHint["checks"]>;
|
||||
forge: PrHint["forge"];
|
||||
hovered: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -508,14 +498,13 @@ function ChecksSummaryContent({
|
||||
|
||||
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
|
||||
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
const icon = getForgePresentation(normalizeForge(forge)).icon;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hovered ? (
|
||||
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
||||
) : (
|
||||
renderChecksSummaryForgeIcon(icon, iconUniProps)
|
||||
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
|
||||
)}
|
||||
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
@@ -529,11 +518,9 @@ function ChecksSummaryContent({
|
||||
|
||||
function ChecksSummaryPressable({
|
||||
checks,
|
||||
forge,
|
||||
url,
|
||||
}: {
|
||||
checks: NonNullable<PrHint["checks"]>;
|
||||
forge: PrHint["forge"];
|
||||
url: string;
|
||||
}) {
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -542,9 +529,9 @@ function ChecksSummaryPressable({
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
|
||||
<ChecksSummaryContent checks={checks} forge={forge} hovered={Boolean(hovered)} />
|
||||
<ChecksSummaryContent checks={checks} hovered={Boolean(hovered)} />
|
||||
),
|
||||
[checks, forge],
|
||||
[checks],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,10 +18,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
splitComposerAttachmentsForSubmit,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
DaemonClient,
|
||||
@@ -174,9 +171,6 @@ export function WorkspaceSetupDialog() {
|
||||
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
|
||||
|
||||
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
||||
const supportsForgeSearch = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
||||
);
|
||||
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
|
||||
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
|
||||
const workspace = createdWorkspace;
|
||||
@@ -315,11 +309,7 @@ export function WorkspaceSetupDialog() {
|
||||
throw new Error(t("workspaceSetup.errors.selectModel"));
|
||||
}
|
||||
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
});
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
const encodedImages = await encodeImages(wirePayload.images);
|
||||
const workspaceDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
@@ -373,7 +363,6 @@ export function WorkspaceSetupDialog() {
|
||||
t,
|
||||
toast,
|
||||
withConnectedClient,
|
||||
supportsForgeSearch,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
@@ -38,7 +38,7 @@ const imageMetadata: AttachmentMetadata = {
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const issueItem: ForgeSearchItem = {
|
||||
const issueItem: GitHubSearchItem = {
|
||||
kind: "issue",
|
||||
number: 101,
|
||||
title: "Fix composer attachments",
|
||||
@@ -50,8 +50,8 @@ const issueItem: ForgeSearchItem = {
|
||||
headRefName: null,
|
||||
};
|
||||
|
||||
const prItem: ForgeSearchItem = {
|
||||
kind: "change_request",
|
||||
const prItem: GitHubSearchItem = {
|
||||
kind: "pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
@@ -357,9 +357,8 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
|
||||
expect(call.options.attachments).toEqual([
|
||||
{
|
||||
type: "forge_change_request",
|
||||
mimeType: "application/paseo-forge-change-request",
|
||||
forge: "github",
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
@@ -381,34 +380,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
expect(userMessage.optimistic).toBe(true);
|
||||
});
|
||||
|
||||
it("can send legacy GitHub attachment payloads for old daemons", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "send old attachment",
|
||||
attachments: [{ kind: "forge_change_request", item: prItem }],
|
||||
attachmentSubmitFormat: "legacy-github",
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0].options.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
body: "PR body",
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends to the existing head when one is present", async () => {
|
||||
const existingItem: StreamItem = {
|
||||
kind: "user_message",
|
||||
@@ -723,12 +694,12 @@ describe("openComposerAttachment", () => {
|
||||
describe("toggleGithubAttachment", () => {
|
||||
it("appends a GitHub issue when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], issueItem);
|
||||
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
|
||||
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||
});
|
||||
|
||||
it("appends a GitHub PR when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], prItem);
|
||||
expect(next).toEqual([{ kind: "forge_change_request", item: prItem }]);
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
});
|
||||
|
||||
it("removes an existing GitHub item with the same kind+number", () => {
|
||||
@@ -741,12 +712,12 @@ describe("toggleGithubAttachment", () => {
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
];
|
||||
const otherIssue: ForgeSearchItem = { ...issueItem, number: 999 };
|
||||
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
|
||||
const next = toggleGithubAttachment(start, otherIssue);
|
||||
expect(next).toEqual([
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
{ kind: "forge_issue", item: otherIssue },
|
||||
{ kind: "github_issue", item: otherIssue },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -776,7 +747,7 @@ describe("toggleGithubAttachmentFromPicker", () => {
|
||||
markGithubAttachmentRemoved,
|
||||
});
|
||||
|
||||
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
|
||||
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -784,8 +755,8 @@ describe("toggleGithubAttachmentFromPicker", () => {
|
||||
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||
it("locates items via their composite kind:number id", () => {
|
||||
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||
expect(findGithubItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem);
|
||||
expect(findGithubItemByOption([issueItem], "change_request:404")).toBeUndefined();
|
||||
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
|
||||
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import {
|
||||
splitComposerAttachmentsForSubmit,
|
||||
type ComposerAttachmentSubmitFormat,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import {
|
||||
appendOptimisticUserMessageToStream,
|
||||
buildOptimisticUserMessage,
|
||||
@@ -165,7 +162,6 @@ export interface DispatchComposerAgentMessageInput {
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
|
||||
encodeImages: (
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
@@ -175,9 +171,7 @@ export interface DispatchComposerAgentMessageInput {
|
||||
export async function dispatchComposerAgentMessage(
|
||||
input: DispatchComposerAgentMessageInput,
|
||||
): Promise<void> {
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
|
||||
format: input.attachmentSubmitFormat,
|
||||
});
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
|
||||
const messageId = generateMessageId();
|
||||
const userMessage = buildOptimisticUserMessage({
|
||||
id: messageId,
|
||||
@@ -348,44 +342,33 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
|
||||
input.openExternalUrl(input.attachment.item.url);
|
||||
}
|
||||
|
||||
export function buildForgeAttachment(item: ForgeSearchItem): UserComposerAttachment {
|
||||
return item.kind === "change_request"
|
||||
? { kind: "forge_change_request", item }
|
||||
: { kind: "forge_issue", item };
|
||||
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
function isForgeAttachment(
|
||||
function isGithubAttachment(
|
||||
attachment: UserComposerAttachment,
|
||||
): attachment is Extract<
|
||||
UserComposerAttachment,
|
||||
{ kind: "forge_issue" | "forge_change_request" | "github_issue" | "github_pr" }
|
||||
> {
|
||||
return (
|
||||
attachment.kind === "forge_issue" ||
|
||||
attachment.kind === "forge_change_request" ||
|
||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||
attachment.kind === "github_issue" ||
|
||||
attachment.kind === "github_pr"
|
||||
);
|
||||
): attachment is Extract<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
|
||||
return attachment.kind === "github_issue" || attachment.kind === "github_pr";
|
||||
}
|
||||
|
||||
export function toggleForgeAttachment(
|
||||
export function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: ForgeSearchItem,
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
isForgeAttachment(attachment) &&
|
||||
isGithubAttachment(attachment) &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((attachment) => !matches(attachment));
|
||||
}
|
||||
return [...current, buildForgeAttachment(item)];
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
interface ToggleGithubAttachmentFromPickerInput {
|
||||
current: UserComposerAttachment[];
|
||||
item: ForgeSearchItem;
|
||||
item: GitHubSearchItem;
|
||||
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
|
||||
}
|
||||
|
||||
@@ -396,33 +379,31 @@ export function toggleGithubAttachmentFromPicker({
|
||||
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
|
||||
const existingAttachment = current.find(
|
||||
(attachment) =>
|
||||
isForgeAttachment(attachment) &&
|
||||
isGithubAttachment(attachment) &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
if (existingAttachment) {
|
||||
markGithubAttachmentRemoved(existingAttachment);
|
||||
}
|
||||
return toggleForgeAttachment(current, item);
|
||||
return toggleGithubAttachment(current, item);
|
||||
}
|
||||
|
||||
export function findGithubItemByOption(
|
||||
items: readonly ForgeSearchItem[],
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
): ForgeSearchItem | undefined {
|
||||
): GitHubSearchItem | undefined {
|
||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||
}
|
||||
|
||||
export function isAttachmentSelectedForGithubItem(
|
||||
current: readonly ComposerAttachment[],
|
||||
item: ForgeSearchItem,
|
||||
item: GitHubSearchItem,
|
||||
): boolean {
|
||||
return userAttachmentsOnly(current).some(
|
||||
(attachment) =>
|
||||
isForgeAttachment(attachment) &&
|
||||
isGithubAttachment(attachment) &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
}
|
||||
|
||||
export const toggleGithubAttachment = toggleForgeAttachment;
|
||||
|
||||
@@ -5,38 +5,14 @@ import {
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
buildForgeAttachmentFromSearchItem,
|
||||
buildLegacyGitHubAttachmentFromSearchItem,
|
||||
} from "@/utils/review-attachments";
|
||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||
|
||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||
|
||||
interface SplitComposerAttachmentsOptions {
|
||||
format?: ComposerAttachmentSubmitFormat;
|
||||
}
|
||||
|
||||
export function resolveComposerAttachmentSubmitFormat(input: {
|
||||
supportsForgeAttachments?: boolean;
|
||||
}): ComposerAttachmentSubmitFormat {
|
||||
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
|
||||
return input.supportsForgeAttachments === false ? "legacy-github" : "forge";
|
||||
}
|
||||
|
||||
export function splitComposerAttachmentsForSubmit(
|
||||
attachments: ComposerAttachment[],
|
||||
options: SplitComposerAttachmentsOptions = {},
|
||||
): {
|
||||
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
|
||||
images: ImageAttachment[];
|
||||
attachments: AgentAttachment[];
|
||||
} {
|
||||
const images: ImageAttachment[] = [];
|
||||
const agentAttachments: AgentAttachment[] = [];
|
||||
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
|
||||
const buildSearchAttachment =
|
||||
options.format === "legacy-github"
|
||||
? buildLegacyGitHubAttachmentFromSearchItem
|
||||
: buildForgeAttachmentFromSearchItem;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.kind === "image") {
|
||||
@@ -60,7 +36,7 @@ export function splitComposerAttachmentsForSubmit(
|
||||
continue;
|
||||
}
|
||||
|
||||
const reviewAttachment = buildSearchAttachment(attachment.item);
|
||||
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
|
||||
if (reviewAttachment) {
|
||||
agentAttachments.push(reviewAttachment);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
splitComposerAttachmentsForSubmit,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -239,23 +236,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}
|
||||
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||
const trimmedPrompt = text.trim();
|
||||
const pendingServerId = getPendingServerId();
|
||||
if (!pendingServerId) {
|
||||
const error = new Error(t("composer.errors.noHostSelected"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
const supportsForgeSearch =
|
||||
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
|
||||
true;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
});
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
const images = wirePayload.images;
|
||||
|
||||
const trimmedPrompt = text.trim();
|
||||
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
|
||||
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
|
||||
const error = new Error(t("composer.errors.initialPromptRequired"));
|
||||
@@ -274,6 +258,13 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pendingServerId = getPendingServerId();
|
||||
if (!pendingServerId) {
|
||||
const error = new Error(t("composer.errors.noHostSelected"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const attempt: CreateAttempt = {
|
||||
clientMessageId: generateMessageId(),
|
||||
text: trimmedPrompt,
|
||||
|
||||
@@ -7,17 +7,17 @@ import React, { type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
|
||||
import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages";
|
||||
import type { GitHubSearchClient } from "@/git/use-github-search-query";
|
||||
import type { GitHubSearchItem, GitHubSearchResponse } from "@getpaseo/protocol/messages";
|
||||
import { useComposerGithubAutoAttach } from "./auto-attach";
|
||||
|
||||
type ForgeSearchPayload = ForgeSearchResponse["payload"];
|
||||
type GitHubSearchPayload = GitHubSearchResponse["payload"];
|
||||
|
||||
const remoteUrl = "git@github.com:acme/paseo.git";
|
||||
const cwd = "/repo";
|
||||
|
||||
const pr101: ForgeSearchItem = {
|
||||
kind: "change_request",
|
||||
const pr101: GitHubSearchItem = {
|
||||
kind: "pr",
|
||||
number: 101,
|
||||
title: "Attach PR",
|
||||
url: "https://github.com/acme/paseo/pull/101",
|
||||
@@ -28,7 +28,7 @@ const pr101: ForgeSearchItem = {
|
||||
headRefName: "feature",
|
||||
};
|
||||
|
||||
const issue202: ForgeSearchItem = {
|
||||
const issue202: GitHubSearchItem = {
|
||||
kind: "issue",
|
||||
number: 202,
|
||||
title: "Attach issue",
|
||||
@@ -52,20 +52,22 @@ interface HarnessInput {
|
||||
remote?: string | null;
|
||||
}
|
||||
|
||||
function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearchPayload {
|
||||
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
|
||||
return {
|
||||
items,
|
||||
authState: "authenticated",
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } {
|
||||
function createSearchClient(
|
||||
items: GitHubSearchItem[],
|
||||
): GitHubSearchClient & { calls: SearchCall[] } {
|
||||
const calls: SearchCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
async searchForge(options) {
|
||||
async searchGitHub(options) {
|
||||
calls.push(options);
|
||||
return githubPayload(items, `search-${options.query}`);
|
||||
},
|
||||
@@ -85,7 +87,7 @@ function createWrapper() {
|
||||
};
|
||||
}
|
||||
|
||||
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
||||
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
|
||||
const [text, setText] = useState(input.initialText ?? "");
|
||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||
input.initialAttachments ?? [],
|
||||
@@ -128,7 +130,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
|
||||
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -199,8 +201,8 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([
|
||||
{ kind: "forge_change_request", item: pr101 },
|
||||
{ kind: "forge_issue", item: issue202 },
|
||||
{ kind: "github_pr", item: pr101 },
|
||||
{ kind: "github_issue", item: issue202 },
|
||||
]);
|
||||
expect(client.calls).toEqual([
|
||||
{ cwd, query: "101", limit: 20 },
|
||||
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
|
||||
import {
|
||||
buildGithubSearchQueryOptions,
|
||||
type GitHubSearchClient,
|
||||
} from "@/git/use-github-search-query";
|
||||
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
|
||||
|
||||
const AUTO_ATTACH_DEBOUNCE_MS = 300;
|
||||
@@ -20,11 +23,10 @@ interface ComposerGithubAutoAttachInput {
|
||||
text: string;
|
||||
remoteUrl: string | null | undefined;
|
||||
attachments: UserComposerAttachment[];
|
||||
client: ForgeSearchClient | null;
|
||||
client: GitHubSearchClient | null;
|
||||
isConnected: boolean;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
supportsForgeSearch?: boolean;
|
||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||
}
|
||||
|
||||
@@ -73,7 +75,6 @@ export function useComposerGithubAutoAttach(
|
||||
params.isConnected,
|
||||
params.serverId,
|
||||
params.cwd,
|
||||
params.supportsForgeSearch,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
@@ -192,12 +193,11 @@ async function fetchGithubRefSearch({
|
||||
|
||||
try {
|
||||
return await queryClient.fetchQuery(
|
||||
buildForgeSearchQueryOptions({
|
||||
buildGithubSearchQueryOptions({
|
||||
client: snapshot.client,
|
||||
serverId: snapshot.serverId,
|
||||
cwd: snapshot.cwd,
|
||||
query: String(ref.number),
|
||||
supportsForgeSearch: snapshot.supportsForgeSearch,
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
@@ -216,12 +216,12 @@ function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubR
|
||||
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||
}
|
||||
|
||||
function githubItemMatchesRef(item: ForgeSearchItem, ref: GithubRef): boolean {
|
||||
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
|
||||
return item.kind === githubItemKind(ref) && item.number === ref.number;
|
||||
}
|
||||
|
||||
function githubItemKind(ref: GithubRef): ForgeSearchItem["kind"] {
|
||||
return ref.kind === "pull" ? "change_request" : "issue";
|
||||
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
|
||||
return ref.kind === "pull" ? "pr" : "issue";
|
||||
}
|
||||
|
||||
function githubRefKey(ref: GithubRef): string {
|
||||
@@ -232,10 +232,7 @@ function attachmentKey(attachment: ComposerAttachment | undefined): string | nul
|
||||
if (
|
||||
!attachment ||
|
||||
attachment.kind === "image" ||
|
||||
(attachment.kind !== "forge_change_request" &&
|
||||
attachment.kind !== "forge_issue" &&
|
||||
attachment.kind !== "github_pr" &&
|
||||
attachment.kind !== "github_issue")
|
||||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Github,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
} from "lucide-react-native";
|
||||
@@ -93,7 +94,7 @@ import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
@@ -101,7 +102,6 @@ import type {
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { PickedFile } from "@/attachments/picked-file";
|
||||
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
|
||||
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
|
||||
@@ -111,11 +111,8 @@ import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/componen
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
|
||||
import { useGithubSearchQuery } from "@/git/use-github-search-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { getForgePresentation } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
|
||||
@@ -284,8 +281,8 @@ interface RenderAttachmentTrayArgs {
|
||||
openImage: string;
|
||||
removeImage: string;
|
||||
removeFile: string;
|
||||
openGithub: (kind: string, numberLabel: string) => string;
|
||||
removeGithub: (kind: string, numberLabel: string) => string;
|
||||
openGithub: (kind: string, number: number) => string;
|
||||
removeGithub: (kind: string, number: number) => string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -619,16 +616,13 @@ function ImageAttachmentPill({
|
||||
}
|
||||
|
||||
interface GithubAttachmentPillProps {
|
||||
attachment: Extract<
|
||||
ComposerAttachment,
|
||||
{ kind: "forge_change_request" | "forge_issue" | "github_pr" | "github_issue" }
|
||||
>;
|
||||
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
openLabel: (kind: string, numberLabel: string) => string;
|
||||
removeLabel: (kind: string, numberLabel: string) => string;
|
||||
openLabel: (kind: string, number: number) => string;
|
||||
removeLabel: (kind: string, number: number) => string;
|
||||
}
|
||||
|
||||
function GithubAttachmentPill({
|
||||
@@ -641,11 +635,7 @@ function GithubAttachmentPill({
|
||||
removeLabel,
|
||||
}: GithubAttachmentPillProps) {
|
||||
const item = attachment.item;
|
||||
const presentation = getForgePresentation(item.forge ?? "github");
|
||||
const isChangeRequest = item.kind === "change_request";
|
||||
const kindLabel = isChangeRequest ? presentation.changeRequestAbbrev : "issue";
|
||||
const subtitleKind = isChangeRequest ? presentation.changeRequestAbbrev : "Issue";
|
||||
const numberPrefix = isChangeRequest ? presentation.numberPrefix : presentation.issueNumberPrefix;
|
||||
const kindLabel = item.kind === "pr" ? "PR" : "issue";
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
}, [onOpen, attachment]);
|
||||
@@ -657,14 +647,14 @@ function GithubAttachmentPill({
|
||||
testID="composer-github-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={openLabel(kindLabel, `${numberPrefix}${item.number}`)}
|
||||
removeAccessibilityLabel={removeLabel(kindLabel, `${numberPrefix}${item.number}`)}
|
||||
openAccessibilityLabel={openLabel(kindLabel, item.number)}
|
||||
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={isChangeRequest ? githubPrPillIcon : githubIssuePillIcon}
|
||||
icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
|
||||
title={item.title}
|
||||
subtitle={`${subtitleKind} ${numberPrefix}${item.number}`}
|
||||
subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
|
||||
/>
|
||||
</AttachmentPill>
|
||||
);
|
||||
@@ -713,8 +703,8 @@ interface GithubPickerOptionProps {
|
||||
testID: string;
|
||||
active: boolean;
|
||||
selected: boolean;
|
||||
item: ForgeSearchItem;
|
||||
onToggle: (item: ForgeSearchItem) => void;
|
||||
item: GitHubSearchItem;
|
||||
onToggle: (item: GitHubSearchItem) => void;
|
||||
}
|
||||
|
||||
function GithubPickerOption({
|
||||
@@ -730,7 +720,7 @@ function GithubPickerOption({
|
||||
}, [onToggle, item]);
|
||||
const leadingSlot = useMemo(
|
||||
() =>
|
||||
item.kind === "change_request" ? (
|
||||
item.kind === "pr" ? (
|
||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
) : (
|
||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
@@ -1055,9 +1045,6 @@ export function Composer({
|
||||
});
|
||||
const setSelectedAttachments = onChangeAttachments;
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const supportsForgeSearch = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
||||
);
|
||||
const githubAutoAttach = useComposerGithubAutoAttach({
|
||||
text: userInput,
|
||||
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
|
||||
@@ -1066,7 +1053,6 @@ export function Composer({
|
||||
isConnected,
|
||||
serverId,
|
||||
cwd,
|
||||
supportsForgeSearch,
|
||||
setAttachments: setSelectedAttachments,
|
||||
});
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
@@ -1226,23 +1212,12 @@ export function Composer({
|
||||
agentId: targetAgentId,
|
||||
text,
|
||||
attachments: sendAttachments,
|
||||
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
encodeImages,
|
||||
stream,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
onAttentionPromptSend,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
]);
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
@@ -1736,33 +1711,12 @@ export function Composer({
|
||||
[contextWindowMeter, isCompactLayout],
|
||||
);
|
||||
|
||||
const hasGithubAttachment = useMemo(
|
||||
() =>
|
||||
selectedAttachments.some(
|
||||
(attachment) =>
|
||||
attachment.kind === "forge_change_request" ||
|
||||
attachment.kind === "forge_issue" ||
|
||||
attachment.kind === "github_pr" ||
|
||||
attachment.kind === "github_issue",
|
||||
),
|
||||
[selectedAttachments],
|
||||
);
|
||||
// Composer stays mounted for each focused agent, so avoid a forge CLI call
|
||||
// until the forge-specific picker or attachment presentation is visible.
|
||||
const { forge } = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
|
||||
});
|
||||
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
|
||||
|
||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||
const githubSearchResultsQuery = useForgeSearchQuery({
|
||||
const githubSearchResultsQuery = useGithubSearchQuery({
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
query: githubSearchQueryTrimmed,
|
||||
supportsForgeSearch,
|
||||
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
|
||||
});
|
||||
|
||||
@@ -1770,18 +1724,11 @@ export function Composer({
|
||||
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
|
||||
const githubSearchOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
githubSearchItems.map((item) => {
|
||||
const presentation = getForgePresentation(item.forge ?? "github");
|
||||
const numberPrefix =
|
||||
item.kind === "change_request"
|
||||
? presentation.numberPrefix
|
||||
: presentation.issueNumberPrefix;
|
||||
return {
|
||||
id: `${item.kind}:${item.number}`,
|
||||
label: `${numberPrefix}${item.number} ${item.title}`,
|
||||
description: githubSearchQueryTrimmed,
|
||||
};
|
||||
}),
|
||||
githubSearchItems.map((item) => ({
|
||||
id: `${item.kind}:${item.number}`,
|
||||
label: `#${item.number} ${item.title}`,
|
||||
description: githubSearchQueryTrimmed,
|
||||
})),
|
||||
[githubSearchItems, githubSearchQueryTrimmed],
|
||||
);
|
||||
|
||||
@@ -1797,10 +1744,8 @@ export function Composer({
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
label: t("composer.attachments.addIssueOrPr", {
|
||||
context: forgePresentation.changeRequestContext,
|
||||
}),
|
||||
icon: renderForgeAttachmentIcon(forgePresentation.icon),
|
||||
label: t("composer.attachments.addIssueOrPr"),
|
||||
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
||||
onSelect: () => {
|
||||
setIsGithubPickerOpen(true);
|
||||
},
|
||||
@@ -1814,11 +1759,11 @@ export function Composer({
|
||||
},
|
||||
},
|
||||
],
|
||||
[handlePickImage, handlePickFile, t, forgePresentation],
|
||||
[handlePickImage, handlePickFile, t],
|
||||
);
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
(item: ForgeSearchItem) => {
|
||||
(item: GitHubSearchItem) => {
|
||||
const nextAttachments = toggleGithubAttachmentFromPicker({
|
||||
current: attachments,
|
||||
item,
|
||||
@@ -1923,10 +1868,10 @@ export function Composer({
|
||||
openImage: t("composer.attachments.openImage"),
|
||||
removeImage: t("composer.attachments.removeImage"),
|
||||
removeFile: t("composer.attachments.removeFile"),
|
||||
openGithub: (kind: string, numberLabel: string) =>
|
||||
t("composer.attachments.openGithub", { kind, number: numberLabel }),
|
||||
removeGithub: (kind: string, numberLabel: string) =>
|
||||
t("composer.attachments.removeGithub", { kind, number: numberLabel }),
|
||||
openGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.openGithub", { kind, number }),
|
||||
removeGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.removeGithub", { kind, number }),
|
||||
},
|
||||
}),
|
||||
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
|
||||
@@ -2038,12 +1983,8 @@ export function Composer({
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder", {
|
||||
context: forgePresentation.changeRequestContext,
|
||||
})}
|
||||
title={t("composer.github.title", {
|
||||
context: forgePresentation.changeRequestContext,
|
||||
})}
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
@@ -2244,16 +2185,12 @@ const ThemedAudioLines = withUnistyles(AudioLines);
|
||||
const ThemedPaperclip = withUnistyles(Paperclip);
|
||||
const ThemedImageIcon = withUnistyles(ImageIcon);
|
||||
const ThemedFileText = withUnistyles(FileText);
|
||||
const ThemedGithub = withUnistyles(Github);
|
||||
|
||||
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||
|
||||
function renderForgeAttachmentIcon(icon: string): ReactElement {
|
||||
return (
|
||||
<ForgeBrandIcon iconKind={icon} size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
}
|
||||
|
||||
const githubPrPillIcon = (
|
||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
|
||||
@@ -62,10 +62,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
splitComposerAttachmentsForSubmit,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import {
|
||||
@@ -731,14 +728,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (queue && queue.length > 0) {
|
||||
const [next, ...rest] = queue;
|
||||
if (sendAgentMessageRef.current) {
|
||||
const supportsForgeSearch =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
|
||||
true;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
});
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments);
|
||||
void sendAgentMessageRef.current(
|
||||
agent.id,
|
||||
next.text,
|
||||
|
||||
@@ -59,10 +59,10 @@ const CATALOG_DATA = [
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.44",
|
||||
version: "3.0.39",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.44", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.39", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
@@ -122,10 +122,10 @@ const CATALOG_DATA = [
|
||||
id: "deepagents",
|
||||
title: "DeepAgents",
|
||||
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
||||
version: "0.1.19",
|
||||
version: "0.1.17",
|
||||
iconId: "deepagents",
|
||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.19"],
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.17"],
|
||||
},
|
||||
{
|
||||
id: "devin",
|
||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.31",
|
||||
version: "0.2.27",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.31", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.27", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.4.17",
|
||||
version: "0.4.15",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.17", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.15", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.174.0",
|
||||
version: "0.170.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.174.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.170.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -173,19 +173,19 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.9.14",
|
||||
version: "0.9.7",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.14", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.7", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
title: "Gemini CLI",
|
||||
description: "Google's official CLI for Gemini",
|
||||
version: "0.51.0",
|
||||
version: "0.50.0",
|
||||
iconId: "gemini",
|
||||
installLink: "https://geminicli.com",
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.51.0", "--acp"],
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.50.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "glm-acp-agent",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.27",
|
||||
version: "1.1.25",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.27", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.25", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.0.48",
|
||||
version: "1.0.43",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.48", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.43", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.19.11",
|
||||
version: "0.19.9",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.11", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.9", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getElectronHost } from "@/desktop/electron/host";
|
||||
import type { BrowserKeyboardPolicy } from "@/keyboard/browser-shortcuts";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
@@ -96,7 +95,6 @@ export interface DesktopWindowControlsOverlayUpdate {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
trafficLightOffsetY?: number;
|
||||
}
|
||||
|
||||
export interface DesktopWindowBridge {
|
||||
@@ -123,9 +121,10 @@ export interface DesktopEventsBridge {
|
||||
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
|
||||
}
|
||||
|
||||
export type DesktopBrowserShortcutEvent =
|
||||
| { browserId?: string; action: "focus-url" }
|
||||
| { browserId: string; action: "new-tab" };
|
||||
export interface DesktopBrowserShortcutEvent {
|
||||
browserId?: string;
|
||||
action: "focus-url";
|
||||
}
|
||||
|
||||
export interface DesktopBrowserNewTabRequestEvent {
|
||||
sourceBrowserId: string;
|
||||
@@ -139,7 +138,6 @@ export interface DesktopAttachedBrowserRegistration {
|
||||
}
|
||||
|
||||
export interface DesktopBrowserBridge {
|
||||
setShortcutPolicy?: (input: BrowserKeyboardPolicy) => Promise<void>;
|
||||
readonly profilePartition?: string;
|
||||
registerAttachedBrowser?: (input: DesktopAttachedBrowserRegistration) => Promise<void>;
|
||||
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;
|
||||
|
||||
@@ -183,77 +183,66 @@ describe("checkout-git-actions-store", () => {
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
for (const rpc of [
|
||||
{
|
||||
label: "forge",
|
||||
method: "checkoutForgeSetAutoMerge",
|
||||
feature: "checkoutForgeSetAutoMerge",
|
||||
},
|
||||
{
|
||||
label: "legacy GitHub",
|
||||
method: "checkoutGithubSetAutoMerge",
|
||||
feature: "checkoutGithubSetAutoMerge",
|
||||
},
|
||||
] as const) {
|
||||
it(`enables PR auto-merge through the ${rpc.label} RPC`, async () => {
|
||||
const setAutoMerge = vi.fn(async () => ({
|
||||
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
}));
|
||||
const client = { [rpc.method]: setAutoMerge };
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { [rpc.feature]: true },
|
||||
});
|
||||
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
|
||||
|
||||
expect(setAutoMerge).toHaveBeenCalledWith(cwd, {
|
||||
enabled: true,
|
||||
method: "squash",
|
||||
});
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
||||
).toBe("success");
|
||||
})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { checkoutGithubSetAutoMerge: true },
|
||||
});
|
||||
|
||||
it(`disables PR auto-merge through the ${rpc.label} RPC`, async () => {
|
||||
const setAutoMerge = vi.fn(async () => ({
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
|
||||
|
||||
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
|
||||
enabled: true,
|
||||
method: "squash",
|
||||
});
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: false,
|
||||
success: true,
|
||||
error: null,
|
||||
}));
|
||||
const client = { [rpc.method]: setAutoMerge };
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { [rpc.feature]: true },
|
||||
});
|
||||
|
||||
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
|
||||
|
||||
expect(setAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||
).toBe("success");
|
||||
})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { checkoutGithubSetAutoMerge: true },
|
||||
});
|
||||
}
|
||||
|
||||
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
|
||||
|
||||
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
|
||||
const client = {
|
||||
checkoutForgeSetAutoMerge: vi.fn(async () => ({
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
@@ -269,9 +258,9 @@ describe("checkout-git-actions-store", () => {
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
|
||||
).rejects.toThrow("Update the host to use auto-merge actions.");
|
||||
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
|
||||
|
||||
expect(client.checkoutForgeSetAutoMerge).not.toHaveBeenCalled();
|
||||
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
|
||||
@@ -42,19 +42,11 @@ function resolveClient(serverId: string) {
|
||||
return client;
|
||||
}
|
||||
|
||||
type AutoMergeActionsRpc = "forge" | "github";
|
||||
|
||||
function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc {
|
||||
function assertGitHubAutoMergeActionsSupported(serverId: string) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) {
|
||||
return "forge";
|
||||
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
|
||||
throw new Error("Update the host to use GitHub auto-merge actions.");
|
||||
}
|
||||
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) {
|
||||
return "github";
|
||||
}
|
||||
throw new Error("Update the host to use auto-merge actions.");
|
||||
}
|
||||
|
||||
function setStatus(
|
||||
@@ -289,19 +281,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
},
|
||||
|
||||
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
|
||||
const rpc = resolveAutoMergeActionsRpc(serverId);
|
||||
assertGitHubAutoMergeActionsSupported(serverId);
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: `enable-pr-auto-merge-${method}`,
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||
const payload =
|
||||
rpc === "forge"
|
||||
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method })
|
||||
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
|
||||
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
@@ -310,19 +297,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
},
|
||||
|
||||
disablePrAutoMerge: async ({ serverId, cwd }) => {
|
||||
const rpc = resolveAutoMergeActionsRpc(serverId);
|
||||
assertGitHubAutoMergeActionsSupported(serverId);
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "disable-pr-auto-merge",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||
const payload =
|
||||
rpc === "forge"
|
||||
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false })
|
||||
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
|
||||
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
// The review draft store persists through AsyncStorage's web shim, which needs window.
|
||||
import "@/test/window-local-storage";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
||||
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import {
|
||||
prPanePipelineQueryKey,
|
||||
prPaneTimelineQueryKey,
|
||||
} from "@/git/pull-request-panel/query-keys";
|
||||
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
|
||||
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
@@ -15,14 +14,6 @@ import {
|
||||
fetchCheckoutStatus,
|
||||
} from "./checkout-status-cache";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
removeItem: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const serverId = "server-1";
|
||||
const cwd = "/repo";
|
||||
|
||||
@@ -50,7 +41,6 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
|
||||
return {
|
||||
cwd,
|
||||
status: {
|
||||
forge: "github",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "My PR",
|
||||
state: "open",
|
||||
@@ -64,8 +54,6 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
|
||||
reviewDecision: null,
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
authState: "authenticated",
|
||||
forge: "github",
|
||||
error: null,
|
||||
requestId: "pr-status-1",
|
||||
...overrides,
|
||||
@@ -74,7 +62,7 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
|
||||
|
||||
function checkoutStatusUpdate(
|
||||
payload: CheckoutStatusPayload,
|
||||
extraPrStatus?: NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>,
|
||||
extraPrStatus?: CheckoutPrStatusPayload,
|
||||
): CheckoutStatusUpdate {
|
||||
return {
|
||||
type: "checkout_status_update",
|
||||
@@ -152,29 +140,6 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes legacy PR auth state at the pushed-cache boundary", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const { authState: _authState, ...legacyPrStatus } = prStatus({
|
||||
githubFeaturesEnabled: false,
|
||||
});
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus(), legacyPrStatus),
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<CheckoutPrStatusPayload>(checkoutPrStatusQueryKey(serverId, cwd))
|
||||
?.authState,
|
||||
).toBe("unauthenticated");
|
||||
expect(
|
||||
queryClient.getQueryData<CheckoutStatusUpdate["payload"]>(
|
||||
checkoutStatusQueryKey(serverId, cwd),
|
||||
)?.prStatus?.authState,
|
||||
).toBe("unauthenticated");
|
||||
});
|
||||
|
||||
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
|
||||
const queryClient = createQueryClient();
|
||||
setDiffModeOverride(false);
|
||||
@@ -201,21 +166,14 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
|
||||
it("invalidates the PR timeline when the prStatus changes, ignoring the volatile requestId", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(
|
||||
checkoutPrStatusQueryKey(serverId, cwd),
|
||||
prStatus({ requestId: "pr-v1" }),
|
||||
);
|
||||
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
|
||||
const pipelineKey = prPanePipelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
pipelineId: 9001,
|
||||
changeRequestNumber: 1,
|
||||
});
|
||||
queryClient.setQueryData(timelineKey, { items: [] });
|
||||
queryClient.setQueryData(pipelineKey, { stages: [] });
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
@@ -223,7 +181,6 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })),
|
||||
});
|
||||
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(false);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
@@ -237,29 +194,14 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
),
|
||||
});
|
||||
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates PR detail queries on the first prStatus emission, scoped to its cwd", () => {
|
||||
it("invalidates the PR timeline on the first prStatus emission, scoped to its cwd", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
|
||||
const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 });
|
||||
const pipelineKey = prPanePipelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
pipelineId: 9001,
|
||||
changeRequestNumber: 1,
|
||||
});
|
||||
const otherPipelineKey = prPanePipelineQueryKey({
|
||||
serverId,
|
||||
cwd: "/repo2",
|
||||
pipelineId: 9001,
|
||||
changeRequestNumber: 1,
|
||||
});
|
||||
queryClient.setQueryData(timelineKey, { items: [] });
|
||||
queryClient.setQueryData(otherTimelineKey, { items: [] });
|
||||
queryClient.setQueryData(pipelineKey, { stages: [] });
|
||||
queryClient.setQueryData(otherPipelineKey, { stages: [] });
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
@@ -269,7 +211,5 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
|
||||
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(otherPipelineKey)?.isInvalidated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
checkoutStatusQueryKey,
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { expireStaleDiffModeOverrides } from "@/review/store";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
export type CheckoutPrStatusPayload = NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>;
|
||||
|
||||
export interface CheckoutStatusClient {
|
||||
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
|
||||
@@ -44,17 +43,14 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
message: CheckoutStatusUpdate;
|
||||
}): void {
|
||||
const { payload } = message;
|
||||
const prStatus = payload.prStatus
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), payload);
|
||||
expireStaleDiffModeOverrides({
|
||||
serverId,
|
||||
cwd: payload.cwd,
|
||||
isDirty: payload.isGit && payload.isDirty,
|
||||
});
|
||||
|
||||
const prStatus = payload.prStatus;
|
||||
if (!prStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { z } from "zod";
|
||||
import type { CheckoutPrMergeMethod, CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { Forge } from "@/git/forge";
|
||||
import type { PrPaneCheck } from "@/git/pull-request-panel/data";
|
||||
|
||||
export interface ForgeIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export type ForgeIconComponent = ComponentType<ForgeIconProps>;
|
||||
|
||||
export interface ForgeBrandColor {
|
||||
light: string;
|
||||
dark: string;
|
||||
}
|
||||
|
||||
export type ForgeIconColorMapping = (theme: Theme) => { color: string };
|
||||
|
||||
export interface ForgeUrlGrammar {
|
||||
/** Path infix before the branch in a tree URL, e.g. "/tree/", "/-/tree/". */
|
||||
treeInfix: string;
|
||||
/** Path infix before the branch in a blob URL, e.g. "/blob/", "/-/blob/". */
|
||||
blobInfix: string;
|
||||
/** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */
|
||||
lineAnchor: (start: number, end?: number) => string;
|
||||
}
|
||||
|
||||
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
|
||||
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
|
||||
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
|
||||
}
|
||||
|
||||
/** URL grammar shared by the gitea-family forges (gitea, forgejo, codeberg). */
|
||||
export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
|
||||
treeInfix: "/src/branch/",
|
||||
blobInfix: "/src/branch/",
|
||||
lineAnchor: GITHUB_LINE_ANCHOR,
|
||||
};
|
||||
|
||||
export type ForgeSpecificEnvelope = { forge: string } & Record<string, unknown>;
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
|
||||
export type LegacyGithubMergeFacts = NonNullable<CheckoutPrStatus["github"]>;
|
||||
|
||||
export interface MergeCapability {
|
||||
/** The change request can be merged directly right now. */
|
||||
directMergeReady: boolean;
|
||||
/** Auto-merge can be enabled right now. */
|
||||
canEnableAutoMerge: boolean;
|
||||
/** Auto-merge is already enabled on the change request. */
|
||||
autoMergeEnabled: boolean;
|
||||
/** The viewer is allowed to disable the active auto-merge. */
|
||||
canDisableAutoMerge: boolean;
|
||||
/** A merge queue is blocking both direct merge and auto-merge. */
|
||||
mergeBlockedByQueue: boolean;
|
||||
/** Merge methods the forge permits for this change request. */
|
||||
allowedMethods: CheckoutPrMergeMethod[];
|
||||
/** The forge's preferred/default merge method, if it reports one. */
|
||||
preferredMethod: CheckoutPrMergeMethod | null;
|
||||
}
|
||||
|
||||
export interface PaneChecksSlotContext {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
/** Change request (PR/MR) number, so a section can address its head pipeline. */
|
||||
changeRequestNumber: number;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
/** Whether the daemon advertises pluggable forge support (gates rich sections). */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Whether the daemon can serve forge check/pipeline details over the
|
||||
* forge-routed RPC. Single source of truth for gating on-demand detail
|
||||
* fetches so a section never reaches an RPC the daemon lacks.
|
||||
*/
|
||||
canFetchCheckDetails: boolean;
|
||||
}
|
||||
|
||||
export interface PaneNativeContribution {
|
||||
guard: (facts: unknown) => boolean;
|
||||
renderHeaderMeta: (facts: unknown) => ReactNode;
|
||||
renderChecksSection: (facts: unknown, ctx: PaneChecksSlotContext) => ReactNode;
|
||||
}
|
||||
|
||||
export interface NativeFallbackCheckEntry {
|
||||
contribute: (facts: unknown, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
|
||||
}
|
||||
|
||||
interface TypedPaneContribution<TFacts extends ForgeSpecificEnvelope> {
|
||||
renderHeaderMeta?: (facts: TFacts) => ReactNode;
|
||||
renderChecksSection?: (facts: TFacts, ctx: PaneChecksSlotContext) => ReactNode;
|
||||
}
|
||||
|
||||
interface TypedNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope> {
|
||||
contribute: (facts: TFacts, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
|
||||
}
|
||||
|
||||
export interface ClientForgeFactsEntry<TFacts extends ForgeSpecificEnvelope> {
|
||||
readonly family: TFacts["forge"];
|
||||
parse: (facts: unknown) => TFacts | null;
|
||||
deriveMergeCapability: (facts: unknown) => MergeCapability | null;
|
||||
readonly nativeFallbackChecks: readonly NativeFallbackCheckEntry[];
|
||||
}
|
||||
|
||||
export interface ClientForgeFactsRegistration<TFacts extends ForgeSpecificEnvelope> {
|
||||
readonly family: TFacts["forge"];
|
||||
readonly schema: z.ZodType<TFacts>;
|
||||
readonly deriveMergeCapability?: (facts: TFacts) => MergeCapability;
|
||||
readonly nativeFallbackChecks?: readonly NativeFallbackCheckEntry[];
|
||||
}
|
||||
|
||||
function parseFacts<TFacts extends ForgeSpecificEnvelope>(
|
||||
schema: z.ZodType<TFacts>,
|
||||
facts: unknown,
|
||||
): TFacts | null {
|
||||
if (!facts) {
|
||||
return null;
|
||||
}
|
||||
const result = schema.safeParse(facts);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
export function defineNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope>(
|
||||
schema: z.ZodType<TFacts>,
|
||||
contribution: TypedNativeFallbackCheck<TFacts>,
|
||||
): NativeFallbackCheckEntry {
|
||||
return {
|
||||
contribute: (facts, status, forge) => {
|
||||
const parsed = parseFacts(schema, facts);
|
||||
return parsed ? contribution.contribute(parsed, status, forge) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function definePaneContribution<TFacts extends ForgeSpecificEnvelope>(
|
||||
schema: z.ZodType<TFacts>,
|
||||
contribution: TypedPaneContribution<TFacts>,
|
||||
): PaneNativeContribution {
|
||||
return {
|
||||
guard: (facts) => parseFacts(schema, facts) !== null,
|
||||
renderHeaderMeta: (facts) => {
|
||||
const parsed = parseFacts(schema, facts);
|
||||
return parsed && contribution.renderHeaderMeta ? contribution.renderHeaderMeta(parsed) : null;
|
||||
},
|
||||
renderChecksSection: (facts, ctx) => {
|
||||
const parsed = parseFacts(schema, facts);
|
||||
return parsed && contribution.renderChecksSection
|
||||
? contribution.renderChecksSection(parsed, ctx)
|
||||
: null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function defineForgeFacts<TFacts extends ForgeSpecificEnvelope>(
|
||||
registration: ClientForgeFactsRegistration<TFacts>,
|
||||
): ClientForgeFactsEntry<TFacts> {
|
||||
return {
|
||||
family: registration.family,
|
||||
parse: (facts) => parseFacts(registration.schema, facts),
|
||||
deriveMergeCapability: (facts) => {
|
||||
if (!registration.deriveMergeCapability) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseFacts(registration.schema, facts);
|
||||
return parsed ? registration.deriveMergeCapability(parsed) : null;
|
||||
},
|
||||
nativeFallbackChecks: registration.nativeFallbackChecks ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure logic half of a forge: URL grammar and runtime-facts derivations. Kept
|
||||
* free of any React/React-Native imports so logic consumers (URL builders,
|
||||
* merge-capability, native-check fallbacks) — and the Node-based e2e harness
|
||||
* that transitively imports them — never pull the client's rendering stack.
|
||||
*/
|
||||
export interface ClientForgeLogicModule<
|
||||
TFacts extends ForgeSpecificEnvelope = ForgeSpecificEnvelope,
|
||||
> {
|
||||
readonly id: string;
|
||||
readonly urlGrammar?: ForgeUrlGrammar;
|
||||
readonly facts?: ClientForgeFactsEntry<TFacts>;
|
||||
}
|
||||
|
||||
/**
|
||||
* View half of a forge: the brand mark, brand color, and PR-pane render
|
||||
* contributions. Imported only by rendering code (icon lookup, the PR pane).
|
||||
*/
|
||||
export interface ClientForgeViewModule {
|
||||
readonly id: string;
|
||||
readonly icon: ForgeIconComponent;
|
||||
readonly brandColor?: ForgeBrandColor | null;
|
||||
readonly paneContributions?: readonly PaneNativeContribution[];
|
||||
}
|
||||
@@ -3,16 +3,14 @@ import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { dotStyles } from "./shared";
|
||||
|
||||
interface CommitRowProps {
|
||||
commit: CheckoutCommit;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) {
|
||||
export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: CommitRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
onCommitPress(commit.sha);
|
||||
}, [commit.sha, onCommitPress]);
|
||||
@@ -32,7 +30,6 @@ export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }:
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{commit.subject}
|
||||
</Text>
|
||||
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||
<View style={styles.caret}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
@@ -61,11 +58,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
timestamp: {
|
||||
flexShrink: 0,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
caret: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
@@ -31,8 +30,6 @@ function CommitsSectionSkeleton() {
|
||||
<View style={styles.skeletonDot} />
|
||||
<View style={styles.skeletonSha} />
|
||||
<View style={styles.skeletonSubject} />
|
||||
<View style={styles.skeletonTimestamp} />
|
||||
<View style={styles.skeletonCaret} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -41,11 +38,9 @@ function CommitsSectionSkeleton() {
|
||||
|
||||
function CommitsSectionContent({
|
||||
query,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: {
|
||||
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -69,7 +64,7 @@ function CommitsSectionContent({
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{query.data.commits.map((commit) => (
|
||||
<CommitRow key={commit.sha} commit={commit} now={now} onCommitPress={onCommitPress} />
|
||||
<CommitRow key={commit.sha} commit={commit} onCommitPress={onCommitPress} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
@@ -78,10 +73,7 @@ function CommitsSectionContent({
|
||||
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreferences } = useChangesPreferences();
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const collapsed = preferences.commitsCollapsed;
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||
const query = useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -89,20 +81,9 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
||||
});
|
||||
|
||||
const handleToggleSection = useCallback(() => {
|
||||
if (collapsed) {
|
||||
setNow(new Date());
|
||||
}
|
||||
void updatePreferences({ commitsCollapsed: !collapsed });
|
||||
}, [collapsed, updatePreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed || !isPanelActive) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => setNow(new Date()), 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [collapsed, isPanelActive]);
|
||||
|
||||
const headerChevronStyle = useMemo(
|
||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||
[collapsed],
|
||||
@@ -144,9 +125,7 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
||||
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{collapsed ? null : (
|
||||
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
|
||||
)}
|
||||
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -216,15 +195,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
skeleton: {
|
||||
paddingBottom: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
skeletonRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
minHeight: 20,
|
||||
},
|
||||
skeletonDot: {
|
||||
@@ -240,22 +218,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonSubject: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
width: "55%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonTimestamp: {
|
||||
width: 40,
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
skeletonCaret: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
View,
|
||||
@@ -84,13 +83,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -1259,6 +1256,7 @@ const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
const ThemedDownload = withUnistyles(Download);
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
@@ -1405,7 +1403,6 @@ function DiffFilesToolbar({
|
||||
}
|
||||
|
||||
interface DiffOptionsMenuProps {
|
||||
brand: string;
|
||||
hideWhitespace: boolean;
|
||||
isMobile: boolean;
|
||||
isRefreshing: boolean;
|
||||
@@ -1418,7 +1415,6 @@ interface DiffOptionsMenuProps {
|
||||
}
|
||||
|
||||
function DiffOptionsMenu({
|
||||
brand,
|
||||
hideWhitespace,
|
||||
isMobile,
|
||||
isRefreshing,
|
||||
@@ -1493,11 +1489,7 @@ function DiffOptionsMenu({
|
||||
testID="changes-refresh"
|
||||
onSelect={onRefresh}
|
||||
>
|
||||
{isRefreshing
|
||||
? t("workspace.git.diff.refreshing")
|
||||
: t("workspace.git.diff.refreshState", {
|
||||
brand,
|
||||
})}
|
||||
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
@@ -2086,62 +2078,6 @@ function computePrErrorMessage(
|
||||
return prPayloadError?.message ?? null;
|
||||
}
|
||||
|
||||
// The precise setup step a workspace needs before its forge features work, or
|
||||
// null when nothing is actionable (authenticated, or no forge remote at all).
|
||||
type ForgeSetupAction = "install_cli" | "sign_in" | null;
|
||||
|
||||
// Drive the onboarding callout from the forge's auth state so the message names
|
||||
// the exact next step (install the CLI vs sign in) for whichever forge backs the
|
||||
// workspace — GitHub included. GitLab additionally requires the host to advertise
|
||||
// GitLab support, matching the rest of the GitLab UI.
|
||||
function computeForgeSetupAction(input: {
|
||||
forge: Forge;
|
||||
forgeProvidersSupported: boolean;
|
||||
authState: ForgeAuthState | undefined;
|
||||
}): ForgeSetupAction {
|
||||
// A daemon without pluggable forge support can't operate any non-GitHub forge,
|
||||
// so don't offer a setup action for one it can't drive.
|
||||
if (input.forge !== "github" && !input.forgeProvidersSupported) {
|
||||
return null;
|
||||
}
|
||||
switch (input.authState) {
|
||||
case "cli_missing":
|
||||
return "install_cli";
|
||||
case "unauthenticated":
|
||||
return "sign_in";
|
||||
case "authenticated":
|
||||
case "no_remote":
|
||||
case "error":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseForgeHost(url: string | null | undefined): string | null {
|
||||
return url ? (parseGitRemoteLocation(url)?.host ?? null) : null;
|
||||
}
|
||||
|
||||
function buildForgeSetupMessage(input: {
|
||||
action: Exclude<ForgeSetupAction, null>;
|
||||
forge: Forge;
|
||||
host: string | null;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
const { brandLabel, signInCli } = getForgePresentation(input.forge);
|
||||
// A forge with no known CLI (an unknown/third-party forge rendered neutrally)
|
||||
// has no install/sign-in command to interpolate — show neutral guidance
|
||||
// rather than the GitLab-specific callout or a null command.
|
||||
if (signInCli === null) {
|
||||
return input.t("workspace.git.forgeSetup.generic", { brand: brandLabel });
|
||||
}
|
||||
if (input.action === "install_cli") {
|
||||
return input.t("workspace.git.forgeSetup.installCli", { cli: signInCli, brand: brandLabel });
|
||||
}
|
||||
const command = buildForgeSignInCommand(input.forge, input.host);
|
||||
return input.t("workspace.git.forgeSetup.signIn", { command, brand: brandLabel });
|
||||
}
|
||||
|
||||
function buildDiffModeTriggerStyle(): PressableStyleFn {
|
||||
return ({ hovered, pressed, open }) => [
|
||||
styles.diffModeTrigger,
|
||||
@@ -2349,36 +2285,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
setWorkspaceAttachments,
|
||||
workspaceAttachmentScopeKey,
|
||||
]);
|
||||
const {
|
||||
githubFeaturesEnabled,
|
||||
forge,
|
||||
authState,
|
||||
payloadError: prPayloadError,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
const forgeProvidersSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
|
||||
);
|
||||
const forgeSetupAction = computeForgeSetupAction({
|
||||
forge,
|
||||
forgeProvidersSupported,
|
||||
authState,
|
||||
});
|
||||
const forgeSetupMessage = useMemo(
|
||||
() =>
|
||||
forgeSetupAction
|
||||
? buildForgeSetupMessage({
|
||||
action: forgeSetupAction,
|
||||
forge,
|
||||
host: parseForgeHost(status?.remoteUrl),
|
||||
t,
|
||||
})
|
||||
: null,
|
||||
[forgeSetupAction, forge, status?.remoteUrl, t],
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
@@ -2490,6 +2401,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
@@ -2615,7 +2531,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
/>
|
||||
) : null}
|
||||
<DiffOptionsMenu
|
||||
brand={getForgePresentation(forge).brandLabel}
|
||||
hideWhitespace={changesPreferences.hideWhitespace}
|
||||
isMobile={isMobile}
|
||||
isRefreshing={isRefreshing}
|
||||
@@ -2631,12 +2546,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{forgeSetupMessage ? (
|
||||
<View style={styles.forgeSetupCallout} testID="forge-setup-callout">
|
||||
<Text style={styles.forgeSetupCalloutText}>{forgeSetupMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
||||
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
@@ -2744,20 +2653,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
forgeSetupCallout: {
|
||||
marginHorizontal: theme.spacing[3],
|
||||
marginBottom: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
forgeSetupCalloutText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
diffContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GitPullRequest } from "lucide-react-native";
|
||||
import { getForgeBrandColorMapping, getForgeIconComponent } from "./forge-icon";
|
||||
import { CLIENT_FORGE_VIEW_MODULES } from "./forges/view";
|
||||
|
||||
describe("getForgeIconComponent", () => {
|
||||
it("resolves a dedicated brand icon for every registered forge module", () => {
|
||||
for (const module of CLIENT_FORGE_VIEW_MODULES) {
|
||||
expect(getForgeIconComponent(module.id)).toBe(module.icon);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the generic pull-request glyph for unknown icon kinds", () => {
|
||||
expect(getForgeIconComponent("some-unknown-forge")).toBe(GitPullRequest);
|
||||
expect(getForgeIconComponent("")).toBe(GitPullRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getForgeBrandColorMapping", () => {
|
||||
it("returns a theme mapping only for forges that declare a brand color", () => {
|
||||
for (const module of CLIENT_FORGE_VIEW_MODULES) {
|
||||
const mapping = getForgeBrandColorMapping(module.id);
|
||||
if (module.brandColor) {
|
||||
expect(mapping).toBeTypeOf("function");
|
||||
} else {
|
||||
expect(mapping).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the light or dark brand color from the theme color scheme", () => {
|
||||
const branded = CLIENT_FORGE_VIEW_MODULES.find((module) => module.brandColor);
|
||||
expect(branded).toBeDefined();
|
||||
if (!branded?.brandColor) {
|
||||
return;
|
||||
}
|
||||
const mapping = getForgeBrandColorMapping(branded.id);
|
||||
expect(mapping?.({ colorScheme: "light" } as never)).toEqual({
|
||||
color: branded.brandColor.light,
|
||||
});
|
||||
expect(mapping?.({ colorScheme: "dark" } as never)).toEqual({
|
||||
color: branded.brandColor.dark,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for unknown icon kinds", () => {
|
||||
expect(getForgeBrandColorMapping("some-unknown-forge")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Brand marks are SVG React components, so they cannot travel over the wire as
|
||||
* a string. This file derives icon and brand-color lookup tables from
|
||||
* CLIENT_FORGE_VIEW_MODULES; a new forge ships an icon component in the client
|
||||
* bundle and references it from its view module. Unknown icon kinds fall back to
|
||||
* a generic pull-request glyph.
|
||||
*/
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { GitPullRequest } from "lucide-react-native";
|
||||
import {
|
||||
type ForgeBrandColor,
|
||||
type ForgeIconColorMapping,
|
||||
type ForgeIconComponent,
|
||||
} from "@/git/client-forge-module";
|
||||
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
|
||||
|
||||
const FORGE_ICON_BY_KIND = new Map(
|
||||
CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, module.icon]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Raw brand icon component for an `iconKind`, for call sites that style with a
|
||||
* plain `color` prop. Falls back to a generic pull-request glyph.
|
||||
*/
|
||||
export function getForgeIconComponent(iconKind: string): ForgeIconComponent {
|
||||
return FORGE_ICON_BY_KIND.get(iconKind) ?? GitPullRequest;
|
||||
}
|
||||
|
||||
const THEMED_ICON_BY_KIND = Object.fromEntries(
|
||||
CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, withUnistyles(module.icon)]),
|
||||
);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
|
||||
/** Theme-driven color mapping, e.g. `(theme) => ({ color: theme.colors.foregroundMuted })`. */
|
||||
export type { ForgeIconColorMapping };
|
||||
|
||||
function brandColorMapping(colors: ForgeBrandColor): ForgeIconColorMapping {
|
||||
return (theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
|
||||
}
|
||||
|
||||
const BRAND_COLOR_MAPPING_BY_KIND = new Map(
|
||||
CLIENT_FORGE_VIEW_MODULES.flatMap((module) =>
|
||||
module.brandColor ? [[module.id, brandColorMapping(module.brandColor)] as const] : [],
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The forge's brand color mapping, or null for forges with no dedicated brand
|
||||
* color (github renders in a neutral tone; an unknown forge has none). Call
|
||||
* sites pair this with {@link ForgeBrandIcon}: use it to tint the icon, or to
|
||||
* decide whether to show a brand badge at all.
|
||||
*/
|
||||
export function getForgeBrandColorMapping(iconKind: string): ForgeIconColorMapping | null {
|
||||
return BRAND_COLOR_MAPPING_BY_KIND.get(iconKind) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themed brand icon for an `iconKind`, for call sites that color via a unistyles
|
||||
* mapping. Falls back to a generic pull-request glyph for unknown forges.
|
||||
*/
|
||||
export function ForgeBrandIcon({
|
||||
iconKind,
|
||||
size,
|
||||
uniProps,
|
||||
}: {
|
||||
iconKind: string;
|
||||
size: number;
|
||||
uniProps: ForgeIconColorMapping;
|
||||
}) {
|
||||
const ThemedIcon = THEMED_ICON_BY_KIND[iconKind] ?? ThemedGitPullRequest;
|
||||
return <ThemedIcon size={size} uniProps={uniProps} />;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildForgeBlobUrl, buildForgeBranchTreeUrl, hasForgeWebUrls } from "./forge-url";
|
||||
|
||||
describe("buildForgeBranchTreeUrl", () => {
|
||||
it("builds a branch-specific GitHub tree URL", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", {
|
||||
remoteUrl: "git@github.com:acme/repo.git",
|
||||
branch: "feature/workspace-button",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
|
||||
});
|
||||
|
||||
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "feature/ship #42",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
|
||||
});
|
||||
|
||||
it("uses the GitLab /-/tree/ infix and supports subgroups", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("gitlab", {
|
||||
remoteUrl: "https://gitlab.com/group/sub/repo.git",
|
||||
branch: "main",
|
||||
}),
|
||||
).toBe("https://gitlab.com/group/sub/repo/-/tree/main");
|
||||
});
|
||||
|
||||
it("uses the Gitea-family /src/branch/ infix", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("gitea", {
|
||||
remoteUrl: "https://gitea.com/acme/repo.git",
|
||||
branch: "main",
|
||||
}),
|
||||
).toBe("https://gitea.com/acme/repo/src/branch/main");
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("codeberg", {
|
||||
remoteUrl: "https://codeberg.org/acme/repo.git",
|
||||
branch: "main",
|
||||
}),
|
||||
).toBe("https://codeberg.org/acme/repo/src/branch/main");
|
||||
});
|
||||
|
||||
it("returns null when the current branch is unavailable", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "HEAD",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a forge with no known URL grammar", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("bitbucket", {
|
||||
remoteUrl: "https://bitbucket.org/acme/repo.git",
|
||||
branch: "main",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForgeBlobUrl", () => {
|
||||
it("builds a blob URL for a file path", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "git@github.com:acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("appends a single-line anchor", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
lineStart: 12,
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
|
||||
});
|
||||
|
||||
it("appends a GitHub line range anchor (#L12-L20)", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
|
||||
});
|
||||
|
||||
it("uses the GitLab /-/blob/ infix and #L12-20 range anchor", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("gitlab", {
|
||||
remoteUrl: "https://gitlab.com/group/sub/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
}),
|
||||
).toBe("https://gitlab.com/group/sub/repo/-/blob/main/src/index.ts#L12-20");
|
||||
});
|
||||
|
||||
it("uses the Gitea-family /src/branch/ blob path with a #L12-L20 anchor", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("forgejo", {
|
||||
remoteUrl: "https://codeberg.org/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
}),
|
||||
).toBe("https://codeberg.org/acme/repo/src/branch/main/src/index.ts#L12-L20");
|
||||
});
|
||||
|
||||
it("derives the web host from a self-hosted remote (GitHub Enterprise)", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "git@github.acme.internal:team/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("canonicalizes the github.com SSH-alias host to the web host", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "ssh://git@ssh.github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("strips leading slashes and encodes path segments", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "/src/a b/c#d.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
|
||||
});
|
||||
|
||||
it("normalizes harmless dot segments in the blob path", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "./src/../index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/index.ts");
|
||||
});
|
||||
|
||||
it("returns null for blob paths that escape above the repo root", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "../outside.ts",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the path is missing", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a forge with no known URL grammar", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("bitbucket", {
|
||||
remoteUrl: "https://bitbucket.org/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasForgeWebUrls", () => {
|
||||
it("is true for forges with a known URL grammar", () => {
|
||||
for (const forge of ["github", "gitlab", "gitea", "forgejo", "codeberg"]) {
|
||||
expect(hasForgeWebUrls(forge)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is false for an unknown forge", () => {
|
||||
expect(hasForgeWebUrls("bitbucket")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Forge-neutral web URL builders for "Open on <forge>" actions (a file blob or a
|
||||
* branch tree). The host comes from the workspace remote — not a hardcoded
|
||||
* cloud host — so self-hosted and Enterprise instances link correctly. Each
|
||||
* forge contributes a small URL grammar (the path infixes and line-anchor
|
||||
* format); an unknown forge has no grammar and yields null, so the action is
|
||||
* simply absent rather than wrong.
|
||||
*
|
||||
* URL grammar lives on each client forge module. The repo identity and host
|
||||
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
|
||||
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
|
||||
*/
|
||||
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
|
||||
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
|
||||
export interface ForgeBlobUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
path: string | null | undefined;
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
}
|
||||
|
||||
export interface ForgeBranchTreeUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
}
|
||||
|
||||
interface ForgeWebLocation {
|
||||
host: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web host + repo path from a remote. The host is the remote's host, except a
|
||||
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
|
||||
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
|
||||
* Self-hosted hosts are returned untouched. The repo path supports nested
|
||||
* groups (e.g. GitLab subgroups) since it is the full remote path.
|
||||
*/
|
||||
function isValidRepoPath(path: string): boolean {
|
||||
const segments = path.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return !segments.includes("..");
|
||||
}
|
||||
|
||||
function resolveForgeWebLocation(
|
||||
forge: string,
|
||||
remoteUrl: string | null | undefined,
|
||||
): ForgeWebLocation | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
const location = parseGitRemoteLocation(remoteUrl);
|
||||
if (!location || !isValidRepoPath(location.path)) {
|
||||
return null;
|
||||
}
|
||||
const cloudHosts = getForgeDefinition(forge)?.cloudHosts;
|
||||
const webHost =
|
||||
cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host)
|
||||
? normalizeHost(cloudHosts[0])
|
||||
: location.host;
|
||||
return { host: webHost, repo: location.path };
|
||||
}
|
||||
|
||||
function encodeBranch(branch: string): string {
|
||||
return branch.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function normalizeBlobPath(path: string | null | undefined): string | null {
|
||||
const segments: string[] = [];
|
||||
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
for (const segment of trimmed.split("/")) {
|
||||
if (!segment || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
segments.pop();
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join("/") || null;
|
||||
}
|
||||
|
||||
export function buildForgeBranchTreeUrl(
|
||||
forge: string,
|
||||
input: ForgeBranchTreeUrlInput,
|
||||
): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const branch = input.branch?.trim();
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
}
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const branch = input.branch?.trim();
|
||||
const filePath = normalizeBlobPath(input.path);
|
||||
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
|
||||
return null;
|
||||
}
|
||||
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
||||
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||
if (input.lineStart && input.lineStart > 0) {
|
||||
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
|
||||
export function hasForgeWebUrls(forge: string): boolean {
|
||||
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildForgeSignInCommand,
|
||||
forgeFromRemoteUrl,
|
||||
getForgePresentation,
|
||||
normalizeForge,
|
||||
} from "./forge";
|
||||
|
||||
describe("normalizeForge", () => {
|
||||
it("maps the gitlab discriminant to gitlab", () => {
|
||||
expect(normalizeForge("gitlab")).toBe("gitlab");
|
||||
});
|
||||
|
||||
it("keeps any non-empty forge id and defaults only absent values to github", () => {
|
||||
expect(normalizeForge("github")).toBe("github");
|
||||
expect(normalizeForge("gitea")).toBe("gitea");
|
||||
expect(normalizeForge("forgejo")).toBe("forgejo");
|
||||
expect(normalizeForge(undefined)).toBe("github");
|
||||
expect(normalizeForge(null)).toBe("github");
|
||||
// An unknown forge id is preserved (rendered neutrally), not collapsed to
|
||||
// GitHub, so a newer daemon's forge degrades gracefully on an older client.
|
||||
expect(normalizeForge("bitbucket")).toBe("bitbucket");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getForgePresentation", () => {
|
||||
it("keeps GitHub on the pull-request noun and the # prefix", () => {
|
||||
const github = getForgePresentation("github");
|
||||
expect(github.brandLabel).toBe("GitHub");
|
||||
expect(github.changeRequestAbbrev).toBe("PR");
|
||||
expect(github.numberPrefix).toBe("#");
|
||||
expect(github.issueNumberPrefix).toBe("#");
|
||||
expect(github.changeRequestContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("relabels GitLab to the merge-request noun and the ! prefix", () => {
|
||||
const gitlab = getForgePresentation("gitlab");
|
||||
expect(gitlab.brandLabel).toBe("GitLab");
|
||||
expect(gitlab.changeRequestAbbrev).toBe("MR");
|
||||
expect(gitlab.numberPrefix).toBe("!");
|
||||
expect(gitlab.issueNumberPrefix).toBe("#");
|
||||
expect(gitlab.changeRequestContext).toBe("mr");
|
||||
});
|
||||
|
||||
it("presents Gitea and Forgejo with GitHub nouns and the tea CLI", () => {
|
||||
expect(getForgePresentation("gitea")).toMatchObject({
|
||||
forge: "gitea",
|
||||
icon: "gitea",
|
||||
brandLabel: "Gitea",
|
||||
changeRequestAbbrev: "PR",
|
||||
numberPrefix: "#",
|
||||
issueNumberPrefix: "#",
|
||||
signInCli: "tea",
|
||||
});
|
||||
expect(getForgePresentation("forgejo")).toMatchObject({
|
||||
forge: "forgejo",
|
||||
icon: "forgejo",
|
||||
brandLabel: "Forgejo",
|
||||
changeRequestAbbrev: "PR",
|
||||
signInCli: "tea",
|
||||
});
|
||||
expect(getForgePresentation("codeberg")).toMatchObject({
|
||||
forge: "codeberg",
|
||||
icon: "codeberg",
|
||||
brandLabel: "Codeberg",
|
||||
changeRequestAbbrev: "PR",
|
||||
signInCli: "tea",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgeFromRemoteUrl", () => {
|
||||
it("detects only public forge hosts that are safe without daemon probing", () => {
|
||||
expect(forgeFromRemoteUrl("https://codeberg.org/example/repo.git")).toBe("codeberg");
|
||||
expect(forgeFromRemoteUrl("https://gitlab.com/example/repo.git")).toBe("gitlab");
|
||||
expect(forgeFromRemoteUrl("https://gitea.com/example/repo.git")).toBe("gitea");
|
||||
});
|
||||
|
||||
it("does not classify self-managed hosts by substring", () => {
|
||||
expect(forgeFromRemoteUrl("git@gitlab.example.org:example/repo.git")).toBeNull();
|
||||
expect(forgeFromRemoteUrl("git@forgejo.example.org:example/repo.git")).toBeNull();
|
||||
expect(forgeFromRemoteUrl("https://notgitlab.example.org/example/repo.git")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForgeSignInCommand", () => {
|
||||
it("uses tea login add for both Gitea-family presentations", () => {
|
||||
expect(buildForgeSignInCommand("gitea", "gitea.com")).toBe("tea login add");
|
||||
expect(buildForgeSignInCommand("forgejo", "forgejo.example.org")).toBe("tea login add");
|
||||
expect(buildForgeSignInCommand("codeberg", "codeberg.org")).toBe("tea login add");
|
||||
});
|
||||
|
||||
it("uses plain gh auth login for GitHub (incl. the ssh.github.com endpoint)", () => {
|
||||
expect(buildForgeSignInCommand("github", "github.com")).toBe("gh auth login");
|
||||
expect(buildForgeSignInCommand("github", "ssh.github.com")).toBe("gh auth login");
|
||||
});
|
||||
|
||||
it("targets the workspace host for self-hosted GitLab", () => {
|
||||
expect(buildForgeSignInCommand("gitlab", "gitlab.acme.com")).toBe(
|
||||
"glab auth login --hostname gitlab.acme.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns no sign-in command for an unknown forge with no known CLI", () => {
|
||||
expect(buildForgeSignInCommand("bitbucket", "bitbucket.org")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Forge-neutral presentation layer for the git-hosting UI, derived from the
|
||||
* shared forge manifest.
|
||||
*
|
||||
* The daemon resolves which forge backs a workspace and reports it on the wire
|
||||
* (`CheckoutPrStatusResponse.payload.forge`). The model keeps the `PullRequest`
|
||||
* noun in code and types; the PR↔MR relabel, the number prefix, the brand mark,
|
||||
* and the icon are a UI concern driven by the manifest entry for that id. An
|
||||
* unknown/absent forge (e.g. a self-hosted forge a newer daemon reports to an
|
||||
* older client) renders neutrally — never GitHub-branded. A null/empty forge
|
||||
* still maps to GitHub so old daemons (which never send a forge) render exactly
|
||||
* as before.
|
||||
*/
|
||||
import { FORGE_DEFINITIONS, getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
|
||||
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
buildForgeBlobUrl,
|
||||
buildForgeBranchTreeUrl,
|
||||
hasForgeWebUrls,
|
||||
type ForgeBlobUrlInput,
|
||||
type ForgeBranchTreeUrlInput,
|
||||
} from "@/git/forge-url";
|
||||
|
||||
/**
|
||||
* A forge id. Open by design: any id the daemon reports is valid, and the
|
||||
* manifest drives presentation with a neutral fallback for unknown ids. Kept as
|
||||
* a named alias to document intent at call sites.
|
||||
*/
|
||||
export type Forge = string;
|
||||
|
||||
export function normalizeForge(raw: string | null | undefined): string {
|
||||
return raw && raw.length > 0 ? raw : "github";
|
||||
}
|
||||
|
||||
export function parseForgeAuthState(value: unknown): ForgeAuthState | undefined {
|
||||
switch (value) {
|
||||
case "authenticated":
|
||||
case "unauthenticated":
|
||||
case "cli_missing":
|
||||
case "no_remote":
|
||||
case "error":
|
||||
return value;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function forgeFromRemoteUrl(remoteUrl: string | null | undefined): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
const host = parseGitRemoteLocation(remoteUrl)?.host;
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeHost(host);
|
||||
for (const definition of FORGE_DEFINITIONS) {
|
||||
if (definition.cloudHosts?.some((cloudHost) => normalizeHost(cloudHost) === normalized)) {
|
||||
return definition.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ForgePresentation {
|
||||
forge: string;
|
||||
/** Icon key; render sites fall back to a generic git icon for unknown values. */
|
||||
icon: string;
|
||||
/** Human brand name, e.g. for "Open on GitLab". */
|
||||
brandLabel: string;
|
||||
/** Short change-request noun: "PR" for GitHub, "MR" for GitLab. */
|
||||
changeRequestAbbrev: string;
|
||||
/** Full change-request noun: "pull request" for GitHub, "merge request" for GitLab. */
|
||||
changeRequestNoun: string;
|
||||
/** Prefix the forge puts before a change-request number: "#" vs "!". */
|
||||
numberPrefix: string;
|
||||
/** Prefix the forge puts before an issue number ("#" on every forge so far). */
|
||||
issueNumberPrefix: string;
|
||||
/** Auth CLI binary for the install hint, or null for a forge with no Paseo-driven sign-in. */
|
||||
signInCli: string | null;
|
||||
/**
|
||||
* i18next context selecting the change-request vocabulary family for any key
|
||||
* that carries an `_mr` variant: `t(key, { context: changeRequestContext })`
|
||||
* resolves `key_mr` for the merge-request family and falls back to the base
|
||||
* (pull-request) string for undefined or unknown families.
|
||||
*/
|
||||
changeRequestContext: "mr" | undefined;
|
||||
buildBlobUrl: ((input: ForgeBlobUrlInput) => string | null) | null;
|
||||
buildBranchTreeUrl: ((input: ForgeBranchTreeUrlInput) => string | null) | null;
|
||||
}
|
||||
|
||||
export function getForgePresentation(forge: string): ForgePresentation {
|
||||
const definition = getForgeDefinitionOrNeutral(forge);
|
||||
const isMergeRequest = definition.changeRequestAbbrev === "MR";
|
||||
const hasWebUrls = hasForgeWebUrls(definition.id);
|
||||
return {
|
||||
forge: definition.id,
|
||||
icon: definition.iconKind,
|
||||
brandLabel: definition.displayName,
|
||||
changeRequestAbbrev: definition.changeRequestAbbrev,
|
||||
changeRequestNoun: definition.changeRequestNoun,
|
||||
numberPrefix: definition.changeRequestNumberPrefix,
|
||||
issueNumberPrefix: definition.issueNumberPrefix,
|
||||
signInCli: definition.signIn?.cli ?? null,
|
||||
changeRequestContext: isMergeRequest ? "mr" : undefined,
|
||||
buildBlobUrl: hasWebUrls ? (input) => buildForgeBlobUrl(definition.id, input) : null,
|
||||
buildBranchTreeUrl: hasWebUrls
|
||||
? (input) => buildForgeBranchTreeUrl(definition.id, input)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildForgeSignInCommand(forge: string, host: string | null): string | null {
|
||||
const signIn = getForgeDefinitionOrNeutral(forge).signIn;
|
||||
if (!signIn) {
|
||||
return null;
|
||||
}
|
||||
// A forge that needs a --hostname only gets it when a host is known; forges
|
||||
// whose command already targets the right API host (e.g. github's plain
|
||||
// `gh auth login`, which must NOT receive ssh.github.com) omit hostnameFlag.
|
||||
if (signIn.hostnameFlag && host) {
|
||||
return `${signIn.command} ${signIn.hostnameFlag} ${host}`;
|
||||
}
|
||||
return signIn.command;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
|
||||
|
||||
export const codebergForgeLogic = {
|
||||
id: "codeberg",
|
||||
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
|
||||
} satisfies ClientForgeLogicModule;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { CodebergIcon } from "@/components/icons/codeberg-icon";
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
|
||||
export const codebergForgeView = {
|
||||
id: "codeberg",
|
||||
icon: CodebergIcon,
|
||||
brandColor: {
|
||||
light: "#2185D0",
|
||||
dark: "#2185D0",
|
||||
},
|
||||
} satisfies ClientForgeViewModule;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
|
||||
|
||||
export const forgejoForgeLogic = {
|
||||
id: "forgejo",
|
||||
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
|
||||
} satisfies ClientForgeLogicModule;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ForgejoIcon } from "@/components/icons/forgejo-icon";
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
|
||||
export const forgejoForgeView = {
|
||||
id: "forgejo",
|
||||
icon: ForgejoIcon,
|
||||
brandColor: {
|
||||
light: "#FB923C",
|
||||
dark: "#FB923C",
|
||||
},
|
||||
} satisfies ClientForgeViewModule;
|
||||
@@ -1,72 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineForgeFacts,
|
||||
defineNativeFallbackCheck,
|
||||
GITEA_FAMILY_URL_GRAMMAR,
|
||||
type ClientForgeLogicModule,
|
||||
type MergeCapability,
|
||||
} from "@/git/client-forge-module";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { mapCheckStatus, type CheckStatus } from "@/git/pull-request-panel/check-status";
|
||||
|
||||
const GiteaMergeFactsSchema = z
|
||||
.object({
|
||||
forge: z.literal("gitea"),
|
||||
mergeable: z.boolean().optional().default(false),
|
||||
hasMerged: z.boolean().optional().default(false),
|
||||
ciStatus: z.string().nullable().optional().default(null),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type GiteaMergeFacts = z.infer<typeof GiteaMergeFactsSchema>;
|
||||
|
||||
// forgeSpecific.ciStatus carries Gitea's raw aggregate CI string. Server twin:
|
||||
// packages/server/src/services/gitea-service.ts (mapGiteaCommitStatus) — "warning"
|
||||
// and "error" are terminal, non-passing states, but the generic mapCheckStatus
|
||||
// would show them as pending, so interpret them here where the module owns Gitea
|
||||
// facts.
|
||||
function mapGiteaCiStatus(ciStatus: string): CheckStatus {
|
||||
if (ciStatus === "warning" || ciStatus === "error") {
|
||||
return "failure";
|
||||
}
|
||||
return mapCheckStatus(ciStatus);
|
||||
}
|
||||
|
||||
const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
|
||||
|
||||
function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability {
|
||||
return {
|
||||
directMergeReady: gitea.mergeable && !gitea.hasMerged,
|
||||
canEnableAutoMerge: false,
|
||||
autoMergeEnabled: false,
|
||||
canDisableAutoMerge: false,
|
||||
mergeBlockedByQueue: false,
|
||||
allowedMethods: GITEA_MERGE_METHODS,
|
||||
preferredMethod: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const giteaForgeLogic = {
|
||||
id: "gitea",
|
||||
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
|
||||
facts: defineForgeFacts({
|
||||
family: "gitea",
|
||||
schema: GiteaMergeFactsSchema,
|
||||
deriveMergeCapability: deriveGiteaMergeCapability,
|
||||
nativeFallbackChecks: [
|
||||
defineNativeFallbackCheck(GiteaMergeFactsSchema, {
|
||||
contribute: (facts, status, forge) => {
|
||||
if (!facts.ciStatus) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: forge,
|
||||
name: "CI",
|
||||
status: mapGiteaCiStatus(facts.ciStatus),
|
||||
url: status.url,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
} satisfies ClientForgeLogicModule<GiteaMergeFacts>;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { GiteaIcon } from "@/components/icons/gitea-icon";
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
|
||||
export const giteaForgeView = {
|
||||
id: "gitea",
|
||||
icon: GiteaIcon,
|
||||
brandColor: {
|
||||
light: "#609926",
|
||||
dark: "#609926",
|
||||
},
|
||||
} satisfies ClientForgeViewModule;
|
||||
@@ -1,95 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineForgeFacts,
|
||||
GITHUB_LINE_ANCHOR,
|
||||
type ClientForgeLogicModule,
|
||||
type MergeCapability,
|
||||
} from "@/git/client-forge-module";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
|
||||
const GithubAutoMergeRequestSchema = z
|
||||
.object({
|
||||
enabledAt: z.string().nullable().optional().default(null),
|
||||
mergeMethod: z.string().nullable().optional().default(null),
|
||||
enabledBy: z.string().nullable().optional().default(null),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.default(null);
|
||||
|
||||
const GithubRepositoryPolicySchema = z
|
||||
.object({
|
||||
autoMergeAllowed: z.boolean().optional().default(false),
|
||||
mergeCommitAllowed: z.boolean().optional().default(false),
|
||||
squashMergeAllowed: z.boolean().optional().default(false),
|
||||
rebaseMergeAllowed: z.boolean().optional().default(false),
|
||||
viewerDefaultMergeMethod: z.string().nullable().optional().default(null),
|
||||
})
|
||||
.optional()
|
||||
.default({
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: null,
|
||||
});
|
||||
|
||||
const GithubMergeFactsSchema = z
|
||||
.object({
|
||||
forge: z.literal("github"),
|
||||
mergeStateStatus: z.string().nullable().optional().default(null),
|
||||
autoMergeRequest: GithubAutoMergeRequestSchema,
|
||||
viewerCanEnableAutoMerge: z.boolean().optional().default(false),
|
||||
viewerCanDisableAutoMerge: z.boolean().optional().default(false),
|
||||
viewerCanMergeAsAdmin: z.boolean().optional().default(false),
|
||||
viewerCanUpdateBranch: z.boolean().optional().default(false),
|
||||
repository: GithubRepositoryPolicySchema,
|
||||
isMergeQueueEnabled: z.boolean().optional().default(false),
|
||||
isInMergeQueue: z.boolean().optional().default(false),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type GithubMergeFacts = z.infer<typeof GithubMergeFactsSchema>;
|
||||
|
||||
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
|
||||
|
||||
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
|
||||
if (value === "SQUASH") return "squash";
|
||||
if (value === "MERGE") return "merge";
|
||||
if (value === "REBASE") return "rebase";
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveGithubMergeCapability(github: GithubMergeFacts): MergeCapability {
|
||||
const repository = github.repository;
|
||||
const allowedMethods: CheckoutPrMergeMethod[] = [];
|
||||
if (repository.mergeCommitAllowed) allowedMethods.push("merge");
|
||||
if (repository.squashMergeAllowed) allowedMethods.push("squash");
|
||||
if (repository.rebaseMergeAllowed) allowedMethods.push("rebase");
|
||||
return {
|
||||
directMergeReady: GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? ""),
|
||||
canEnableAutoMerge:
|
||||
github.mergeStateStatus === "BLOCKED" &&
|
||||
repository.autoMergeAllowed &&
|
||||
github.viewerCanEnableAutoMerge,
|
||||
autoMergeEnabled: github.autoMergeRequest !== null,
|
||||
canDisableAutoMerge: github.viewerCanDisableAutoMerge === true,
|
||||
mergeBlockedByQueue: github.isMergeQueueEnabled || github.isInMergeQueue,
|
||||
allowedMethods,
|
||||
preferredMethod: normalizeGithubMergeMethod(repository.viewerDefaultMergeMethod ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
export const githubForgeLogic = {
|
||||
id: "github",
|
||||
urlGrammar: {
|
||||
treeInfix: "/tree/",
|
||||
blobInfix: "/blob/",
|
||||
lineAnchor: GITHUB_LINE_ANCHOR,
|
||||
},
|
||||
facts: defineForgeFacts({
|
||||
family: "github",
|
||||
schema: GithubMergeFactsSchema,
|
||||
deriveMergeCapability: deriveGithubMergeCapability,
|
||||
}),
|
||||
} satisfies ClientForgeLogicModule<GithubMergeFacts>;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
|
||||
export const githubForgeView = {
|
||||
id: "github",
|
||||
icon: GitHubIcon,
|
||||
brandColor: null,
|
||||
} satisfies ClientForgeViewModule;
|
||||
@@ -1,155 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineForgeFacts,
|
||||
type ClientForgeLogicModule,
|
||||
type MergeCapability,
|
||||
} from "@/git/client-forge-module";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import type { CheckStatus } from "@/git/pull-request-panel/check-status";
|
||||
|
||||
const gitlabLineAnchor = (start: number, end?: number): string =>
|
||||
end && end > start ? `#L${start}-${end}` : `#L${start}`;
|
||||
|
||||
/**
|
||||
* Canonical set of GitLab pipeline statuses that count as "still active" (a
|
||||
* pipeline that has not reached a terminal state). Server-side twin:
|
||||
* packages/server/src/services/gitlab-facts.ts (GITLAB_ACTIVE_PIPELINE_STATUSES),
|
||||
* duplicated because the app can't depend on the server package.
|
||||
*/
|
||||
const GITLAB_ACTIVE_PIPELINE_STATUSES = [
|
||||
"created",
|
||||
"waiting_for_resource",
|
||||
"preparing",
|
||||
"pending",
|
||||
"running",
|
||||
"scheduled",
|
||||
] as const;
|
||||
|
||||
const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
|
||||
|
||||
export function isPipelineActiveStatus(status: string): boolean {
|
||||
return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status);
|
||||
}
|
||||
|
||||
export function mapPipelineStatus(status: string): CheckStatus {
|
||||
switch (status) {
|
||||
case "success":
|
||||
case "passed":
|
||||
return "success";
|
||||
case "failed":
|
||||
return "failure";
|
||||
case "running":
|
||||
case "pending":
|
||||
case "created":
|
||||
case "waiting_for_resource":
|
||||
case "preparing":
|
||||
case "scheduled":
|
||||
case "manual":
|
||||
return "pending";
|
||||
case "canceled":
|
||||
case "cancelled":
|
||||
case "skipped":
|
||||
return "skipped";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
const GitlabMergeFactsSchema = z
|
||||
.object({
|
||||
forge: z.literal("gitlab"),
|
||||
detailedMergeStatus: z.string().nullable().optional().default(null),
|
||||
mergeStatus: z.string().nullable().optional().default(null),
|
||||
hasConflicts: z.boolean().optional().default(false),
|
||||
blockingDiscussionsResolved: z.boolean().optional().default(true),
|
||||
approvalsRequired: z.number().optional().default(0),
|
||||
approvalsGiven: z.number().optional().default(0),
|
||||
pipelineStatus: z.string().nullable().optional().default(null),
|
||||
pipelineId: z.number().nullable().optional().default(null),
|
||||
pipelineUrl: z.string().nullable().optional().default(null),
|
||||
mergeWhenPipelineSucceeds: z.boolean().optional().default(false),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type GitlabMergeFacts = z.infer<typeof GitlabMergeFactsSchema>;
|
||||
|
||||
export { GitlabMergeFactsSchema };
|
||||
|
||||
const GITLAB_MERGEABLE_STATUS = "mergeable";
|
||||
const GITLAB_LEGACY_MERGEABLE_STATUS = "can_be_merged";
|
||||
const GITLAB_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
|
||||
|
||||
/**
|
||||
* Direct-merge readiness from GitLab's merge signals. `detailedMergeStatus` is
|
||||
* GitLab 15.6+ only; when it is absent (older self-managed instances) fall back
|
||||
* to the legacy `mergeStatus === "can_be_merged"` with no conflicts so merge is
|
||||
* not silently refused everywhere.
|
||||
*/
|
||||
function isGitlabDirectMergeReady(gitlab: GitlabMergeFacts): boolean {
|
||||
if (gitlab.detailedMergeStatus != null) {
|
||||
return gitlab.detailedMergeStatus === GITLAB_MERGEABLE_STATUS;
|
||||
}
|
||||
return gitlab.mergeStatus === GITLAB_LEGACY_MERGEABLE_STATUS && gitlab.hasConflicts !== true;
|
||||
}
|
||||
|
||||
function deriveGitlabMergeCapability(gitlab: GitlabMergeFacts): MergeCapability {
|
||||
const autoMergeEnabled = gitlab.mergeWhenPipelineSucceeds === true;
|
||||
const hasActivePipeline =
|
||||
gitlab.pipelineStatus !== null && isPipelineActiveStatus(gitlab.pipelineStatus);
|
||||
return {
|
||||
directMergeReady: isGitlabDirectMergeReady(gitlab),
|
||||
canEnableAutoMerge: !autoMergeEnabled && hasActivePipeline,
|
||||
autoMergeEnabled,
|
||||
canDisableAutoMerge: autoMergeEnabled,
|
||||
mergeBlockedByQueue: false,
|
||||
allowedMethods: GITLAB_MERGE_METHODS,
|
||||
preferredMethod: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitlabPipelineSummary {
|
||||
id: number;
|
||||
status: CheckStatus;
|
||||
rawStatus: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface GitlabApprovals {
|
||||
given: number;
|
||||
required: number;
|
||||
}
|
||||
|
||||
export function deriveGitlabPipelineSummary(facts: GitlabMergeFacts): GitlabPipelineSummary | null {
|
||||
if (facts.pipelineId == null) {
|
||||
return null;
|
||||
}
|
||||
const rawStatus = facts.pipelineStatus ?? "";
|
||||
return {
|
||||
id: facts.pipelineId,
|
||||
status: mapPipelineStatus(rawStatus),
|
||||
rawStatus,
|
||||
url: facts.pipelineUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveGitlabApprovals(facts: GitlabMergeFacts): GitlabApprovals | null {
|
||||
const required = facts.approvalsRequired ?? 0;
|
||||
if (required <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { given: facts.approvalsGiven ?? 0, required };
|
||||
}
|
||||
|
||||
export const gitlabForgeLogic = {
|
||||
id: "gitlab",
|
||||
urlGrammar: {
|
||||
treeInfix: "/-/tree/",
|
||||
blobInfix: "/-/blob/",
|
||||
lineAnchor: gitlabLineAnchor,
|
||||
},
|
||||
facts: defineForgeFacts({
|
||||
family: "gitlab",
|
||||
schema: GitlabMergeFactsSchema,
|
||||
deriveMergeCapability: deriveGitlabMergeCapability,
|
||||
}),
|
||||
} satisfies ClientForgeLogicModule<GitlabMergeFacts>;
|
||||
@@ -1,328 +0,0 @@
|
||||
import React, { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleCheck, ExternalLink } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CheckoutPipelineJob, CheckoutPipelineStage } from "@getpaseo/protocol/messages";
|
||||
import { GitLabIcon } from "@/components/icons/gitlab-icon";
|
||||
import {
|
||||
definePaneContribution,
|
||||
type ClientForgeViewModule,
|
||||
type PaneChecksSlotContext,
|
||||
} from "@/git/client-forge-module";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { formatDuration } from "@/utils/time";
|
||||
import {
|
||||
CheckStatusIcon,
|
||||
Section,
|
||||
SUMMARY_DANGER_ICON,
|
||||
SUMMARY_SUCCESS_ICON,
|
||||
SUMMARY_WARNING_ICON,
|
||||
SummaryPill,
|
||||
foregroundMutedColorMapping,
|
||||
sectionKitStyles,
|
||||
successColorMapping,
|
||||
} from "@/git/pull-request-panel/section-kit";
|
||||
import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline";
|
||||
import {
|
||||
deriveGitlabApprovals,
|
||||
deriveGitlabPipelineSummary,
|
||||
GitlabMergeFactsSchema,
|
||||
isPipelineActiveStatus,
|
||||
mapPipelineStatus,
|
||||
type GitlabApprovals,
|
||||
type GitlabMergeFacts,
|
||||
type GitlabPipelineSummary,
|
||||
} from "./gitlab";
|
||||
|
||||
function renderGitlabHeaderMeta(facts: GitlabMergeFacts): ReactNode {
|
||||
const approvals = deriveGitlabApprovals(facts);
|
||||
if (!approvals) {
|
||||
return null;
|
||||
}
|
||||
return <GitlabApprovalsBadge approvals={approvals} />;
|
||||
}
|
||||
|
||||
function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotContext): ReactNode {
|
||||
if (!ctx.enabled) {
|
||||
return null;
|
||||
}
|
||||
const summary = deriveGitlabPipelineSummary(facts);
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<GitLabPipelineSection
|
||||
serverId={ctx.serverId}
|
||||
cwd={ctx.cwd}
|
||||
changeRequestNumber={ctx.changeRequestNumber}
|
||||
summary={summary}
|
||||
open={ctx.open}
|
||||
onToggle={ctx.onToggle}
|
||||
canFetchCheckDetails={ctx.canFetchCheckDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
|
||||
function GitlabApprovalsBadge({ approvals }: { approvals: GitlabApprovals }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.approvalsBadge} testID="pr-pane-approvals">
|
||||
<ThemedCircleCheck
|
||||
size={11}
|
||||
uniProps={
|
||||
approvals.given >= approvals.required ? successColorMapping : foregroundMutedColorMapping
|
||||
}
|
||||
/>
|
||||
<Text style={styles.approvalsText}>
|
||||
{t("workspace.git.pr.approvals", { given: approvals.given, required: approvals.required })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable];
|
||||
}
|
||||
|
||||
function jobRowPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [styles.pipelineJobRow, Boolean(hovered) && styles.hoverable];
|
||||
}
|
||||
|
||||
function formatPipelineDuration(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) {
|
||||
return "";
|
||||
}
|
||||
return formatDuration(seconds * 1000);
|
||||
}
|
||||
|
||||
interface PipelineJobCounts {
|
||||
passed: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
|
||||
const counts: PipelineJobCounts = { passed: 0, failed: 0, pending: 0 };
|
||||
for (const job of jobs) {
|
||||
const status = mapPipelineStatus(job.status);
|
||||
if (status === "success") counts.passed += 1;
|
||||
else if (status === "failure") counts.failed += 1;
|
||||
else if (status === "pending") counts.pending += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function GitLabPipelineSection({
|
||||
serverId,
|
||||
cwd,
|
||||
changeRequestNumber,
|
||||
summary,
|
||||
open,
|
||||
onToggle,
|
||||
canFetchCheckDetails,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
changeRequestNumber: number;
|
||||
summary: GitlabPipelineSummary;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
canFetchCheckDetails: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
|
||||
serverId,
|
||||
cwd,
|
||||
pipelineId: summary.id,
|
||||
changeRequestNumber,
|
||||
enabled: open && canFetchCheckDetails,
|
||||
live: isPipelineActiveStatus(summary.rawStatus),
|
||||
});
|
||||
|
||||
const counts = useMemo(
|
||||
() => countPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)),
|
||||
[pipeline],
|
||||
);
|
||||
|
||||
const totalCounted = counts.passed + counts.failed + counts.pending;
|
||||
const showBreakdown = !isPlaceholderData && totalCounted > 0;
|
||||
const displayCounts = showBreakdown ? counts : { passed: 0, failed: 0, pending: 0 };
|
||||
|
||||
const handleOpenPipeline = useCallback(() => {
|
||||
if (summary.url) {
|
||||
void openExternalUrl(summary.url);
|
||||
}
|
||||
}, [summary.url]);
|
||||
|
||||
const sectionSummary = (
|
||||
<>
|
||||
<SummaryPill
|
||||
count={displayCounts.passed}
|
||||
icon={SUMMARY_SUCCESS_ICON}
|
||||
variant="success"
|
||||
testID="pr-pane-pipeline-passed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={displayCounts.failed}
|
||||
icon={SUMMARY_DANGER_ICON}
|
||||
variant="danger"
|
||||
testID="pr-pane-pipeline-failed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={displayCounts.pending}
|
||||
icon={SUMMARY_WARNING_ICON}
|
||||
variant="warning"
|
||||
testID="pr-pane-pipeline-pending"
|
||||
/>
|
||||
{showBreakdown ? null : <CheckStatusIcon status={summary.status} />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.pipeline")}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
summary={sectionSummary}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handleOpenPipeline}
|
||||
style={rowPressableStyle}
|
||||
disabled={!summary.url}
|
||||
testID="pr-pane-pipeline-link"
|
||||
>
|
||||
<CheckStatusIcon status={summary.status} />
|
||||
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
||||
{`Pipeline #${summary.id}`}
|
||||
</Text>
|
||||
{summary.rawStatus ? (
|
||||
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
|
||||
{summary.rawStatus}
|
||||
</Text>
|
||||
) : null}
|
||||
{summary.url ? (
|
||||
<View style={sectionKitStyles.checkTrailing}>
|
||||
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{isLoading ? (
|
||||
<Text style={sectionKitStyles.emptyText}>
|
||||
{t("workspace.git.pr.empty.loadingPipeline")}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isLoading && pipeline && pipeline.stages.length === 0 ? (
|
||||
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noJobs")}</Text>
|
||||
) : null}
|
||||
{!isLoading && pipeline && pipeline.stages.length > 0
|
||||
? pipeline.stages.map((stage) => <PipelineStageGroup key={stage.name} stage={stage} />)
|
||||
: null}
|
||||
{!isLoading && !pipeline && error ? (
|
||||
<Text style={sectionKitStyles.emptyText}>
|
||||
{t("workspace.git.pr.empty.pipelineJobsLoadFailed")}
|
||||
</Text>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) {
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.pipelineStageHeader}>
|
||||
<CheckStatusIcon status={mapPipelineStatus(stage.status)} />
|
||||
<Text style={styles.pipelineStageName} numberOfLines={1}>
|
||||
{stage.name}
|
||||
</Text>
|
||||
</View>
|
||||
{stage.jobs.map((job) => (
|
||||
<PipelineJobRow key={job.id} job={job} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
|
||||
const { t } = useTranslation();
|
||||
const handlePress = useCallback(() => {
|
||||
if (job.url) {
|
||||
void openExternalUrl(job.url);
|
||||
}
|
||||
}, [job.url]);
|
||||
const duration = formatPipelineDuration(job.durationSeconds);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
|
||||
<CheckStatusIcon status={mapPipelineStatus(job.status)} />
|
||||
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
||||
{job.name}
|
||||
</Text>
|
||||
{job.allowFailure ? (
|
||||
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
|
||||
{t("workspace.git.pr.empty.allowedToFail")}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={sectionKitStyles.checkTrailing}>
|
||||
{duration ? <Text style={sectionKitStyles.checkDuration}>{duration}</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export const gitlabForgeView = {
|
||||
id: "gitlab",
|
||||
icon: GitLabIcon,
|
||||
brandColor: {
|
||||
light: "#FC6D26",
|
||||
dark: "#FC6D26",
|
||||
},
|
||||
paneContributions: [
|
||||
definePaneContribution(GitlabMergeFactsSchema, {
|
||||
renderHeaderMeta: renderGitlabHeaderMeta,
|
||||
renderChecksSection: renderGitlabChecksSection,
|
||||
}),
|
||||
],
|
||||
} satisfies ClientForgeViewModule;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
approvalsBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
approvalsText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hoverable: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
pipelineStageHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
pipelineStageName: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
flexShrink: 1,
|
||||
},
|
||||
pipelineJobRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[6],
|
||||
minHeight: 32,
|
||||
},
|
||||
}));
|
||||
@@ -1,59 +0,0 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLIENT_FORGE_LOGIC_MODULES } from "./index";
|
||||
import { CLIENT_FORGE_VIEW_MODULES } from "./view";
|
||||
|
||||
/**
|
||||
* Adding a forge is "create forges/<id>.ts (logic) + forges/<id>.view.tsx (view)
|
||||
* + register one line in each registry". This test removes the only failure mode
|
||||
* of the explicit registries — forgetting a line — without resorting to
|
||||
* bundler-specific auto-discovery that Metro, Vite, and tsgo would each need to
|
||||
* understand differently. The logic/view split keeps logic consumers (URL
|
||||
* builders, merge-capability, native checks, and the Node e2e harness) free of
|
||||
* the client rendering stack.
|
||||
*/
|
||||
const forgesDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const dirEntries = readdirSync(forgesDir);
|
||||
|
||||
const logicModuleIds = dirEntries
|
||||
.filter((file) => /^[a-z0-9-]+\.ts$/.test(file) && file !== "index.ts" && file !== "view.ts")
|
||||
.map((file) => file.replace(/\.ts$/, ""))
|
||||
.sort();
|
||||
|
||||
const viewModuleIds = dirEntries
|
||||
.filter((file) => /^[a-z0-9-]+\.view\.tsx$/.test(file))
|
||||
.map((file) => file.replace(/\.view\.tsx$/, ""))
|
||||
.sort();
|
||||
|
||||
describe("CLIENT_FORGE_LOGIC_MODULES completeness", () => {
|
||||
it("registers every forge logic module file in the directory", () => {
|
||||
const registeredIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
|
||||
expect(registeredIds).toEqual(logicModuleIds);
|
||||
});
|
||||
|
||||
it("has no duplicate forge ids", () => {
|
||||
const ids = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLIENT_FORGE_VIEW_MODULES completeness", () => {
|
||||
it("registers every forge view module file in the directory", () => {
|
||||
const registeredIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
|
||||
expect(registeredIds).toEqual(viewModuleIds);
|
||||
});
|
||||
|
||||
it("has no duplicate forge ids", () => {
|
||||
const ids = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forge logic and view registries stay paired", () => {
|
||||
it("has a view module for every logic module and vice versa", () => {
|
||||
const logicIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
|
||||
const viewIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
|
||||
expect(logicIds).toEqual(viewIds);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { ClientForgeLogicModule, ForgeSpecificEnvelope } from "@/git/client-forge-module";
|
||||
import { codebergForgeLogic } from "./codeberg";
|
||||
import { forgejoForgeLogic } from "./forgejo";
|
||||
import { giteaForgeLogic } from "./gitea";
|
||||
import { githubForgeLogic } from "./github";
|
||||
import { gitlabForgeLogic } from "./gitlab";
|
||||
|
||||
/**
|
||||
* Pure logic registry. Import this (never the view registry) from URL builders,
|
||||
* merge-capability, and native-check derivations so those paths — and the
|
||||
* Node-based e2e harness that transitively imports them — stay free of the
|
||||
* client rendering stack (react-native, react-native-svg, unistyles).
|
||||
*/
|
||||
export const CLIENT_FORGE_LOGIC_MODULES: readonly ClientForgeLogicModule[] = [
|
||||
githubForgeLogic,
|
||||
gitlabForgeLogic,
|
||||
giteaForgeLogic,
|
||||
forgejoForgeLogic,
|
||||
codebergForgeLogic,
|
||||
];
|
||||
|
||||
export function getClientForgeLogicModule(id: string): ClientForgeLogicModule | null {
|
||||
return CLIENT_FORGE_LOGIC_MODULES.find((module) => module.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function parseClientForgeFacts(facts: unknown): ForgeSpecificEnvelope | null {
|
||||
for (const module of CLIENT_FORGE_LOGIC_MODULES) {
|
||||
const parsed = module.facts?.parse(facts);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user