mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f913736fa | ||
|
|
8f8a373eea | ||
|
|
c6df5ffd4d | ||
|
|
20e46e3e3f | ||
|
|
9e5accee3c | ||
|
|
16bbead5a4 | ||
|
|
ee3a9b013b | ||
|
|
891dd5a813 | ||
|
|
68a215d40c | ||
|
|
b897c59e9c | ||
|
|
acb8506d1f | ||
|
|
ab24070075 | ||
|
|
fab975a059 | ||
|
|
504b687f89 | ||
|
|
bbf3d0f9cc | ||
|
|
963d4f9240 | ||
|
|
e241e02afb | ||
|
|
fdee3236f7 | ||
|
|
fd7061a8b2 | ||
|
|
76e336a1be | ||
|
|
f0d7eeb98c | ||
|
|
f91a984348 | ||
|
|
cbbf6c1684 | ||
|
|
89c2fac3c6 | ||
|
|
f8dd0fc2e0 | ||
|
|
b55acfc60f | ||
|
|
a6fdcb469c | ||
|
|
5bd317205c | ||
|
|
e59c94812b | ||
|
|
1c8fabd293 | ||
|
|
717f195f0c | ||
|
|
869edcbf11 | ||
|
|
c596e058cd | ||
|
|
fa1198c2be | ||
|
|
1f253d92e2 |
+213
-20
@@ -18,6 +18,106 @@ env:
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }}
|
||||
server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }}
|
||||
desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }}
|
||||
desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }}
|
||||
app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }}
|
||||
sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }}
|
||||
playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }}
|
||||
relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }}
|
||||
cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect affected CI jobs
|
||||
id: filter
|
||||
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
|
||||
with:
|
||||
filters: |
|
||||
shared:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
- '.mise.toml'
|
||||
- '.tool-versions'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'patches/**'
|
||||
- 'scripts/**'
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.base.json'
|
||||
- 'vitest.config.ts'
|
||||
quality:
|
||||
- 'packages/**'
|
||||
- '*.cjs'
|
||||
- '*.js'
|
||||
- '*.json'
|
||||
- '*.mjs'
|
||||
- '*.ts'
|
||||
server:
|
||||
- 'packages/app/e2e/fixtures/recording.*'
|
||||
- 'packages/client/**'
|
||||
- 'packages/cli/src/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
desktop:
|
||||
- 'packages/app/**'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/desktop/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
desktop_package:
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'packages/desktop/**'
|
||||
app:
|
||||
- 'packages/app/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
sdk:
|
||||
- 'packages/client/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
playwright:
|
||||
- 'packages/app/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
relay:
|
||||
- 'packages/relay/**'
|
||||
cli:
|
||||
- 'nix/**'
|
||||
- 'packages/app/e2e/global-setup.ts'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/desktop/src/daemon/runtime-paths.ts'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
|
||||
- name: Validate CI workflow
|
||||
run: node --test scripts/ci-workflow.test.mjs
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -39,6 +139,12 @@ jobs:
|
||||
run: npx oxfmt --check .
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -63,6 +169,12 @@ jobs:
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -89,6 +201,8 @@ jobs:
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||
|
||||
server-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -97,28 +211,43 @@ jobs:
|
||||
name: server-tests (${{ matrix.os }})
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.server != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected server tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No server changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build server dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
@@ -126,6 +255,8 @@ jobs:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
desktop-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -134,49 +265,53 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
env:
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Skip unaffected desktop tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No desktop changes detected."
|
||||
|
||||
- name: Detect desktop changes
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
id: desktop_changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
desktop:
|
||||
- 'packages/desktop/**'
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Build app dependencies for desktop E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Install virtual display
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
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'
|
||||
if: env.RUN_TESTS == 'true' && 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'
|
||||
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
|
||||
with:
|
||||
name: browser-tab-bridge-e2e
|
||||
path: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
@@ -184,7 +319,11 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: >-
|
||||
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
@@ -192,7 +331,11 @@ jobs:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: >-
|
||||
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
@@ -201,6 +344,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -225,6 +374,12 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -251,6 +406,8 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -264,43 +421,57 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.playwright != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected Playwright tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No Playwright changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install Playwright browsers
|
||||
if: env.RUN_TESTS == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && matrix.desktop
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
if: env.RUN_TESTS == 'true' && failure()
|
||||
with:
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
@@ -309,6 +480,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
relay-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -330,6 +507,8 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -338,24 +517,38 @@ jobs:
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected CLI tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No CLI changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.4 - 2026-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- Set an agent's thinking level from the CLI ([#2533](https://github.com/getpaseo/paseo/pull/2533))
|
||||
- Switch projects while creating a workspace with ⌘P / Ctrl+P ([#2110](https://github.com/getpaseo/paseo/pull/2110) by [@turtleDev](https://github.com/turtleDev))
|
||||
- Open a project or workspace folder from the sidebar ([#2491](https://github.com/getpaseo/paseo/pull/2491) by [@PTK030](https://github.com/PTK030))
|
||||
- Flick up on the chat to dismiss the keyboard ([#2417](https://github.com/getpaseo/paseo/pull/2417) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Custom providers can read the agent's working directory ([#2563](https://github.com/getpaseo/paseo/pull/2563))
|
||||
|
||||
### Fixed
|
||||
|
||||
- The same repository added on more than one machine appears as one project in the sidebar ([#2565](https://github.com/getpaseo/paseo/pull/2565))
|
||||
- Idle agents keep their background work instead of shutting down when unused ([#2590](https://github.com/getpaseo/paseo/pull/2590))
|
||||
- Desktop no longer crashes at startup while restoring your file tree ([#2595](https://github.com/getpaseo/paseo/pull/2595))
|
||||
- Plan approval only shows the latest proposal ([#2534](https://github.com/getpaseo/paseo/pull/2534))
|
||||
- Opus 5 appears once in the model list, with its full 1M context ([#2497](https://github.com/getpaseo/paseo/pull/2497))
|
||||
- The context meter no longer blanks out partway through a conversation ([#2494](https://github.com/getpaseo/paseo/pull/2494) by [@theslava](https://github.com/theslava))
|
||||
- A very large working diff no longer drops your session ([#2488](https://github.com/getpaseo/paseo/pull/2488) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Interrupting Pi no longer surfaces a stream error ([#2311](https://github.com/getpaseo/paseo/pull/2311) by [@mcowger](https://github.com/mcowger))
|
||||
- Grok usage shows again in Settings ([#2353](https://github.com/getpaseo/paseo/pull/2353) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- OMP models that report no context window now load ([#2406](https://github.com/getpaseo/paseo/pull/2406) by [@astartsky](https://github.com/astartsky))
|
||||
- Paseo's own tools are available directly in OMP ([#2418](https://github.com/getpaseo/paseo/pull/2418) by [@perezd](https://github.com/perezd))
|
||||
- Codex finds the skills defined in your project ([#2423](https://github.com/getpaseo/paseo/pull/2423) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Pull request comments containing HTML render correctly ([#2432](https://github.com/getpaseo/paseo/pull/2432) by [@mcowger](https://github.com/mcowger))
|
||||
- Self-hosted forge links keep their port ([#2478](https://github.com/getpaseo/paseo/pull/2478) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- The Linux AppImage launches when opened from your desktop ([#2439](https://github.com/getpaseo/paseo/pull/2439) by [@stonegray](https://github.com/stonegray))
|
||||
- Repository search works with older versions of the GitHub CLI ([#2611](https://github.com/getpaseo/paseo/pull/2611))
|
||||
|
||||
## 0.2.3 - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
## ライセンス
|
||||
|
||||
@@ -163,6 +163,7 @@ npm run typecheck
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
## License
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
|
||||
+4
-13
@@ -21,16 +21,8 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
|
||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
||||
primed.
|
||||
|
||||
The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only
|
||||
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
|
||||
or permission, and have not been activated during the idle window are eligible. `running`,
|
||||
`initializing`, and `error` agents stay resident. An idle parent also stays resident while current
|
||||
in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated
|
||||
independently; collection does not cascade or change parentage.
|
||||
|
||||
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
|
||||
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
|
||||
runtime.
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -59,9 +51,8 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
|
||||
|
||||
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.
|
||||
|
||||
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
|
||||
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
|
||||
releases the live runtime and writes `lastStatus: closed` on the still-active record.
|
||||
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
|
||||
children.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
|
||||
|
||||
|
||||
+11
-4
@@ -4,9 +4,15 @@
|
||||
|
||||
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
|
||||
|
||||
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
||||
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
|
||||
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
|
||||
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
|
||||
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
|
||||
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
|
||||
Creation persists it with the project, and normal boot reconciliation fills it for
|
||||
older records where the field is absent—there is no migration.
|
||||
|
||||
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
||||
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
|
||||
names, and workspace foreign keys. Attached workspaces are independently refreshed
|
||||
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
||||
are observed too.
|
||||
|
||||
@@ -450,7 +456,8 @@ Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
|
||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
||||
|
||||
@@ -242,6 +242,14 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
Because the shell differs per platform, a lifecycle command that must run
|
||||
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
|
||||
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
|
||||
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
|
||||
that reads what it needs from `process.env` and invoke it as
|
||||
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
|
||||
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
||||
@@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **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. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. 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).
|
||||
- **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`.
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
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 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.
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. 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 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.
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ 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.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
|
||||
- A beta release moves only `beta`; `latest` remains on the newest stable.
|
||||
- A stable release moves both `latest` and `beta` to that stable version. This
|
||||
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
|
||||
a beta is promoted or superseded by a direct stable release.
|
||||
|
||||
## Release version decision
|
||||
|
||||
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
|
||||
@@ -76,6 +84,20 @@ 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.
|
||||
|
||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||
version for every published package. This changes dist-tags only; do not
|
||||
republish the packages:
|
||||
|
||||
```bash
|
||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||
for package in highlight relay protocol client server cli; do
|
||||
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
|
||||
done
|
||||
```
|
||||
|
||||
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
|
||||
stable release complete.
|
||||
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
|
||||
@@ -92,6 +114,7 @@ npm run version:all:patch
|
||||
npm run version:all:minor
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
# Then move npm's beta dist-tag to this stable version using the command above.
|
||||
```
|
||||
|
||||
## Beta flow
|
||||
@@ -506,6 +529,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump
|
||||
- [ ] 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
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] 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
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-DaVBk1PxsNr1AQu/mTG1Inp67pLLDmc+Ed0IZ5GyzgY=
|
||||
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35211,7 +35211,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36236,12 +36236,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.3",
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/server": "0.2.3",
|
||||
"@getpaseo/client": "0.2.4",
|
||||
"@getpaseo/protocol": "0.2.4",
|
||||
"@getpaseo/server": "0.2.4",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36488,10 +36488,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/relay": "0.2.3",
|
||||
"@getpaseo/protocol": "0.2.4",
|
||||
"@getpaseo/relay": "0.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36502,7 +36502,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36745,7 +36745,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37641,7 +37641,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37873,7 +37873,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37886,7 +37886,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38104,15 +38104,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.2.3",
|
||||
"@getpaseo/highlight": "0.2.3",
|
||||
"@getpaseo/protocol": "0.2.3",
|
||||
"@getpaseo/relay": "0.2.3",
|
||||
"@getpaseo/client": "0.2.4",
|
||||
"@getpaseo/highlight": "0.2.4",
|
||||
"@getpaseo/protocol": "0.2.4",
|
||||
"@getpaseo/relay": "0.2.4",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38649,7 +38649,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -49,9 +49,9 @@
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:client": "npm run build --workspace=@getpaseo/client",
|
||||
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
addProjectFlow,
|
||||
addProjectFlowBack,
|
||||
@@ -13,36 +13,21 @@ import {
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
openAddProjectFlow,
|
||||
waitForConnectedHost,
|
||||
} from "./helpers/add-project-flow";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import {
|
||||
addConnectedHostAndReload,
|
||||
addOfflineHostAndReload,
|
||||
waitForConnectedHost,
|
||||
} from "./helpers/hosts";
|
||||
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
const SECONDARY_HOST_ID = "add-project-flow-secondary";
|
||||
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||
|
||||
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
|
||||
const registryEntry = buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${host.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.evaluate(
|
||||
({ key, entry }) => {
|
||||
localStorage.setItem(key, JSON.stringify([entry]));
|
||||
},
|
||||
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function expectProjectDirectory(pathname: string): Promise<void> {
|
||||
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
|
||||
}
|
||||
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
test("keyboard selection chooses the second host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
|
||||
@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectId,
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectId,
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await fillComposerDraft(page, "lock test prompt");
|
||||
|
||||
@@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
@@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
||||
// user-confirmed removal proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
|
||||
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`);
|
||||
await expect(removeItem).toBeVisible({ timeout: 10_000 });
|
||||
await removeItem.click();
|
||||
}
|
||||
@@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
|
||||
|
||||
try {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
|
||||
const newWorkspaceRow = page.getByTestId(
|
||||
`sidebar-project-new-workspace-row-${workspace.projectId}`,
|
||||
`sidebar-project-new-workspace-row-${workspace.projectKey}`,
|
||||
);
|
||||
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
||||
|
||||
@@ -195,9 +195,10 @@ test.describe("Project with no workspaces persists", () => {
|
||||
test.describe("Project remove", () => {
|
||||
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
|
||||
let readdedProjectId: string | null = null;
|
||||
|
||||
try {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
@@ -206,7 +207,7 @@ test.describe("Project remove", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await removeProjectFromSidebar(page, workspace.projectId);
|
||||
await removeProjectFromSidebar(page, workspace.projectKey);
|
||||
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
@@ -220,21 +221,24 @@ test.describe("Project remove", () => {
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
expect(readded.error).toBeNull();
|
||||
expect(readded.project).not.toBeNull();
|
||||
const readdedProjectId = readded.project?.projectId ?? "";
|
||||
readdedProjectId = readded.project?.projectId ?? "";
|
||||
const readdedProjectKey = readded.project?.projectKey ?? "";
|
||||
expect(readdedProjectId).not.toBe(workspace.projectId);
|
||||
expect(readdedProjectKey).toBe(workspace.projectKey);
|
||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
|
||||
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
if (readdedProjectId) {
|
||||
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
|
||||
}
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
|
||||
return page.getByTestId(`add-project-flow-method-${method}`);
|
||||
}
|
||||
|
||||
export async function waitForConnectedHost(
|
||||
page: Page,
|
||||
input: { serverId: string; endpoint: string },
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-hosts-trigger").click();
|
||||
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
|
||||
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(host).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
|
||||
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
|
||||
await expect(currentPage).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -57,6 +57,69 @@ export async function addOfflineHostAndReload(
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function addConnectedHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; port: number; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
await addConnectedHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
|
||||
}
|
||||
|
||||
export async function addConnectedHostsAndReload(
|
||||
page: Page,
|
||||
inputs: Array<{ serverId: string; label: string; port: number }>,
|
||||
options?: { primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const connectedHosts = inputs.map((input) =>
|
||||
buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: `127.0.0.1:${input.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.evaluate(
|
||||
({ hosts, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) registry[0].label = primaryLabel;
|
||||
for (const host of hosts) {
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
hosts: connectedHosts,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: options?.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function waitForConnectedHost(
|
||||
page: Page,
|
||||
input: { serverId: string; endpoint: string },
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-hosts-trigger").click();
|
||||
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
|
||||
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(host).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
paseoHome: string;
|
||||
restart(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
paseoHome,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopProcess(child);
|
||||
|
||||
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "listProjects"
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
@@ -29,6 +30,7 @@ type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
|
||||
|
||||
export interface OpenedProject {
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
workspaceName: string;
|
||||
@@ -45,10 +47,19 @@ function requireWorkspace(payload: WorkspacePayload) {
|
||||
return payload.workspace;
|
||||
}
|
||||
|
||||
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
|
||||
async function openedProjectFromWorkspace(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspace: WorkspaceDescriptor,
|
||||
): Promise<OpenedProject> {
|
||||
const payload = await client.listProjects();
|
||||
const project = payload.projects.find((candidate) => candidate.projectId === workspace.projectId);
|
||||
if (!project?.projectKey) {
|
||||
throw new Error(`Project ${workspace.projectId} has no project key`);
|
||||
}
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectId: workspace.projectId,
|
||||
projectKey: project.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -107,7 +118,7 @@ export async function openProjectViaDaemon(
|
||||
source: { kind: "directory", path: repoPath },
|
||||
}),
|
||||
);
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
return openedProjectFromWorkspace(client, workspace);
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromDaemon(
|
||||
@@ -149,7 +160,7 @@ export async function createWorktreeViaDaemon(
|
||||
worktreeSlug: input.slug,
|
||||
});
|
||||
const workspace = requireWorkspace(payload);
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
return openedProjectFromWorkspace(client, workspace);
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceComposer(
|
||||
@@ -177,6 +188,14 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Control+P");
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search projects");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await expect(searchInput).toBeFocused();
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceProjectSelected(
|
||||
page: Page,
|
||||
projectDisplayName: string,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
|
||||
import { openProjectSettings } from "./project-settings";
|
||||
import { waitForSidebarHydration } from "./workspace-ui";
|
||||
|
||||
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
|
||||
|
||||
function projectGroup(page: Page, projectName: string): Locator {
|
||||
return page.getByRole("group", { name: projectName, exact: true });
|
||||
}
|
||||
|
||||
function projectGroupWithWorkspace(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
workspaceName: string,
|
||||
): Locator {
|
||||
return projectGroup(page, projectName).filter({ hasText: workspaceName });
|
||||
}
|
||||
|
||||
export async function openProjectDirectory(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
export async function openProjectDirectoryWithHosts(
|
||||
page: Page,
|
||||
input: {
|
||||
hosts: Array<{ serverId: string; label: string; port: number }>;
|
||||
primaryLabel?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostsAndReload(page, input.hosts, { primaryLabel: input.primaryLabel });
|
||||
for (const host of input.hosts) {
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: host.serverId,
|
||||
endpoint: `localhost:${host.port}`,
|
||||
});
|
||||
}
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
export async function expectProjectContainsWorkspaces(
|
||||
page: Page,
|
||||
input: { projectName: string; workspaceNames: string[] },
|
||||
): Promise<void> {
|
||||
const group = projectGroup(page, input.projectName);
|
||||
await expect(group).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||
for (const workspaceName of input.workspaceNames) {
|
||||
await expect(group.getByRole("button").filter({ hasText: workspaceName })).toBeVisible({
|
||||
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectSeparateProjects(
|
||||
page: Page,
|
||||
projects: Array<{ projectName: string; workspaceName: string }>,
|
||||
): Promise<void> {
|
||||
for (const project of projects) {
|
||||
await expect(
|
||||
projectGroupWithWorkspace(page, project.projectName, project.workspaceName),
|
||||
).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||
}
|
||||
}
|
||||
|
||||
export async function beginWorkspaceFromProject(page: Page, projectName: string): Promise<void> {
|
||||
const group = projectGroup(page, projectName);
|
||||
await expect(group).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||
await group.hover();
|
||||
await group.getByLabel(`Create a new workspace for ${projectName}`).click();
|
||||
await expect(page.getByRole("button", { name: "Workspace project", exact: true })).toContainText(
|
||||
projectName,
|
||||
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||
);
|
||||
}
|
||||
|
||||
export async function selectWorkspaceHost(page: Page, hostName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Host", exact: true }).click();
|
||||
await page.getByRole("button", { name: hostName, exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
|
||||
}
|
||||
|
||||
export async function createWorkspaceWithoutAgent(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectProjectWorkspaceCountForHost(
|
||||
page: Page,
|
||||
input: { projectName: string; hostName: string; count: number },
|
||||
): Promise<void> {
|
||||
const group = projectGroup(page, input.projectName);
|
||||
await expect(group.getByRole("button").filter({ hasText: input.hostName })).toHaveCount(
|
||||
input.count,
|
||||
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||
);
|
||||
}
|
||||
|
||||
export async function renameProject(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Rename project", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
|
||||
await page.getByRole("button", { name: "Save project name", exact: true }).click();
|
||||
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({
|
||||
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGroupedProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.getByRole("button", { name: "Projects", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
await openProjectSettings(page, projectName);
|
||||
}
|
||||
|
||||
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> {
|
||||
const projectSettings = page.getByRole("main");
|
||||
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click();
|
||||
await page.getByRole("button", { name: hostName, exact: true }).click();
|
||||
await expect(
|
||||
projectSettings.getByRole("button", { name: "Switch host", exact: true }),
|
||||
).toContainText(hostName);
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
|
||||
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText(
|
||||
projectName,
|
||||
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
|
||||
const projectId = new URL(page.url()).searchParams.get("projectId");
|
||||
expect(projectId).not.toBeNull();
|
||||
return projectId!;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type SessionRequest = Record<string, unknown> & { type?: string; requestId?: str
|
||||
export interface FakeScheduleHostWorkspace {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}
|
||||
@@ -120,6 +121,7 @@ export async function buildFakeScheduleHostWorkspace(
|
||||
return {
|
||||
serverId: "schedule-fake-host",
|
||||
projectId,
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
workspace: {
|
||||
...baseWorkspace,
|
||||
@@ -139,6 +141,7 @@ export async function installFakeScheduleHost(input: {
|
||||
port: string;
|
||||
serverId: string;
|
||||
workspace: Record<string, unknown>;
|
||||
project: Pick<FakeScheduleHostWorkspace, "projectId" | "projectKey" | "projectDisplayName">;
|
||||
schedules?: FakeScheduleSummary[];
|
||||
}): Promise<void> {
|
||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||
@@ -154,6 +157,7 @@ export async function installFakeScheduleHost(input: {
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
workspaceMultiplicity: true,
|
||||
projectList: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
workspaceRecovery: true,
|
||||
@@ -197,6 +201,22 @@ export async function installFakeScheduleHost(input: {
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "project.list.request":
|
||||
ws.send(
|
||||
buildSessionMessage("project.list.response", {
|
||||
requestId,
|
||||
projects: [
|
||||
{
|
||||
projectId: input.project.projectId,
|
||||
projectKey: input.project.projectKey,
|
||||
projectDisplayName: input.project.projectDisplayName,
|
||||
projectRootPath: String(input.workspace.projectRootPath),
|
||||
projectKind: "git",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_agents_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_agents_response", {
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface SeedWorkspaceDescriptor {
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
interface SeedProjectDescriptor {
|
||||
projectId: string;
|
||||
projectKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||
@@ -25,15 +30,18 @@ export interface SeedDaemonClient {
|
||||
addProject(cwd: string): Promise<{
|
||||
project: {
|
||||
projectId: string;
|
||||
projectKey?: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||
renameProject(projectId: string, customName: string | null): Promise<void>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
|
||||
createWorkspace(input: {
|
||||
source:
|
||||
| { kind: "directory"; path: string; projectId?: string }
|
||||
@@ -179,8 +187,10 @@ export interface SeededWorkspace {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
workspaceDirectory: string;
|
||||
/** Stable project identity the daemon groups workspaces under. */
|
||||
/** Host-local identity used by daemon project operations. */
|
||||
projectId: string;
|
||||
/** Opaque cross-host key used by grouped project UI. */
|
||||
projectKey: string;
|
||||
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
||||
projectDisplayName: string;
|
||||
cleanup(): Promise<void>;
|
||||
@@ -208,6 +218,11 @@ export async function seedWorkspace(options: {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
}
|
||||
const workspace = created.workspace;
|
||||
const listed = await client.listProjects();
|
||||
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
|
||||
if (!descriptor?.projectKey) {
|
||||
throw new Error(`Created project ${workspace.projectId} has no project key`);
|
||||
}
|
||||
return {
|
||||
client,
|
||||
repoPath: project.path,
|
||||
@@ -215,6 +230,7 @@ export async function seedWorkspace(options: {
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
projectId: workspace.projectId,
|
||||
projectKey: descriptor.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
|
||||
@@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
|
||||
}
|
||||
const descriptor = (await client.listProjects()).projects.find(
|
||||
(project) => project.projectId === createdWorkspace.workspace?.projectId,
|
||||
);
|
||||
const projectKey = descriptor?.projectKey;
|
||||
if (!projectKey) {
|
||||
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
promptPreview,
|
||||
@@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
||||
workspaceName: createdWorkspace.workspace.name,
|
||||
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
|
||||
projectId: createdWorkspace.workspace.projectId,
|
||||
projectKey,
|
||||
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
@@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
@@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: firstProject.projectId,
|
||||
projectKey: firstProject.projectKey,
|
||||
projectDisplayName: firstProject.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
|
||||
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: secondProject.projectId,
|
||||
projectKey: secondProject.projectKey,
|
||||
projectDisplayName: secondProject.projectDisplayName,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
openNewWorkspaceProjectPickerWithShortcut,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
@@ -106,6 +107,20 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -164,13 +179,13 @@ test.describe("New workspace entry points", () => {
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Project A's row icon opens New Workspace with A preselected.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: projectA.projectId,
|
||||
projectKey: projectA.projectKey,
|
||||
projectDisplayName: projectA.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
@@ -179,7 +194,9 @@ test.describe("New workspace entry points", () => {
|
||||
// manualProjectKey is what the reused 'new' screen must reset when the next
|
||||
// route-driven navigation targets a different project.
|
||||
await page.getByTestId("new-workspace-project-picker-trigger").click();
|
||||
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
|
||||
const optionC = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${projectC.projectKey}`,
|
||||
);
|
||||
await expect(optionC).toBeVisible({ timeout: 30_000 });
|
||||
await optionC.click();
|
||||
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
|
||||
@@ -188,7 +205,7 @@ test.describe("New workspace entry points", () => {
|
||||
// because the stale manual choice (C) was reset on the route change. If the
|
||||
// reset were missing, the trigger would still read C.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: projectB.projectId,
|
||||
projectKey: projectB.projectKey,
|
||||
projectDisplayName: projectB.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
@@ -211,8 +228,8 @@ test.describe("New workspace entry points", () => {
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open New Workspace for the non-git project via the global button, then
|
||||
// select it in the picker (the per-row icon would preselect it too).
|
||||
@@ -221,7 +238,7 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const nonGitOption = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
|
||||
`new-workspace-project-picker-option-${nonGitProject.projectKey}`,
|
||||
);
|
||||
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
|
||||
await nonGitOption.click();
|
||||
@@ -234,7 +251,7 @@ test.describe("New workspace entry points", () => {
|
||||
// Switching to the git project on the same screen reveals the Isolation row.
|
||||
await trigger.click();
|
||||
const gitOption = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${gitProject.projectId}`,
|
||||
`new-workspace-project-picker-option-${gitProject.projectKey}`,
|
||||
);
|
||||
await expect(gitOption).toBeVisible({ timeout: 30_000 });
|
||||
await gitOption.click();
|
||||
|
||||
@@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => {
|
||||
// so its handler is still registered when we cycle here.
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const localProjectIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||
|
||||
@@ -212,9 +213,13 @@ test.describe("New workspace flow", () => {
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
for (const projectId of localProjectIds) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
localProjectIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
@@ -830,6 +835,7 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
localProjectIds.add(openedProject.projectId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
@@ -881,6 +887,7 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
localProjectIds.add(openedProject.projectId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { waitForConnectedHost } from "./helpers/hosts";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
|
||||
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
|
||||
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
|
||||
dialogOpenResult: projectPickerFixture.projectPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: getServerId(),
|
||||
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||
});
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await openAddProjectFlow(page);
|
||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
|
||||
dialogOpenResult: null,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: getServerId(),
|
||||
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||
});
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await openAddProjectFlow(page);
|
||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
@@ -163,6 +163,7 @@ test.describe("Schedules", () => {
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
schedules: [
|
||||
buildFakeHostSchedule({
|
||||
id: scheduleId,
|
||||
|
||||
@@ -205,7 +205,7 @@ test.describe("Schedules project target", () => {
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
@@ -245,6 +245,7 @@ test.describe("Schedules project target", () => {
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
});
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
@@ -277,6 +278,7 @@ test.describe("Schedules project target", () => {
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
@@ -313,7 +315,7 @@ test.describe("Schedules project target", () => {
|
||||
await expectSettled(hostTrigger);
|
||||
|
||||
await projectTrigger.click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
@@ -335,8 +337,7 @@ test.describe("Schedules project target", () => {
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
await projectTrigger.click();
|
||||
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
|
||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click();
|
||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText(/select model/i);
|
||||
@@ -362,7 +363,7 @@ test.describe("Schedules project target", () => {
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(
|
||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||
|
||||
@@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => {
|
||||
|
||||
// Both projects are expandable parents — the non-git one is NOT flattened
|
||||
// into a bare workspace link.
|
||||
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Each parent shows both of its workspace rows underneath.
|
||||
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
@@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => {
|
||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||
// git project can branch off a worktree, and the non-git project can add
|
||||
// another workspace because the host supports workspaceMultiplicity.
|
||||
await projectRow(page, gitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||
await projectRow(page, gitProject.projectKey).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await projectRow(page, nonGitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
||||
await projectRow(page, nonGitProject.projectKey).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test as base } from "./fixtures";
|
||||
import {
|
||||
beginWorkspaceFromProject,
|
||||
createWorkspaceWithoutAgent,
|
||||
expectProjectContainsWorkspaces,
|
||||
expectProjectSettingsName,
|
||||
expectProjectWorkspaceCountForHost,
|
||||
expectSeparateProjects,
|
||||
openGroupedProjectSettings,
|
||||
openProjectDirectory,
|
||||
openProjectDirectoryWithHosts,
|
||||
renameProject,
|
||||
selectWorkspaceHost,
|
||||
switchProjectSettingsHost,
|
||||
} from "./helpers/project-grouping";
|
||||
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||
const LEGACY_PRIMARY_HOST_LABEL = "Legacy Primary Host";
|
||||
const LEGACY_SECONDARY_HOST_LABEL = "Legacy Secondary Host";
|
||||
const GROUPED_PROJECT_NAME = "paseo-e2e/grouped-project";
|
||||
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
|
||||
const SUBDIRECTORY = path.join("packages", "app");
|
||||
const REPO_FILES = [{ path: path.join(SUBDIRECTORY, "package.json"), content: "{}\n" }];
|
||||
|
||||
interface HostConnection {
|
||||
serverId: string;
|
||||
label: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface ProjectDirectoryScenario {
|
||||
hosts: HostConnection[];
|
||||
primaryLabel?: string;
|
||||
}
|
||||
|
||||
interface CreatedProject {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
async function createProject(
|
||||
client: SeedDaemonClient,
|
||||
input: {
|
||||
projectPath: string;
|
||||
serverId: string;
|
||||
workspaceName: string;
|
||||
projectName?: string;
|
||||
},
|
||||
): Promise<CreatedProject> {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: input.projectPath },
|
||||
title: input.workspaceName,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create project on ${input.serverId}`);
|
||||
}
|
||||
if (input.projectName) {
|
||||
await client.renameProject(created.workspace.projectId, input.projectName);
|
||||
}
|
||||
return { projectId: created.workspace.projectId };
|
||||
}
|
||||
|
||||
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
|
||||
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
|
||||
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
for (const project of projects) delete project.projectKey;
|
||||
await writeFile(projectsPath, JSON.stringify(projects));
|
||||
}
|
||||
|
||||
const test = base.extend<{
|
||||
crossHostProject: ProjectDirectoryScenario;
|
||||
reconciledCrossHostProject: ProjectDirectoryScenario;
|
||||
rootAndSubdirectoryProjects: ProjectDirectoryScenario;
|
||||
crossHostSubdirectoryProject: ProjectDirectoryScenario;
|
||||
sameHostClones: ProjectDirectoryScenario;
|
||||
}>({
|
||||
crossHostProject: async ({ page: _page }, provide) => {
|
||||
const secondaryHost = await startIsolatedHostDaemon("project-grouping-secondary");
|
||||
const primaryRepo = await createTempGitRepo("grouped-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const primaryClient = await connectSeedClient();
|
||||
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||
let primary: CreatedProject | null = null;
|
||||
let secondary: CreatedProject | null = null;
|
||||
|
||||
try {
|
||||
primary = await createProject(primaryClient, {
|
||||
projectPath: primaryRepo.path,
|
||||
serverId: getServerId(),
|
||||
workspaceName: "Primary workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
secondary = await createProject(secondaryClient, {
|
||||
projectPath: secondaryRepo.path,
|
||||
serverId: secondaryHost.serverId,
|
||||
workspaceName: "Secondary workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await provide({
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
hosts: [
|
||||
{
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
|
||||
if (secondary)
|
||||
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryClient.close().catch(() => undefined);
|
||||
await primaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryHost.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
|
||||
reconciledCrossHostProject: async ({ page: _page }, provide) => {
|
||||
const primaryHost = await startIsolatedHostDaemon("project-grouping-legacy-primary");
|
||||
const secondaryHost = await startIsolatedHostDaemon("project-grouping-legacy-secondary");
|
||||
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const primaryClient = await connectSeedClient({ port: primaryHost.port });
|
||||
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||
|
||||
try {
|
||||
await createProject(primaryClient, {
|
||||
projectPath: primaryRepo.path,
|
||||
serverId: primaryHost.serverId,
|
||||
workspaceName: "Recovered primary workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await createProject(secondaryClient, {
|
||||
projectPath: secondaryRepo.path,
|
||||
serverId: secondaryHost.serverId,
|
||||
workspaceName: "Recovered secondary workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await primaryClient.close();
|
||||
await secondaryClient.close();
|
||||
await removePersistedProjectKeys(primaryHost);
|
||||
await removePersistedProjectKeys(secondaryHost);
|
||||
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
|
||||
|
||||
await provide({
|
||||
hosts: [
|
||||
{
|
||||
serverId: primaryHost.serverId,
|
||||
label: LEGACY_PRIMARY_HOST_LABEL,
|
||||
port: primaryHost.port,
|
||||
},
|
||||
{
|
||||
serverId: secondaryHost.serverId,
|
||||
label: LEGACY_SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryClient.close().catch(() => undefined);
|
||||
await primaryHost.close().catch(() => undefined);
|
||||
await secondaryHost.close().catch(() => undefined);
|
||||
await primaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryRepo.cleanup().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
|
||||
rootAndSubdirectoryProjects: async ({ page: _page }, provide) => {
|
||||
const repo = await createTempGitRepo("grouped-root-subdir-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
files: REPO_FILES,
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
const projects: CreatedProject[] = [];
|
||||
|
||||
try {
|
||||
projects.push(
|
||||
await createProject(client, {
|
||||
projectPath: repo.path,
|
||||
serverId: getServerId(),
|
||||
workspaceName: "Repository root workspace",
|
||||
projectName: "Repository root project",
|
||||
}),
|
||||
);
|
||||
projects.push(
|
||||
await createProject(client, {
|
||||
projectPath: path.join(repo.path, SUBDIRECTORY),
|
||||
serverId: getServerId(),
|
||||
workspaceName: "App package workspace",
|
||||
projectName: "App package project",
|
||||
}),
|
||||
);
|
||||
await provide({ hosts: [] });
|
||||
} finally {
|
||||
for (const project of projects) {
|
||||
await client.removeProject(project.projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
|
||||
crossHostSubdirectoryProject: async ({ page: _page }, provide) => {
|
||||
const secondaryHost = await startIsolatedHostDaemon("project-grouping-subdir-secondary");
|
||||
const primaryRepo = await createTempGitRepo("grouped-subdir-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
files: REPO_FILES,
|
||||
});
|
||||
const secondaryRepo = await createTempGitRepo("grouped-subdir-secondary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
files: REPO_FILES,
|
||||
});
|
||||
const primaryClient = await connectSeedClient();
|
||||
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||
let primary: CreatedProject | null = null;
|
||||
let secondary: CreatedProject | null = null;
|
||||
|
||||
try {
|
||||
primary = await createProject(primaryClient, {
|
||||
projectPath: path.join(primaryRepo.path, SUBDIRECTORY),
|
||||
serverId: getServerId(),
|
||||
workspaceName: "Primary app workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
secondary = await createProject(secondaryClient, {
|
||||
projectPath: path.join(secondaryRepo.path, SUBDIRECTORY),
|
||||
serverId: secondaryHost.serverId,
|
||||
workspaceName: "Secondary app workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await provide({
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
hosts: [
|
||||
{
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
|
||||
if (secondary)
|
||||
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryClient.close().catch(() => undefined);
|
||||
await primaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryHost.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
|
||||
sameHostClones: async ({ page: _page }, provide) => {
|
||||
const firstRepo = await createTempGitRepo("grouped-clone-first-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const secondRepo = await createTempGitRepo("grouped-clone-second-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
const projects: CreatedProject[] = [];
|
||||
|
||||
try {
|
||||
projects.push(
|
||||
await createProject(client, {
|
||||
projectPath: firstRepo.path,
|
||||
serverId: getServerId(),
|
||||
workspaceName: "First clone workspace",
|
||||
projectName: "First clone",
|
||||
}),
|
||||
);
|
||||
projects.push(
|
||||
await createProject(client, {
|
||||
projectPath: secondRepo.path,
|
||||
serverId: getServerId(),
|
||||
workspaceName: "Second clone workspace",
|
||||
projectName: "Second clone",
|
||||
}),
|
||||
);
|
||||
await provide({ hosts: [] });
|
||||
} finally {
|
||||
for (const project of projects) {
|
||||
await client.removeProject(project.projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await firstRepo.cleanup().catch(() => undefined);
|
||||
await secondRepo.cleanup().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function openScenario(
|
||||
page: Parameters<typeof openProjectDirectory>[0],
|
||||
scenario: ProjectDirectoryScenario,
|
||||
): Promise<void> {
|
||||
if (scenario.hosts.length === 0) {
|
||||
await openProjectDirectory(page);
|
||||
return;
|
||||
}
|
||||
await openProjectDirectoryWithHosts(page, scenario);
|
||||
}
|
||||
|
||||
test.describe("Sidebar project grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("groups projects with the same Git remote across hosts", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await expectProjectContainsWorkspaces(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
workspaceNames: ["Primary workspace", "Secondary workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("groups persisted projects missing project keys after boot", async ({
|
||||
page,
|
||||
reconciledCrossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, reconciledCrossHostProject);
|
||||
await expectProjectContainsWorkspaces(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
workspaceNames: ["Recovered primary workspace", "Recovered secondary workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a repository root and its subdirectory as separate projects", async ({
|
||||
page,
|
||||
rootAndSubdirectoryProjects,
|
||||
}) => {
|
||||
await openScenario(page, rootAndSubdirectoryProjects);
|
||||
await expectSeparateProjects(page, [
|
||||
{ projectName: "Repository root project", workspaceName: "Repository root workspace" },
|
||||
{ projectName: "App package project", workspaceName: "App package workspace" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("groups the same repository subdirectory across hosts", async ({
|
||||
page,
|
||||
crossHostSubdirectoryProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostSubdirectoryProject);
|
||||
await expectProjectContainsWorkspaces(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
workspaceNames: ["Primary app workspace", "Secondary app workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps two clones of the same repository on one host separate", async ({
|
||||
page,
|
||||
sameHostClones,
|
||||
}) => {
|
||||
await openScenario(page, sameHostClones);
|
||||
await expectSeparateProjects(page, [
|
||||
{ projectName: "First clone", workspaceName: "First clone workspace" },
|
||||
{ projectName: "Second clone", workspaceName: "Second clone workspace" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME);
|
||||
await renameProject(page, "Primary-only project name");
|
||||
await expectProjectSettingsName(page, "Primary-only project name");
|
||||
await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL);
|
||||
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
|
||||
});
|
||||
|
||||
test("creates a workspace on the selected host from a grouped project", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||
await selectWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
await createWorkspaceWithoutAgent(page);
|
||||
await expectProjectWorkspaceCountForHost(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
|
||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click();
|
||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||
|
||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
|
||||
@@ -32,8 +32,12 @@ interface RestartDaemonClient {
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceDirectory: string;
|
||||
projectId: string;
|
||||
}>;
|
||||
}>;
|
||||
listProjects(): Promise<{
|
||||
projects: Array<{ projectId: string; projectKey?: string }>;
|
||||
}>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
agent: {
|
||||
@@ -461,9 +465,16 @@ test.describe("Workspace model restart regressions", () => {
|
||||
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
||||
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
|
||||
|
||||
const reconciledProjectKey = (await client.listProjects()).projects.find(
|
||||
(project) => project.projectId === seeded.projectId,
|
||||
);
|
||||
if (!reconciledProjectKey?.projectKey) {
|
||||
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
|
||||
}
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: reconciledProjectKey.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
|
||||
@@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
try {
|
||||
const project = {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
try {
|
||||
const project = {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
};
|
||||
|
||||
@@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
try {
|
||||
const project = {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
};
|
||||
|
||||
@@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
// Model B: a non-git project is an expandable parent like any other, with
|
||||
// its single workspace already rendered as its own row underneath.
|
||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
|
||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
|
||||
@@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
// Both the original and the new workspace render as distinct rows under
|
||||
// the same expandable parent.
|
||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
|
||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface GithubRepositoryChoice {
|
||||
cloneUrl: string;
|
||||
cloneProtocol?: "https" | "ssh";
|
||||
description: string | null;
|
||||
visibility: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,6 @@ export function buildManualGithubRepositoryChoices(query: string): GithubReposit
|
||||
nameWithOwner: identity?.repo ?? remoteName,
|
||||
cloneUrl: repo,
|
||||
description: "Clone this repository URL",
|
||||
visibility: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
@@ -116,7 +115,6 @@ export function buildManualGithubRepositoryChoices(query: string): GithubReposit
|
||||
cloneUrl: nameWithOwner,
|
||||
cloneProtocol,
|
||||
description: `Clone owner/repo via ${cloneProtocol.toUpperCase()}`,
|
||||
visibility: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
type ScrollKeyboardDismissEvent,
|
||||
type ScrollKeyboardDismissGesture,
|
||||
} from "./model";
|
||||
|
||||
interface Point {
|
||||
ts: number;
|
||||
y: number;
|
||||
nativeTs?: number | null;
|
||||
}
|
||||
|
||||
function event(point: Point): ScrollKeyboardDismissEvent {
|
||||
return {
|
||||
timeStamp: point.ts,
|
||||
nativeEvent: {
|
||||
contentOffset: { y: point.y },
|
||||
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
|
||||
return points.reduce(
|
||||
(gesture, point) => recordScroll(gesture, event(point)),
|
||||
beginDrag(event(start)),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
|
||||
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
|
||||
}
|
||||
|
||||
describe("scroll keyboard dismissal", () => {
|
||||
it("keeps the keyboard up for a slow read-scroll", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 8 },
|
||||
{ ts: 1080, y: 16 },
|
||||
{ ts: 1120, y: 24 },
|
||||
],
|
||||
{ ts: 1160, y: 32 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("dismisses for an upward flick", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a fast-but-decelerating drag below the release threshold", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1100, y: 500 },
|
||||
{ ts: 1200, y: 590 },
|
||||
{ ts: 1270, y: 598 },
|
||||
],
|
||||
{ ts: 1300, y: 600 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the whole drag when the gesture is too short to sample", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
|
||||
});
|
||||
|
||||
it("steps back a sample when release lands immediately after one", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 120 },
|
||||
{ ts: 1075, y: 225 },
|
||||
],
|
||||
{ ts: 1080, y: 240 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when release has no measurable time span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores scroll events that arrive before the minimum sample span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 5000, nativeTs: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 5001, nativeTs: 1200, y: 40 },
|
||||
{ ts: 5002, nativeTs: 1400, y: 80 },
|
||||
],
|
||||
{ ts: 5003, nativeTs: 1600, y: 120 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to synthetic event time when native time is absent", () => {
|
||||
expect(
|
||||
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
|
||||
ts: 1080,
|
||||
nativeTs: null,
|
||||
y: 240,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores scroll and release events without a matching drag", () => {
|
||||
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
|
||||
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
|
||||
});
|
||||
|
||||
it("returns to idle after release", () => {
|
||||
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
|
||||
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Pure state machine for the chat history's flick-to-dismiss gesture.
|
||||
*
|
||||
* Native keyboardDismissMode cannot express this interaction: "on-drag"
|
||||
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
|
||||
* inverted list. We therefore classify the list's existing scroll gesture at
|
||||
* release instead of introducing a competing pan recognizer.
|
||||
*/
|
||||
|
||||
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
|
||||
const RELEASE_SAMPLE_MIN_MS = 30;
|
||||
|
||||
/**
|
||||
* The subset of a native scroll event used by the classifier. React Native's
|
||||
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
|
||||
*/
|
||||
export interface ScrollKeyboardDismissEvent {
|
||||
timeStamp: number;
|
||||
nativeEvent: {
|
||||
contentOffset: { y: number };
|
||||
timestamp?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DragSamples {
|
||||
startTs: number;
|
||||
startY: number;
|
||||
sampleTs: number;
|
||||
sampleY: number;
|
||||
previousSampleTs: number;
|
||||
previousSampleY: number;
|
||||
}
|
||||
|
||||
export type ScrollKeyboardDismissGesture =
|
||||
| { phase: "idle" }
|
||||
| { phase: "dragging"; samples: DragSamples };
|
||||
|
||||
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
|
||||
phase: "idle",
|
||||
});
|
||||
|
||||
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
|
||||
const nativeTimestamp = event.nativeEvent.timestamp;
|
||||
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
|
||||
return nativeTimestamp;
|
||||
}
|
||||
return event.timeStamp;
|
||||
}
|
||||
|
||||
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: timestamp,
|
||||
startY: offsetY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: offsetY,
|
||||
previousSampleTs: 0,
|
||||
previousSampleY: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordScroll(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): ScrollKeyboardDismissGesture {
|
||||
if (gesture.phase === "idle") {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const { samples } = gesture;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: samples.startTs,
|
||||
startY: samples.startY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: event.nativeEvent.contentOffset.y,
|
||||
previousSampleTs: samples.sampleTs,
|
||||
previousSampleY: samples.sampleY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseDrag(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
|
||||
if (gesture.phase === "idle") {
|
||||
return { gesture, shouldDismiss: false };
|
||||
}
|
||||
|
||||
const releaseTs = resolveEventTimeMs(event);
|
||||
const releaseY = event.nativeEvent.contentOffset.y;
|
||||
const { samples } = gesture;
|
||||
|
||||
// The release event carries the gesture's true endpoint. The final onScroll
|
||||
// event can still be stale when a short flick lands.
|
||||
let spanStartTs = samples.startTs;
|
||||
let spanStartY = samples.startY;
|
||||
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
|
||||
spanStartTs = samples.sampleTs;
|
||||
spanStartY = samples.sampleY;
|
||||
} else if (samples.previousSampleTs > 0) {
|
||||
spanStartTs = samples.previousSampleTs;
|
||||
spanStartY = samples.previousSampleY;
|
||||
}
|
||||
|
||||
const spanDurationMs = releaseTs - spanStartTs;
|
||||
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
|
||||
|
||||
return {
|
||||
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
TextInput,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
} from "./model";
|
||||
|
||||
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
|
||||
|
||||
/**
|
||||
* Owns the chat history's flick-to-dismiss behavior. The native stream only
|
||||
* forwards the FlatList scroll lifecycle; removing this hook and those three
|
||||
* calls removes the feature completely.
|
||||
*/
|
||||
export function useScrollKeyboardDismiss() {
|
||||
const { shift } = useKeyboardShift();
|
||||
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
|
||||
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = beginDrag(event);
|
||||
});
|
||||
|
||||
const onScroll = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = recordScroll(gestureRef.current, event);
|
||||
});
|
||||
|
||||
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
|
||||
const release = releaseDrag(gestureRef.current, event);
|
||||
gestureRef.current = release.gesture;
|
||||
|
||||
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
|
||||
// second calculation on JS, this prevents a hardware keyboard's focused
|
||||
// composer from being blurred when no software keyboard occupies space.
|
||||
if (!release.shouldDismiss || shift.value <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep blur and dismiss paired: this exact sequence was validated on a
|
||||
// physical Android device to clear both input focus and the IME inset.
|
||||
const focusedInput = TextInput.State.currentlyFocusedInput();
|
||||
if (focusedInput) {
|
||||
TextInput.State.blurTextInput(focusedInput);
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
});
|
||||
|
||||
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import {
|
||||
createStreamStrategy,
|
||||
@@ -52,7 +53,6 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
};
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
@@ -110,6 +110,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const isUserScrollActiveRef = useRef(false);
|
||||
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
|
||||
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
@@ -335,6 +336,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
const previousOffsetY = scrollOffsetYRef.current;
|
||||
scrollOffsetYRef.current = contentOffset.y;
|
||||
scrollKeyboardDismiss.onScroll(event);
|
||||
|
||||
streamViewportMetricsRef.current = {
|
||||
contentHeight: Math.max(0, contentSize.height),
|
||||
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||
@@ -365,7 +368,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent(() => {
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
@@ -373,6 +376,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
bottomAnchorController.beginUserScroll();
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
@@ -381,6 +385,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// gesture position now because layout may move the viewport in the meantime.
|
||||
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const isNearBottom = isScrollEventNearBottom(event);
|
||||
scrollKeyboardDismiss.onScrollEndDrag(event);
|
||||
|
||||
clearPendingUserScrollEnd();
|
||||
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
|
||||
userScrollEndFrameIdRef.current = null;
|
||||
|
||||
@@ -883,7 +883,7 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/index" />
|
||||
<Stack.Screen name="settings/[section]" />
|
||||
<Stack.Screen name="settings/projects/index" />
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="settings/projects/[serverId]/[projectId]" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default function SettingsProjectDetailRoute() {
|
||||
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
|
||||
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
|
||||
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
|
||||
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsProjectDetailRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
projectId?: string | string[];
|
||||
}>();
|
||||
const serverId = normalizeProjectSettingsRouteId(params.serverId);
|
||||
const projectId = normalizeProjectSettingsRouteId(params.projectId);
|
||||
const view = useMemo(
|
||||
() => ({ kind: "project" as const, serverId, projectId }),
|
||||
[projectId, serverId],
|
||||
);
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
}
|
||||
@@ -365,7 +365,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const recommendedPaths = useRecommendedProjectPaths(hostId);
|
||||
const openProject = useOpenProject(hostId);
|
||||
const cloneGithubProject = useCloneGithubProject(hostId);
|
||||
const addEmptyProject = useSessionStore((store) => store.addEmptyProject);
|
||||
const upsertProject = useSessionStore((store) => store.upsertProject);
|
||||
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const submissionInFlightRef = useRef(false);
|
||||
@@ -644,7 +644,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
title: repository.cloneProtocol
|
||||
? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}`
|
||||
: repository.nameWithOwner,
|
||||
subtitle: repository.description ?? repository.visibility,
|
||||
subtitle: repository.description,
|
||||
icon: Github,
|
||||
testID: `add-project-flow-repository-${repository.id}`,
|
||||
select: () =>
|
||||
@@ -735,7 +735,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
registerProjectDescriptor({
|
||||
serverId: page.hostId,
|
||||
project: payload.project,
|
||||
addEmptyProject,
|
||||
upsertProject,
|
||||
setHasHydratedWorkspaces,
|
||||
});
|
||||
openNewWorkspaceForProject(page.hostId, payload.project);
|
||||
@@ -749,7 +749,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
} finally {
|
||||
submissionInFlightRef.current = false;
|
||||
}
|
||||
}, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]);
|
||||
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
|
||||
|
||||
const submitActive = useCallback(() => {
|
||||
if (page.kind === "new-directory-name") {
|
||||
|
||||
@@ -24,16 +24,28 @@ import {
|
||||
} from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
ExplorerDirectory,
|
||||
ExplorerEntry,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||
import {
|
||||
flattenExplorerTree,
|
||||
reconcileRestoredExpandedPaths,
|
||||
restoreExpandedDirectories,
|
||||
setExpandedDirectoryPath,
|
||||
showHiddenFilesAndRestoreExpandedDirectories,
|
||||
type ExplorerTreeRow,
|
||||
} from "@/file-explorer/tree";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
@@ -83,7 +95,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
||||
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
|
||||
}
|
||||
|
||||
function treeRowKeyExtractor(row: TreeRow) {
|
||||
function treeRowKeyExtractor(row: ExplorerTreeRow) {
|
||||
return row.entry.path;
|
||||
}
|
||||
|
||||
@@ -199,11 +211,6 @@ interface FileExplorerPaneProps {
|
||||
onAddToChat?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
entry: ExplorerEntry;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function FileExplorerPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -263,7 +270,7 @@ export function FileExplorerPane({
|
||||
[isExplorerLoading, pendingRequest],
|
||||
);
|
||||
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
@@ -276,9 +283,19 @@ export function FileExplorerPane({
|
||||
hasWorkspaceScope,
|
||||
hasInitializedRef,
|
||||
workspaceStateKey,
|
||||
persistedExpandedPaths: expandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
}, [
|
||||
expandedPaths,
|
||||
hasWorkspaceScope,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
showHiddenFiles,
|
||||
workspaceStateKey,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) =>
|
||||
@@ -351,11 +368,42 @@ export function FileExplorerPane({
|
||||
|
||||
const handleToggleHiddenFiles = useCallback(() => {
|
||||
const willShow = !usePanelStore.getState().explorerShowHiddenFiles;
|
||||
toggleExplorerShowHiddenFiles();
|
||||
if (willShow) {
|
||||
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
|
||||
if (!willShow) {
|
||||
toggleExplorerShowHiddenFiles();
|
||||
return;
|
||||
}
|
||||
}, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]);
|
||||
const rootDirectory = directories.get(".");
|
||||
if (!rootDirectory || !workspaceStateKey) {
|
||||
toggleExplorerShowHiddenFiles();
|
||||
return;
|
||||
}
|
||||
void showHiddenFilesAndRestoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths: expandedPaths,
|
||||
showHiddenFiles: toggleExplorerShowHiddenFiles,
|
||||
requestDirectoryListing: (path) =>
|
||||
requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
}),
|
||||
}).then((restoredPaths) => {
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||
reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths: expandedPaths,
|
||||
currentExpandedPaths: new Set(currentPaths),
|
||||
restoredExpandedPaths: restoredPaths,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
});
|
||||
}, [
|
||||
directories,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
toggleExplorerShowHiddenFiles,
|
||||
workspaceStateKey,
|
||||
]);
|
||||
|
||||
const refreshExplorer = useCallback(
|
||||
() =>
|
||||
@@ -387,7 +435,7 @@ export function FileExplorerPane({
|
||||
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
|
||||
|
||||
const treeRows = useMemo(
|
||||
() => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }),
|
||||
() => flattenExplorerTree({ directories, expandedPaths, sortOption, showHiddenFiles }),
|
||||
[directories, expandedPaths, showHiddenFiles, sortOption],
|
||||
);
|
||||
|
||||
@@ -400,7 +448,7 @@ export function FileExplorerPane({
|
||||
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
|
||||
|
||||
const renderTreeRow = useCallback(
|
||||
(info: ListRenderItemInfo<TreeRow>) => (
|
||||
(info: ListRenderItemInfo<ExplorerTreeRow>) => (
|
||||
<TreeRowDispatcher
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
@@ -480,11 +528,11 @@ interface FileExplorerPaneContentProps {
|
||||
error: string | null;
|
||||
showInitialLoading: boolean;
|
||||
showBackFromError: boolean;
|
||||
treeRows: TreeRow[];
|
||||
treeRows: ExplorerTreeRow[];
|
||||
currentSortLabel: string;
|
||||
isRefreshFetching: boolean;
|
||||
treeListRef: RefObject<FlatList<TreeRow> | null>;
|
||||
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => ReactElement;
|
||||
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
|
||||
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
|
||||
handleSortCycle: () => void;
|
||||
handleToggleHiddenFiles: () => void;
|
||||
handleRefresh: () => void;
|
||||
@@ -637,71 +685,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
||||
const sorted = [...entries];
|
||||
sorted.sort((a, b) => {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === "directory" ? -1 : 1;
|
||||
}
|
||||
switch (sortOption) {
|
||||
case "name":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "modified":
|
||||
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
|
||||
case "size":
|
||||
return b.size - a.size;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function buildTreeRows({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
path,
|
||||
depth,
|
||||
}: {
|
||||
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
|
||||
expandedPaths: Set<string>;
|
||||
sortOption: SortOption;
|
||||
showHiddenFiles: boolean;
|
||||
path: string;
|
||||
depth: number;
|
||||
}): TreeRow[] {
|
||||
const directory = directories.get(path);
|
||||
if (!directory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: TreeRow[] = [];
|
||||
const entries = sortEntries(
|
||||
filterVisibleExplorerEntries(directory.entries, showHiddenFiles),
|
||||
sortOption,
|
||||
);
|
||||
|
||||
for (const entry of entries) {
|
||||
rows.push({ entry, depth });
|
||||
if (entry.kind === "directory" && expandedPaths.has(entry.path)) {
|
||||
rows.push(
|
||||
...buildTreeRows({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
path: entry.path,
|
||||
depth: depth + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function deriveExplorerFields(state: AgentFileExplorerState | undefined) {
|
||||
return {
|
||||
directories:
|
||||
@@ -751,30 +734,6 @@ function resolveCurrentSortLabel(
|
||||
return labels[sortOption] ?? labels.name;
|
||||
}
|
||||
|
||||
function resolveTreeRows({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
}: {
|
||||
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
|
||||
expandedPaths: Set<string>;
|
||||
sortOption: SortOption;
|
||||
showHiddenFiles: boolean;
|
||||
}): TreeRow[] {
|
||||
if (!directories.get(".")) {
|
||||
return [];
|
||||
}
|
||||
return buildTreeRows({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
path: ".",
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDirectory({
|
||||
entry,
|
||||
workspaceStateKey,
|
||||
@@ -786,26 +745,25 @@ function toggleDirectory({
|
||||
entry: ExplorerEntry;
|
||||
workspaceStateKey: string | null;
|
||||
expandedPaths: Set<string>;
|
||||
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
|
||||
directories: Map<string, ExplorerDirectory>;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<boolean>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: string[]) => void;
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): void {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const isExpanded = expandedPaths.has(entry.path);
|
||||
if (isExpanded) {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
|
||||
if (!directories.has(entry.path)) {
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||
setExpandedDirectoryPath({
|
||||
currentExpandedPaths: currentPaths,
|
||||
directoryPath: entry.path,
|
||||
expanded: !isExpanded,
|
||||
}),
|
||||
);
|
||||
if (!isExpanded && !directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
@@ -827,7 +785,7 @@ function TreeRowDispatcher({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
info: ListRenderItemInfo<TreeRow>;
|
||||
info: ListRenderItemInfo<ExplorerTreeRow>;
|
||||
expandedPaths: Set<string>;
|
||||
selectedEntryPath: string | null;
|
||||
isDirectoryLoading: (path: string) => boolean;
|
||||
@@ -865,54 +823,59 @@ async function initializeExplorer({
|
||||
hasWorkspaceScope,
|
||||
hasInitializedRef,
|
||||
workspaceStateKey,
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
}: {
|
||||
hasWorkspaceScope: boolean;
|
||||
hasInitializedRef: RefObject<boolean>;
|
||||
workspaceStateKey: string | null;
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
showHiddenFiles: boolean;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<boolean>;
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): Promise<void> {
|
||||
if (!hasWorkspaceScope || hasInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasInitializedRef.current = true;
|
||||
const succeeded = await requestDirectoryListing(".", {
|
||||
const rootDirectory = await requestDirectoryListing(".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
if (!succeeded) {
|
||||
if (!rootDirectory) {
|
||||
hasInitializedRef.current = false;
|
||||
return;
|
||||
}
|
||||
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
|
||||
}
|
||||
|
||||
function requestPersistedExpandedPaths({
|
||||
workspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
}: {
|
||||
workspaceStateKey: string | null;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<boolean>;
|
||||
}): void {
|
||||
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
|
||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (!persistedPaths) {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) {
|
||||
void requestDirectoryListing(path, {
|
||||
|
||||
const restoredPaths = await restoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing: (path) =>
|
||||
requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
const hiddenPersistedPaths = showHiddenFiles
|
||||
? []
|
||||
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
|
||||
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||
reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths,
|
||||
currentExpandedPaths: new Set(currentPaths),
|
||||
restoredExpandedPaths: restoredPathsWithHidden,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshExplorerDirectories({
|
||||
@@ -925,7 +888,7 @@ async function refreshExplorerDirectories({
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<boolean>;
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
}): Promise<null> {
|
||||
if (!hasWorkspaceScope) {
|
||||
return null;
|
||||
|
||||
@@ -296,6 +296,16 @@ describe("splitHtmlishMarkdown", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes HTML table cells and inline formatting into markdown", () => {
|
||||
expect(
|
||||
normalizeHtmlishMarkdown(
|
||||
"<table><tr><td><strong>Score</strong>: 78</td></tr><tr><td><strong>No security concerns identified</strong></td></tr><tr><td><strong>Recommended focus areas for review</strong></td><td>Bearer authentication</td></tr></table>",
|
||||
),
|
||||
).toBe(
|
||||
"\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves complex code tags inert instead of parsing HTML", () => {
|
||||
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
|
||||
'<code onclick="evil()"><script>x</script></code>',
|
||||
|
||||
@@ -30,6 +30,15 @@ const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
const VOID_HTML_TAGS = new Set(["br", "img"]);
|
||||
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
|
||||
b: ["**", "**"],
|
||||
del: ["~~", "~~"],
|
||||
em: ["*", "*"],
|
||||
i: ["*", "*"],
|
||||
s: ["~~", "~~"],
|
||||
strike: ["~~", "~~"],
|
||||
strong: ["**", "**"],
|
||||
};
|
||||
|
||||
interface ProtectedMarkdownRange {
|
||||
start: number;
|
||||
@@ -313,36 +322,83 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
if (token.name === "a") {
|
||||
output += renderLinkToken(token, children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "sub") {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
output += `\`${renderInlineTokens(children)}\``;
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
const rawTag = token.raw;
|
||||
const tagName = token.name;
|
||||
if (isHeadingTag(token)) {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
|
||||
output += renderHtmlTag(token, children);
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
if (token.name === "a") {
|
||||
return renderLinkToken(token, children);
|
||||
}
|
||||
if (token.name === "sub" || isHeadingTagName(token.name)) {
|
||||
return renderInlineTokens(children);
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
return `\`${renderInlineTokens(children)}\``;
|
||||
}
|
||||
const wrapper = MARKDOWN_TAG_WRAPPERS[token.name];
|
||||
if (wrapper) {
|
||||
return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`;
|
||||
}
|
||||
if (token.name === "table") {
|
||||
return renderTableTokens(children);
|
||||
}
|
||||
if (token.name === "p" || token.name === "div") {
|
||||
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
|
||||
}
|
||||
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
|
||||
}
|
||||
|
||||
function renderTableTokens(tokens: HtmlToken[]): string {
|
||||
const rows: string[] = [];
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
if (!isOpenTag(tokens[index], "tr")) {
|
||||
continue;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, index, "tr");
|
||||
if (closeIndex === null) {
|
||||
continue;
|
||||
}
|
||||
const cells = renderTableCells(tokens.slice(index + 1, closeIndex));
|
||||
if (cells.length === 1) {
|
||||
rows.push(`- ${cells[0]}`);
|
||||
} else if (cells.length > 1) {
|
||||
const label =
|
||||
cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`;
|
||||
rows.push(`- ${label}: ${cells.slice(1).join(" ")}`);
|
||||
}
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return rows.length > 0 ? `\n${rows.join("\n")}\n` : "";
|
||||
}
|
||||
|
||||
function renderTableCells(tokens: HtmlToken[]): string[] {
|
||||
const cells: string[] = [];
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) {
|
||||
continue;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, index, token.name);
|
||||
if (closeIndex === null) {
|
||||
continue;
|
||||
}
|
||||
const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim();
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function renderImageToken(token: HtmlTagToken): string {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
if (!image) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -164,9 +165,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
setHostProfiles([makeHost()]);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: ["project-a", "project-b"],
|
||||
|
||||
@@ -115,6 +115,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
resolveSidebarProjectIconTarget,
|
||||
resolveSidebarProjectLocalPath,
|
||||
type SidebarProjectHostTarget,
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
@@ -131,6 +132,8 @@ import {
|
||||
getIsElectron,
|
||||
} from "@/constants/platform";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||
|
||||
@@ -504,6 +507,9 @@ function ProjectRowTrailingActions({
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
}) {
|
||||
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
|
||||
const settingsTarget = project.hosts[0] ?? null;
|
||||
return (
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{worktreeTarget ? (
|
||||
@@ -522,7 +528,8 @@ function ProjectRowTrailingActions({
|
||||
>
|
||||
<ProjectKebabMenu
|
||||
projectKey={project.projectKey}
|
||||
projectPath={project.iconWorkingDir}
|
||||
settingsTarget={settingsTarget}
|
||||
projectPath={localProjectPath}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
/>
|
||||
@@ -549,11 +556,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
|
||||
function ProjectKebabMenu({
|
||||
projectKey,
|
||||
settingsTarget,
|
||||
projectPath,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
projectKey: string;
|
||||
settingsTarget: { serverId: string; projectId: string } | null;
|
||||
projectPath: string;
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
@@ -561,10 +570,10 @@ function ProjectKebabMenu({
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (projectKey.trim().length === 0) return;
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||
if (!settingsTarget) return;
|
||||
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
|
||||
}, [settingsTarget]);
|
||||
const canOpenProjectSettings = settingsTarget !== null;
|
||||
// Desktop-only: open a second window that lands on this project via the same
|
||||
// open-project flow as a CLI launch. The project stays visible here too — no
|
||||
// ownership, no move.
|
||||
@@ -609,6 +618,10 @@ function ProjectKebabMenu({
|
||||
{t("sidebar.project.actions.openNewWindow")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
path={projectPath}
|
||||
testID={`sidebar-project-menu-open-folder-${projectKey}`}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
@@ -660,6 +673,7 @@ function WorkspaceRowRightGroup({
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
@@ -698,6 +712,7 @@ function WorkspaceRowRightGroup({
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspacePath}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
@@ -901,10 +916,10 @@ function NewWorkspaceGhostRow({
|
||||
serverId: worktreeTarget.serverId,
|
||||
sourceDirectory: worktreeTarget.iconWorkingDir,
|
||||
displayName,
|
||||
projectId: project.projectKey,
|
||||
projectId: worktreeTarget.projectId,
|
||||
}) as Href,
|
||||
);
|
||||
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
|
||||
}, [displayName, onWorkspacePress, worktreeTarget]);
|
||||
const rowStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.newWorkspaceGhostRow,
|
||||
@@ -981,10 +996,10 @@ function ProjectHeaderRow({
|
||||
serverId: worktreeTarget.serverId,
|
||||
sourceDirectory: worktreeTarget.iconWorkingDir,
|
||||
displayName,
|
||||
projectId: project.projectKey,
|
||||
projectId: worktreeTarget.projectId,
|
||||
}) as Href,
|
||||
);
|
||||
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
|
||||
}, [displayName, onWorkspacePress, worktreeTarget]);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -1748,7 +1763,6 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
void removeProjectFromHosts({
|
||||
projectKey: project.projectKey,
|
||||
targets: readiness.targets,
|
||||
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
|
||||
})
|
||||
@@ -1817,7 +1831,7 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
<View role="group" accessibilityLabel={displayName} style={styles.projectBlock}>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
|
||||
@@ -43,6 +43,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt
|
||||
hosts: [
|
||||
{
|
||||
serverId: "srv",
|
||||
projectId: "project",
|
||||
iconWorkingDir: "/repo",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -58,6 +59,7 @@ interface SidebarWorkspaceMenuProps {
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
openInFileManagerPath?: string | null;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceMenu({
|
||||
@@ -73,6 +75,7 @@ export function SidebarWorkspaceMenu({
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
openInFileManagerPath,
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
@@ -137,6 +140,10 @@ export function SidebarWorkspaceMenu({
|
||||
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
path={openInFileManagerPath}
|
||||
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
|
||||
@@ -299,7 +299,14 @@ export function ComboboxItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable testID={testID} disabled={disabled} onPress={onPress} style={itemPressableStyle}>
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={itemPressableStyle}
|
||||
>
|
||||
{leadingContent}
|
||||
<View style={itemContentStyle}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
@@ -93,7 +94,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
act(() => {
|
||||
setHostProfiles([hostProfile()]);
|
||||
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(
|
||||
seedSessionWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
|
||||
@@ -139,7 +140,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
it("publishes status-mode shortcut targets in visual status order", async () => {
|
||||
act(() => {
|
||||
useSidebarViewStore.getState().setGroupMode("status");
|
||||
useSessionStore.getState().setWorkspaces(
|
||||
seedSessionWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
[
|
||||
@@ -211,18 +212,14 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
|
||||
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(
|
||||
"host-a",
|
||||
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
||||
);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(
|
||||
"host-b",
|
||||
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
||||
);
|
||||
seedSessionWorkspaces(
|
||||
"host-a",
|
||||
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
||||
);
|
||||
seedSessionWorkspaces(
|
||||
"host-b",
|
||||
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
||||
);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
||||
useSidebarViewStore.getState().toggleHostFilter("host-b");
|
||||
|
||||
@@ -3,101 +3,56 @@ import {
|
||||
buildWorktreeSetupCalloutPolicy,
|
||||
selectActiveGitWorkspaceProject,
|
||||
shouldShowWorktreeSetupCallout,
|
||||
type WorktreeSetupWorkspaceInput,
|
||||
} from "./worktree-setup-callout-policy";
|
||||
|
||||
function gitWorkspace(
|
||||
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
|
||||
): WorktreeSetupWorkspaceInput {
|
||||
return {
|
||||
projectId: "project-1",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project-1",
|
||||
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectActiveGitWorkspaceProject", () => {
|
||||
it("selects the active git workspace project from checkout metadata", () => {
|
||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/main-project-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the workspace project root when checkout metadata has no main root", () => {
|
||||
it("selects the host-local project id for a git workspace", () => {
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject(
|
||||
"server-1",
|
||||
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
|
||||
),
|
||||
selectActiveGitWorkspaceProject("server-1", {
|
||||
projectId: "prj_local",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project",
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/project-1",
|
||||
projectId: "prj_local",
|
||||
repoRoot: "/repo/project",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-git workspaces and blank project coordinates", () => {
|
||||
it("ignores non-git workspaces and blank coordinates", () => {
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
|
||||
).toBe(null);
|
||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject(
|
||||
"server-1",
|
||||
gitWorkspace({ projectRootPath: " ", project: null }),
|
||||
),
|
||||
).toBe(null);
|
||||
selectActiveGitWorkspaceProject("server-1", {
|
||||
projectId: "project",
|
||||
projectKind: "directory",
|
||||
projectRootPath: "/repo",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowWorktreeSetupCallout", () => {
|
||||
it("shows the callout when paseo config was read and setup commands are missing", () => {
|
||||
it("shows only after a successful config read with no setup command", () => {
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show the callout when setup commands are present", () => {
|
||||
expect(
|
||||
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
|
||||
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm i" } } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowWorktreeSetupCallout({
|
||||
ok: true,
|
||||
config: { worktree: { setup: [" ", "npm install"] } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show the callout when reading paseo config fails or has not completed", () => {
|
||||
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorktreeSetupCalloutPolicy", () => {
|
||||
it("builds the stable sidebar callout identity and action route", () => {
|
||||
it("routes by server id and project id", () => {
|
||||
expect(
|
||||
buildWorktreeSetupCalloutPolicy({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/project-1",
|
||||
projectId: "prj_local",
|
||||
repoRoot: "/repo/project",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "worktree-setup-missing:project-1",
|
||||
dismissalKey: "worktree-setup-missing:project-1",
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
actionLabel: "Open project settings",
|
||||
projectSettingsRoute: "/settings/projects/project-1",
|
||||
testID: "worktree-setup-callout-project-1",
|
||||
).toMatchObject({
|
||||
id: "worktree-setup-missing:server-1:prj_local",
|
||||
projectSettingsRoute: "/settings/projects/server-1/prj_local",
|
||||
testID: "worktree-setup-callout-prj_local",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,16 +6,11 @@ export interface WorktreeSetupWorkspaceInput {
|
||||
projectId: string;
|
||||
projectKind: string;
|
||||
projectRootPath: string;
|
||||
project?: {
|
||||
checkout?: {
|
||||
mainRepoRoot?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ActiveGitWorkspaceProject {
|
||||
serverId: string;
|
||||
projectKey: string;
|
||||
projectId: string;
|
||||
repoRoot: string;
|
||||
}
|
||||
|
||||
@@ -43,13 +38,13 @@ export function selectActiveGitWorkspaceProject(
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectKey = workspace.projectId.trim();
|
||||
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
|
||||
if (!projectKey || !repoRoot) {
|
||||
const projectId = workspace.projectId;
|
||||
const repoRoot = workspace.projectRootPath.trim();
|
||||
if (!projectId.trim() || !repoRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, projectKey, repoRoot };
|
||||
return { serverId, projectId, repoRoot };
|
||||
}
|
||||
|
||||
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
|
||||
@@ -59,7 +54,7 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu
|
||||
export function buildWorktreeSetupCalloutPolicy(
|
||||
project: ActiveGitWorkspaceProject,
|
||||
): WorktreeSetupCalloutPolicy {
|
||||
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
|
||||
const calloutKey = `worktree-setup-missing:${project.serverId}:${project.projectId}`;
|
||||
|
||||
return {
|
||||
id: calloutKey,
|
||||
@@ -68,8 +63,8 @@ export function buildWorktreeSetupCalloutPolicy(
|
||||
title: i18n.t("sidebar.worktreeSetup.title"),
|
||||
description: i18n.t("sidebar.worktreeSetup.description"),
|
||||
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
|
||||
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
|
||||
testID: `worktree-setup-callout-${project.projectKey}`,
|
||||
projectSettingsRoute: buildProjectSettingsRoute(project.serverId, project.projectId),
|
||||
testID: `worktree-setup-callout-${project.projectId}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
|
||||
export function WorktreeSetupCalloutSource() {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const activeProject = useWorkspaceFields(
|
||||
const selectedWorkspaceProject = useWorkspaceFields(
|
||||
selection?.serverId ?? null,
|
||||
selection?.workspaceId ?? null,
|
||||
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
|
||||
);
|
||||
const activeProject = selectedWorkspaceProject;
|
||||
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
|
||||
const callouts = useSidebarCallouts();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -204,25 +204,34 @@ describe("server data push router", () => {
|
||||
|
||||
fake.emit({
|
||||
type: "subscribe_checkout_diff_response",
|
||||
payload: { subscriptionId, cwd, files: [], error: null, requestId: "diff-1" },
|
||||
payload: {
|
||||
subscriptionId,
|
||||
cwd,
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: "diff-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(queryKey)).toEqual({
|
||||
cwd,
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: "diff-1",
|
||||
});
|
||||
|
||||
fake.emit({
|
||||
type: "checkout_diff_update",
|
||||
payload: { subscriptionId, cwd, files: [], error: null },
|
||||
payload: { subscriptionId, cwd, files: [], error: null, diffTooLarge: true },
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(queryKey)).toEqual({
|
||||
cwd,
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: `subscription:${subscriptionId}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -420,6 +420,9 @@ function applyCheckoutDiffUpdate(input: {
|
||||
cwd: input.message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(input.message.payload.files),
|
||||
error: input.message.payload.error,
|
||||
...(input.message.payload.diffTooLarge !== undefined
|
||||
? { diffTooLarge: input.message.payload.diffTooLarge }
|
||||
: {}),
|
||||
requestId: `subscription:${input.message.payload.subscriptionId}`,
|
||||
},
|
||||
});
|
||||
@@ -440,6 +443,9 @@ function applyCheckoutDiffSubscribeResponse(input: {
|
||||
cwd: input.message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(input.message.payload.files),
|
||||
error: input.message.payload.error,
|
||||
...(input.message.payload.diffTooLarge !== undefined
|
||||
? { diffTooLarge: input.message.payload.diffTooLarge }
|
||||
: {}),
|
||||
requestId: input.message.payload.requestId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExplorerEntry } from "@/stores/session-store";
|
||||
import {
|
||||
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
|
||||
flattenExplorerTree,
|
||||
reconcileRestoredExpandedPaths,
|
||||
restoreExpandedDirectories,
|
||||
setExpandedDirectoryPath,
|
||||
showHiddenFilesAndRestoreExpandedDirectories,
|
||||
} from "./tree";
|
||||
|
||||
function makeDirectoryEntry(name: string, path: string): ExplorerEntry {
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
kind: "directory",
|
||||
size: 0,
|
||||
modifiedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("file explorer tree", () => {
|
||||
it("flattens a deeply expanded tree without consuming the call stack", () => {
|
||||
const depth = 10_000;
|
||||
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
|
||||
const expandedPaths = new Set<string>(["."]);
|
||||
let parentPath = ".";
|
||||
|
||||
for (let index = 1; index <= depth; index += 1) {
|
||||
const childPath = `directory-${index}`;
|
||||
directories.set(parentPath, {
|
||||
path: parentPath,
|
||||
entries: [makeDirectoryEntry(childPath, childPath)],
|
||||
});
|
||||
expandedPaths.add(childPath);
|
||||
parentPath = childPath;
|
||||
}
|
||||
directories.set(parentPath, { path: parentPath, entries: [] });
|
||||
|
||||
const rows = flattenExplorerTree({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption: "name",
|
||||
showHiddenFiles: true,
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(depth);
|
||||
expect(rows[0]).toEqual({
|
||||
entry: makeDirectoryEntry("directory-1", "directory-1"),
|
||||
depth: 0,
|
||||
});
|
||||
expect(rows.at(-1)).toEqual({
|
||||
entry: makeDirectoryEntry(`directory-${depth}`, `directory-${depth}`),
|
||||
depth: depth - 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens a large expanded directory without spreading its rows into the parent", () => {
|
||||
const fileCount = 150_000;
|
||||
const files = Array.from(
|
||||
{ length: fileCount },
|
||||
(_, index): ExplorerEntry => ({
|
||||
name: `file-${index.toString().padStart(6, "0")}`,
|
||||
path: `generated/file-${index}`,
|
||||
kind: "file",
|
||||
size: index,
|
||||
modifiedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
const child = makeDirectoryEntry("generated", "generated");
|
||||
const directories = new Map([
|
||||
[".", { path: ".", entries: [child] }],
|
||||
["generated", { path: "generated", entries: files }],
|
||||
]);
|
||||
|
||||
const rows = flattenExplorerTree({
|
||||
directories,
|
||||
expandedPaths: new Set([".", "generated"]),
|
||||
sortOption: "name",
|
||||
showHiddenFiles: true,
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(fileCount + 1);
|
||||
expect(rows[0]).toEqual({ entry: child, depth: 0 });
|
||||
expect(rows.at(-1)).toEqual({ entry: files[fileCount - 1], depth: 1 });
|
||||
});
|
||||
|
||||
it("restores five rendered directory levels rather than counting path segments", async () => {
|
||||
const paths = [
|
||||
"generated/cache/level-1",
|
||||
"generated/cache/level-1/level-2",
|
||||
"generated/cache/level-1/level-2/level-3",
|
||||
"generated/cache/level-1/level-2/level-3/level-4",
|
||||
"generated/cache/level-1/level-2/level-3/level-4/level-5",
|
||||
"generated/cache/level-1/level-2/level-3/level-4/level-5/level-6",
|
||||
];
|
||||
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
|
||||
const rootDirectory = {
|
||||
path: ".",
|
||||
entries: [makeDirectoryEntry("level-1", paths[0])],
|
||||
};
|
||||
directories.set(".", rootDirectory);
|
||||
for (let index = 0; index < paths.length - 1; index += 1) {
|
||||
directories.set(paths[index], {
|
||||
path: paths[index],
|
||||
entries: [makeDirectoryEntry(`level-${index + 2}`, paths[index + 1])],
|
||||
});
|
||||
}
|
||||
|
||||
const requestedPaths: string[] = [];
|
||||
const expandedPaths = await restoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths: new Set(paths),
|
||||
showHiddenFiles: true,
|
||||
requestDirectoryListing: async (path) => {
|
||||
requestedPaths.push(path);
|
||||
return directories.get(path) ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
expect(MAX_AUTO_EXPANDED_DIRECTORY_DEPTH).toBe(5);
|
||||
expect(requestedPaths).toEqual(paths.slice(0, 5));
|
||||
expect(expandedPaths).toEqual([".", ...paths.slice(0, 5)]);
|
||||
});
|
||||
|
||||
it("does not restore persisted descendants beneath a collapsed rendered directory", async () => {
|
||||
const rootDirectory = {
|
||||
path: ".",
|
||||
entries: [makeDirectoryEntry("parent", "parent")],
|
||||
};
|
||||
const requestedPaths: string[] = [];
|
||||
|
||||
const expandedPaths = await restoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths: new Set(["parent/child", "parent/child/grandchild"]),
|
||||
showHiddenFiles: true,
|
||||
requestDirectoryListing: async (path) => {
|
||||
requestedPaths.push(path);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestedPaths).toEqual([]);
|
||||
expect(expandedPaths).toEqual(["."]);
|
||||
});
|
||||
|
||||
it("preserves expansion changes made while persisted directories are restoring", () => {
|
||||
const paths = reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths: new Set([".", "parent", "parent/child"]),
|
||||
currentExpandedPaths: new Set([".", "parent/child", "manual"]),
|
||||
restoredExpandedPaths: [".", "parent"],
|
||||
});
|
||||
|
||||
expect(paths).toEqual([".", "manual"]);
|
||||
});
|
||||
|
||||
it("applies a directory click to the latest restored expansion paths", () => {
|
||||
const expanded = setExpandedDirectoryPath({
|
||||
currentExpandedPaths: [".", "restored"],
|
||||
directoryPath: "manual",
|
||||
expanded: true,
|
||||
});
|
||||
const collapsed = setExpandedDirectoryPath({
|
||||
currentExpandedPaths: expanded,
|
||||
directoryPath: "manual",
|
||||
expanded: false,
|
||||
});
|
||||
|
||||
expect(expanded).toEqual([".", "restored", "manual"]);
|
||||
expect(collapsed).toEqual([".", "restored"]);
|
||||
});
|
||||
|
||||
it("shows hidden files before waiting for expanded directories to restore", async () => {
|
||||
const rootDirectory = {
|
||||
path: ".",
|
||||
entries: [makeDirectoryEntry(".hidden", ".hidden")],
|
||||
};
|
||||
let resolveDirectory!: (directory: { path: string; entries: ExplorerEntry[] }) => void;
|
||||
const directoryListing = new Promise<{ path: string; entries: ExplorerEntry[] }>((resolve) => {
|
||||
resolveDirectory = resolve;
|
||||
});
|
||||
let hiddenFilesAreShown = false;
|
||||
|
||||
const restoration = showHiddenFilesAndRestoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths: new Set([".hidden"]),
|
||||
showHiddenFiles: () => {
|
||||
hiddenFilesAreShown = true;
|
||||
},
|
||||
requestDirectoryListing: () => directoryListing,
|
||||
});
|
||||
|
||||
expect(hiddenFilesAreShown).toBe(true);
|
||||
resolveDirectory({ path: ".hidden", entries: [] });
|
||||
await expect(restoration).resolves.toEqual([".", ".hidden"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
|
||||
import type { SortOption } from "@/stores/panel-store/state";
|
||||
import { filterVisibleExplorerEntries } from "./visibility";
|
||||
|
||||
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
|
||||
|
||||
export interface ExplorerTreeRow {
|
||||
entry: ExplorerEntry;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface FlattenExplorerTreeInput {
|
||||
directories: ReadonlyMap<string, ExplorerDirectory>;
|
||||
expandedPaths: ReadonlySet<string>;
|
||||
sortOption: SortOption;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
interface RestoreExpandedDirectoriesInput {
|
||||
rootDirectory: ExplorerDirectory;
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
showHiddenFiles: boolean;
|
||||
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
|
||||
}
|
||||
|
||||
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
||||
RestoreExpandedDirectoriesInput,
|
||||
"showHiddenFiles"
|
||||
> {
|
||||
showHiddenFiles: () => void;
|
||||
}
|
||||
|
||||
interface ReconcileRestoredExpandedPathsInput {
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
currentExpandedPaths: ReadonlySet<string>;
|
||||
restoredExpandedPaths: string[];
|
||||
}
|
||||
|
||||
interface SetExpandedDirectoryPathInput {
|
||||
currentExpandedPaths: readonly string[];
|
||||
directoryPath: string;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export function flattenExplorerTree({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
}: FlattenExplorerTreeInput): ExplorerTreeRow[] {
|
||||
const root = directories.get(".");
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: ExplorerTreeRow[] = [];
|
||||
const pending = rowsForDirectory(root, 0, sortOption, showHiddenFiles).toReversed();
|
||||
|
||||
while (pending.length > 0) {
|
||||
const row = pending.pop();
|
||||
if (!row) {
|
||||
break;
|
||||
}
|
||||
rows.push(row);
|
||||
|
||||
const entry = row.entry;
|
||||
if (entry.kind !== "directory" || !expandedPaths.has(entry.path)) {
|
||||
continue;
|
||||
}
|
||||
const childDirectory = directories.get(entry.path);
|
||||
if (!childDirectory) {
|
||||
continue;
|
||||
}
|
||||
const childRows = rowsForDirectory(childDirectory, row.depth + 1, sortOption, showHiddenFiles);
|
||||
for (let index = childRows.length - 1; index >= 0; index -= 1) {
|
||||
pending.push(childRows[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function restoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
|
||||
const restoredPaths = ["."];
|
||||
const restoredPathSet = new Set(restoredPaths);
|
||||
let parentDirectories = [rootDirectory];
|
||||
|
||||
for (let depth = 1; depth <= MAX_AUTO_EXPANDED_DIRECTORY_DEPTH; depth += 1) {
|
||||
const pathsToRequest: string[] = [];
|
||||
for (const directory of parentDirectories) {
|
||||
const entries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
|
||||
for (const entry of entries) {
|
||||
const isPersistedExpandedDirectory =
|
||||
entry.kind === "directory" && persistedExpandedPaths.has(entry.path);
|
||||
if (isPersistedExpandedDirectory && !restoredPathSet.has(entry.path)) {
|
||||
pathsToRequest.push(entry.path);
|
||||
restoredPathSet.add(entry.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pathsToRequest.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const requestedDirectories = await Promise.all(
|
||||
pathsToRequest.map((path) => requestDirectoryListing(path)),
|
||||
);
|
||||
parentDirectories = [];
|
||||
for (const directory of requestedDirectories) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
restoredPaths.push(directory.path);
|
||||
parentDirectories.push(directory);
|
||||
}
|
||||
}
|
||||
|
||||
return restoredPaths;
|
||||
}
|
||||
|
||||
export function showHiddenFilesAndRestoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
|
||||
showHiddenFiles();
|
||||
return restoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles: true,
|
||||
requestDirectoryListing,
|
||||
});
|
||||
}
|
||||
|
||||
export function reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths,
|
||||
currentExpandedPaths,
|
||||
restoredExpandedPaths,
|
||||
}: ReconcileRestoredExpandedPathsInput): string[] {
|
||||
const reconciledPaths = new Set(restoredExpandedPaths);
|
||||
|
||||
for (const path of persistedExpandedPaths) {
|
||||
if (!currentExpandedPaths.has(path)) {
|
||||
reconciledPaths.delete(path);
|
||||
}
|
||||
}
|
||||
for (const path of currentExpandedPaths) {
|
||||
if (!persistedExpandedPaths.has(path)) {
|
||||
reconciledPaths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reconciledPaths);
|
||||
}
|
||||
|
||||
export function setExpandedDirectoryPath({
|
||||
currentExpandedPaths,
|
||||
directoryPath,
|
||||
expanded,
|
||||
}: SetExpandedDirectoryPathInput): string[] {
|
||||
const nextPaths = new Set(currentExpandedPaths);
|
||||
if (expanded) {
|
||||
nextPaths.add(directoryPath);
|
||||
} else {
|
||||
nextPaths.delete(directoryPath);
|
||||
}
|
||||
return Array.from(nextPaths);
|
||||
}
|
||||
|
||||
function rowsForDirectory(
|
||||
directory: ExplorerDirectory,
|
||||
depth: number,
|
||||
sortOption: SortOption,
|
||||
showHiddenFiles: boolean,
|
||||
): ExplorerTreeRow[] {
|
||||
const visibleEntries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
|
||||
const sortedEntries = sortExplorerEntries(visibleEntries, sortOption);
|
||||
return sortedEntries.map((entry) => ({ entry, depth }));
|
||||
}
|
||||
|
||||
function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
||||
const sorted = [...entries];
|
||||
sorted.sort((a, b) => {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === "directory" ? -1 : 1;
|
||||
}
|
||||
switch (sortOption) {
|
||||
case "name":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "modified":
|
||||
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
|
||||
case "size":
|
||||
return b.size - a.size;
|
||||
}
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
@@ -119,6 +119,7 @@ import {
|
||||
type InlineReviewActions,
|
||||
} from "@/review";
|
||||
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
||||
import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -1755,6 +1756,7 @@ interface DiffBodyContentProps {
|
||||
notGit: boolean;
|
||||
isDiffLoading: boolean;
|
||||
diffErrorMessage: string | null;
|
||||
diffTooLarge: boolean;
|
||||
hasChanges: boolean;
|
||||
emptyMessage: string;
|
||||
children: ReactElement;
|
||||
@@ -1768,6 +1770,7 @@ function DiffBodyContent({
|
||||
notGit,
|
||||
isDiffLoading,
|
||||
diffErrorMessage,
|
||||
diffTooLarge,
|
||||
hasChanges,
|
||||
emptyMessage,
|
||||
children,
|
||||
@@ -1803,6 +1806,9 @@ function DiffBodyContent({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (diffTooLarge) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
if (diffErrorMessage) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
@@ -2693,6 +2699,7 @@ export function GitDiffPane({
|
||||
selectBase: handleSelectBase,
|
||||
files,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
reviewActions,
|
||||
reviewAttachment,
|
||||
@@ -2855,6 +2862,7 @@ export function GitDiffPane({
|
||||
notGit={notGit}
|
||||
isDiffLoading={isDiffLoading}
|
||||
diffErrorMessage={diffErrorMessage}
|
||||
diffTooLarge={diffTooLarge}
|
||||
hasChanges={hasChanges}
|
||||
emptyMessage={emptyMessage}
|
||||
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { FileWarning } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedFileWarning = withUnistyles(FileWarning);
|
||||
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export function DiffTooLargeState() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="diff-too-large">
|
||||
<ThemedFileWarning size={ICON_SIZE.lg} uniProps={foregroundMutedIconColorMapping} />
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>{t("workspace.git.diff.previewTooLargeTitle")}</Text>
|
||||
<Text style={styles.description}>{t("workspace.git.diff.previewTooLargeDescription")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
copy: {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
maxWidth: 360,
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
textAlign: "center",
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -44,6 +44,24 @@ describe("buildForgeBranchTreeUrl", () => {
|
||||
).toBe("https://codeberg.org/acme/repo/src/branch/main");
|
||||
});
|
||||
|
||||
it("preserves a non-default port for a self-hosted https remote", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("forgejo", {
|
||||
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
|
||||
branch: "master",
|
||||
}),
|
||||
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master");
|
||||
});
|
||||
|
||||
it("omits the port for a self-hosted remote on the default port", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("forgejo", {
|
||||
remoteUrl: "https://home-git.example.com/team/repo.git",
|
||||
branch: "master",
|
||||
}),
|
||||
).toBe("https://home-git.example.com/team/repo/src/branch/master");
|
||||
});
|
||||
|
||||
it("returns null when the current branch is unavailable", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", {
|
||||
@@ -131,6 +149,18 @@ describe("buildForgeBlobUrl", () => {
|
||||
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("preserves a non-default port for a self-hosted https remote", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("forgejo", {
|
||||
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
|
||||
branch: "master",
|
||||
path: "src/index.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
}),
|
||||
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master/src/index.ts#L12-L20");
|
||||
});
|
||||
|
||||
it("canonicalizes the github.com SSH-alias host to the web host", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface ForgeBranchTreeUrlInput {
|
||||
|
||||
interface ForgeWebLocation {
|
||||
host: string;
|
||||
/** Non-default port for a self-hosted http(s) origin, or undefined. */
|
||||
port?: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
@@ -58,18 +60,28 @@ function resolveForgeWebLocation(
|
||||
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 };
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
const isCloudHost = cloudHosts.includes(location.host);
|
||||
const webHost = isCloudHost ? cloudHosts[0] : location.host;
|
||||
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
|
||||
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
|
||||
// web port, and a canonicalized cloud host always serves on the default port.
|
||||
const port =
|
||||
!isCloudHost && (location.transport === "http" || location.transport === "https")
|
||||
? location.port
|
||||
: undefined;
|
||||
return { host: webHost, port, repo: location.path };
|
||||
}
|
||||
|
||||
function encodeBranch(branch: string): string {
|
||||
return branch.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
/** Host, plus `:port` when the remote pins a non-default port. */
|
||||
function forgeAuthority(location: ForgeWebLocation): string {
|
||||
return location.port ? `${location.host}:${location.port}` : location.host;
|
||||
}
|
||||
|
||||
function normalizeBlobPath(path: string | null | undefined): string | null {
|
||||
const segments: string[] = [];
|
||||
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
@@ -102,7 +114,7 @@ export function buildForgeBranchTreeUrl(
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
}
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
@@ -114,7 +126,7 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
|
||||
return null;
|
||||
}
|
||||
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
||||
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||
if (input.lineStart && input.lineStart > 0) {
|
||||
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function useCheckoutDiffQuery({
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null && enabled && isConnected,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
|
||||
@@ -79,6 +79,7 @@ export function useWorkingDiff({
|
||||
const {
|
||||
files,
|
||||
payloadError: diffPayloadError,
|
||||
diffTooLarge,
|
||||
isLoading: isDiffLoading,
|
||||
} = useCheckoutDiffQuery({
|
||||
serverId,
|
||||
@@ -123,6 +124,7 @@ export function useWorkingDiff({
|
||||
selectBase,
|
||||
files,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
reviewActions,
|
||||
reviewAttachment,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
|
||||
export interface KeyboardShiftContextValue {
|
||||
shift: SharedValue<number>;
|
||||
bottomInset: SharedValue<number>;
|
||||
}
|
||||
|
||||
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
|
||||
|
||||
/** Read the app-wide keyboard inset without loading its native provider implementation. */
|
||||
export function useKeyboardShift(): KeyboardShiftContextValue {
|
||||
const context = useContext(KeyboardShiftContext);
|
||||
if (!context) {
|
||||
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -4,10 +4,7 @@ import type {
|
||||
ProjectAddResponse,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
|
||||
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
|
||||
|
||||
type OpenProjectPayload = ProjectAddResponse["payload"];
|
||||
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
|
||||
@@ -47,28 +44,28 @@ export interface OpenProjectDirectlyInput {
|
||||
isConnected: boolean;
|
||||
canAddProject: boolean;
|
||||
client: Pick<DaemonClient, "addProject"> | null;
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
}
|
||||
|
||||
interface ProjectRegistrationCallbacks {
|
||||
serverId: string;
|
||||
isConnected: boolean;
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
}
|
||||
|
||||
export interface RegisterProjectDescriptorInput {
|
||||
serverId: string;
|
||||
project: WorkspaceProjectDescriptorPayload;
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
}
|
||||
|
||||
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) return false;
|
||||
input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project));
|
||||
input.upsertProject(serverId, normalizeProjectDescriptor(input.project));
|
||||
input.setHasHydratedWorkspaces(serverId, true);
|
||||
return true;
|
||||
}
|
||||
@@ -109,7 +106,7 @@ export async function openProjectDirectly(
|
||||
const registered = registerProjectDescriptor({
|
||||
serverId: normalizedServerId,
|
||||
project: payload.project,
|
||||
addEmptyProject: input.addEmptyProject,
|
||||
upsertProject: input.upsertProject,
|
||||
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
|
||||
});
|
||||
return registered
|
||||
@@ -145,7 +142,7 @@ export async function cloneGithubProjectDirectly(
|
||||
const registered = registerProjectDescriptor({
|
||||
serverId: normalizedServerId,
|
||||
project: payload.project,
|
||||
addEmptyProject: input.addEmptyProject,
|
||||
upsertProject: input.upsertProject,
|
||||
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
|
||||
});
|
||||
return registered
|
||||
|
||||
@@ -78,20 +78,25 @@ function project(input: {
|
||||
projectKind?: WorkspaceStructureProject["projectKind"];
|
||||
iconWorkingDir?: string;
|
||||
workspaceKeys: string[];
|
||||
hosts?: WorkspaceStructureProject["hosts"];
|
||||
hosts?: Array<
|
||||
Omit<WorkspaceStructureProject["hosts"][number], "projectId"> & { projectId?: string }
|
||||
>;
|
||||
}): WorkspaceStructureProject {
|
||||
return {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName ?? input.projectKey,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
hosts: input.hosts ?? [
|
||||
{
|
||||
serverId: "srv",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
hosts: Array.from(
|
||||
input.hosts ?? [
|
||||
{
|
||||
serverId: "srv",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
|
||||
),
|
||||
workspaceKeys: input.workspaceKeys,
|
||||
};
|
||||
}
|
||||
@@ -324,8 +329,18 @@ describe("shared sidebar workspace model", () => {
|
||||
expect.objectContaining({
|
||||
projectKey: "getpaseo/paseo",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectId: "getpaseo/paseo",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
projectId: "getpaseo/paseo",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
expect.objectContaining({
|
||||
@@ -401,6 +416,35 @@ describe("shared sidebar workspace model", () => {
|
||||
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
|
||||
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
|
||||
});
|
||||
|
||||
it("keeps a structurally disambiguated project key in status entries", () => {
|
||||
const projectKey = "host:srv:project:prj_a";
|
||||
const model = buildSidebarWorkspacePlacementModel({
|
||||
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
|
||||
});
|
||||
const entries = buildSidebarWorkspaceEntries({
|
||||
placements: model.workspaces,
|
||||
sessions: [
|
||||
{
|
||||
serverId: "srv",
|
||||
workspaceAgentActivity: new Map(),
|
||||
workspaces: new Map([
|
||||
[
|
||||
"clone-a",
|
||||
workspace({
|
||||
id: "clone-a",
|
||||
name: "main",
|
||||
projectId: "prj_a",
|
||||
projectDisplayName: "acme/app",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowSidebarHostLabels", () => {
|
||||
|
||||
@@ -141,10 +141,11 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
projectKey?: string;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
||||
const projectKey = input.projectKey ?? input.workspace.projectId;
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
@@ -323,6 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
|
||||
const entry = createSidebarWorkspaceEntry({
|
||||
serverId: placement.serverId,
|
||||
workspace,
|
||||
projectKey: placement.projectKey,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
|
||||
import {
|
||||
useSessionStore,
|
||||
type AgentFileExplorerState,
|
||||
type ExplorerDirectory,
|
||||
} from "@/stores/session-store";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
|
||||
function createExplorerState(): AgentFileExplorerState {
|
||||
@@ -88,9 +92,9 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
async (
|
||||
path: string,
|
||||
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
): Promise<boolean> => {
|
||||
): Promise<ExplorerDirectory | null> => {
|
||||
if (!workspaceStateKey) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
|
||||
@@ -119,7 +123,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
lastError: t("workspace.fileExplorer.states.unavailable"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
@@ -129,7 +133,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
lastError: t("workspace.terminal.hostDisconnected"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -150,7 +154,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
|
||||
return nextState;
|
||||
});
|
||||
return true;
|
||||
return directory;
|
||||
} catch (error) {
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
@@ -161,7 +165,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
: t("workspace.fileExplorer.errors.failedToListDirectory"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createElement, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -23,16 +16,10 @@ import {
|
||||
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||
resolveKeyboardShift,
|
||||
} from "@/hooks/keyboard-shift-policy";
|
||||
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
|
||||
type KeyboardShiftMode = "translate" | "padding";
|
||||
|
||||
interface KeyboardShiftContextValue {
|
||||
shift: SharedValue<number>;
|
||||
bottomInset: SharedValue<number>;
|
||||
}
|
||||
|
||||
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
|
||||
|
||||
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
|
||||
@@ -78,14 +65,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
return createElement(KeyboardShiftContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useKeyboardShift(): KeyboardShiftContextValue {
|
||||
const context = useContext(KeyboardShiftContext);
|
||||
if (!context) {
|
||||
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
|
||||
shift: SharedValue<number>;
|
||||
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getOpenProjectFailureReason,
|
||||
openProjectDirectly,
|
||||
} from "@/hooks/open-project";
|
||||
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
|
||||
import type { ProjectDescriptor } from "@/stores/session-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const PROJECT_PATH = "/repo/project";
|
||||
@@ -20,7 +20,7 @@ function buildProjectPayload() {
|
||||
|
||||
interface RecordedProject {
|
||||
serverId: string;
|
||||
project: ProjectWithoutWorkspacesDescriptor;
|
||||
project: ProjectDescriptor;
|
||||
}
|
||||
|
||||
interface RecordedHydrated {
|
||||
@@ -40,7 +40,7 @@ function createFakeSession() {
|
||||
return {
|
||||
projects,
|
||||
hydrated,
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => {
|
||||
projects.push({ serverId, project });
|
||||
},
|
||||
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
|
||||
@@ -83,7 +83,7 @@ describe("openProjectDirectly", () => {
|
||||
project: projectPayload,
|
||||
}),
|
||||
},
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
|
||||
serverId: SERVER_ID,
|
||||
project: {
|
||||
projectId: "project-1",
|
||||
projectKey: null,
|
||||
projectDisplayName: "project",
|
||||
projectCustomName: null,
|
||||
projectKind: "git",
|
||||
@@ -117,7 +118,7 @@ describe("openProjectDirectly", () => {
|
||||
project: buildProjectPayload(),
|
||||
}),
|
||||
},
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -146,7 +147,7 @@ describe("openProjectDirectly", () => {
|
||||
project: null,
|
||||
}),
|
||||
},
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -173,7 +174,7 @@ describe("cloneGithubProjectDirectly", () => {
|
||||
cloneProtocol: "https",
|
||||
isConnected: true,
|
||||
client: github,
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
|
||||
project: {
|
||||
...projectPayload,
|
||||
projectCustomName: null,
|
||||
projectKey: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -208,7 +210,7 @@ describe("cloneGithubProjectDirectly", () => {
|
||||
cloneProtocol: "https",
|
||||
isConnected: true,
|
||||
client: github,
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useOpenProject(
|
||||
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
|
||||
: false,
|
||||
);
|
||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||
const upsertProject = useSessionStore((state) => state.upsertProject);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
@@ -31,13 +31,13 @@ export function useOpenProject(
|
||||
isConnected,
|
||||
canAddProject,
|
||||
client,
|
||||
addEmptyProject,
|
||||
upsertProject,
|
||||
setHasHydratedWorkspaces,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
[
|
||||
addEmptyProject,
|
||||
upsertProject,
|
||||
canAddProject,
|
||||
client,
|
||||
isConnected,
|
||||
@@ -57,7 +57,7 @@ export function useCloneGithubProject(
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||
const upsertProject = useSessionStore((state) => state.upsertProject);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
@@ -69,10 +69,10 @@ export function useCloneGithubProject(
|
||||
...(cloneProtocol ? { cloneProtocol } : {}),
|
||||
isConnected,
|
||||
client,
|
||||
addEmptyProject,
|
||||
upsertProject,
|
||||
setHasHydratedWorkspaces,
|
||||
});
|
||||
},
|
||||
[addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
|
||||
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,242 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { ProjectDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
deriveProjectsFromReplica,
|
||||
type ProjectHostReplica,
|
||||
type ProjectHostRuntimeState,
|
||||
} from "@/hooks/use-projects";
|
||||
|
||||
function runtimeState(input: {
|
||||
serverId: string;
|
||||
isOnline: boolean;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
error?: string | null;
|
||||
}): ProjectHostRuntimeState {
|
||||
function project(id: string, name: string, root: string): ProjectDescriptor {
|
||||
return {
|
||||
projectId: id,
|
||||
projectKey: `remote:github.com/${name}`,
|
||||
projectDisplayName: name,
|
||||
projectCustomName: null,
|
||||
projectRootPath: root,
|
||||
projectKind: "git",
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeState(input: Partial<ProjectHostRuntimeState> & { serverId: string }) {
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
isOnline: input.isOnline,
|
||||
isOnline: input.isOnline ?? true,
|
||||
isLoading: input.isLoading ?? false,
|
||||
isFetching: input.isFetching ?? false,
|
||||
error: input.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectCustomName: null,
|
||||
projectRootPath: input.cwd,
|
||||
workspaceDirectory: input.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
title: null,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
},
|
||||
githubRuntime: null,
|
||||
project: {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName,
|
||||
checkout: {
|
||||
cwd: input.cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
worktreeRoot: input.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyProject(input: {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectCustomName: null,
|
||||
projectRootPath: input.cwd,
|
||||
projectKind: "git",
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveProjectsFromReplica", () => {
|
||||
it("aggregates store workspaces and empty projects sorted by display name", () => {
|
||||
it("derives projects from the project descriptor replica", () => {
|
||||
const replicas: ProjectHostReplica[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "z-main",
|
||||
projectKey: "remote:github.com/acme/zeta",
|
||||
projectName: "acme/zeta",
|
||||
cwd: "/repo/zeta",
|
||||
remoteUrl: "https://github.com/acme/zeta.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
workspaces: [],
|
||||
projects: [project("prj_zeta", "acme/zeta", "/repo/zeta")],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
workspaces: [],
|
||||
emptyProjects: [
|
||||
emptyProject({
|
||||
projectKey: "remote:github.com/acme/alpha",
|
||||
projectName: "acme/alpha",
|
||||
cwd: "/repo/alpha",
|
||||
}),
|
||||
],
|
||||
projects: [project("prj_alpha", "acme/alpha", "/repo/alpha")],
|
||||
},
|
||||
];
|
||||
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas,
|
||||
runtimeStates: [
|
||||
runtimeState({ serverId: "local", isOnline: true }),
|
||||
runtimeState({ serverId: "local" }),
|
||||
runtimeState({ serverId: "laptop", isOnline: false }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects.map((project) => project.projectName)).toEqual([
|
||||
"acme/alpha",
|
||||
"acme/zeta",
|
||||
]);
|
||||
expect(result.projects[0]?.hosts).toEqual([
|
||||
expect.objectContaining({
|
||||
serverId: "laptop",
|
||||
isOnline: false,
|
||||
repoRoot: "/repo/alpha",
|
||||
workspaceCount: 0,
|
||||
}),
|
||||
]);
|
||||
expect(result.hostErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps runtime directory status into loading and host errors", () => {
|
||||
const replicas: ProjectHostReplica[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
workspaces: [],
|
||||
emptyProjects: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas,
|
||||
runtimeStates: [
|
||||
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
|
||||
runtimeState({
|
||||
serverId: "laptop",
|
||||
isOnline: true,
|
||||
isLoading: true,
|
||||
error: "laptop unavailable",
|
||||
}),
|
||||
],
|
||||
expect(result.projects.map((item) => item.projectName)).toEqual(["acme/alpha", "acme/zeta"]);
|
||||
expect(result.projects[0]?.hosts[0]).toMatchObject({
|
||||
serverId: "laptop",
|
||||
projectId: "prj_alpha",
|
||||
repoRoot: "/repo/alpha",
|
||||
workspaceCount: 0,
|
||||
});
|
||||
|
||||
expect(result.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
expect(result.hostErrors).toEqual([
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
message: "laptop unavailable",
|
||||
},
|
||||
]);
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.isFetching).toBe(true);
|
||||
});
|
||||
|
||||
it("returns only the stable public project and host entry shapes", () => {
|
||||
it("preserves runtime loading and error state", () => {
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
workspaces: [],
|
||||
projects: [project("prj_app", "acme/app", "/repo/app")],
|
||||
},
|
||||
],
|
||||
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
|
||||
runtimeStates: [
|
||||
runtimeState({
|
||||
serverId: "local",
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: "unavailable",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
"projectCustomName",
|
||||
"projectKey",
|
||||
"projectName",
|
||||
"totalWorkspaceCount",
|
||||
]);
|
||||
expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
|
||||
"gitRuntime",
|
||||
"githubRuntime",
|
||||
"isOnline",
|
||||
"repoRoot",
|
||||
"serverId",
|
||||
"serverName",
|
||||
"workspaceCount",
|
||||
"workspaces",
|
||||
]);
|
||||
expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
|
||||
"currentBranch",
|
||||
"id",
|
||||
"name",
|
||||
"status",
|
||||
"workspaceKind",
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.isFetching).toBe(true);
|
||||
expect(result.hostErrors).toEqual([
|
||||
{ serverId: "local", serverName: "Local", message: "unavailable" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
useSessionStore,
|
||||
type EmptyProjectDescriptor,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||
@@ -24,7 +24,7 @@ export interface ProjectHostReplica {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
emptyProjects: EmptyProjectDescriptor[];
|
||||
projects: ProjectDescriptor[];
|
||||
}
|
||||
|
||||
export interface ProjectHostRuntimeState {
|
||||
@@ -87,7 +87,7 @@ function selectProjectHostReplicas(
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
workspaces: Array.from(session?.workspaces.values() ?? []),
|
||||
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
|
||||
projects: Array.from(session?.projects.values() ?? []),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function deriveProjectsFromReplica(input: {
|
||||
serverName: replica.serverName,
|
||||
isOnline: runtimeState?.isOnline ?? false,
|
||||
workspaces: replica.workspaces,
|
||||
emptyProjects: replica.emptyProjects,
|
||||
projects: replica.projects,
|
||||
};
|
||||
});
|
||||
const hostErrors = input.replicas.flatMap((replica) => {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
computeSidebarOrderUpdates,
|
||||
@@ -128,35 +125,12 @@ export function useSidebarWorkspacesList(options?: {
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
if (snapshot?.connectionStatus !== "online") continue;
|
||||
const client = runtime.getClient(serverId);
|
||||
if (!client) continue;
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
try {
|
||||
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
|
||||
client,
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
});
|
||||
for (const workspace of workspaces) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
next.set(workspace.id, workspace);
|
||||
}
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(serverId, next);
|
||||
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
|
||||
// a freshly-added project from the sidebar.
|
||||
store.setEmptyProjects(serverId, emptyProjects);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
error,
|
||||
});
|
||||
// ignore explicit refresh failures; hook keeps existing data
|
||||
}
|
||||
})();
|
||||
void runtime.refreshDirectories(serverId).catch((error) => {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [isActive, runtime, serverIds]);
|
||||
|
||||
|
||||
@@ -764,6 +764,8 @@ export const ar: TranslationResources = {
|
||||
closeChangesTab: "إغلاق علامة تبويب التغييرات",
|
||||
binaryFile: "ملف ثنائي",
|
||||
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
|
||||
previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته",
|
||||
previewTooLargeDescription: "ضيّق نطاق المقارنة لمعاينتها هنا",
|
||||
unified: "الفرق الموحدة",
|
||||
split: "فرق جنبًا إلى جنب",
|
||||
switchToUnified: "التبديل إلى الفرق الموحد",
|
||||
@@ -908,6 +910,8 @@ export const ar: TranslationResources = {
|
||||
openSettings: "افتح إعدادات المشروع",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "إزالة المشروع",
|
||||
removing: "جارٍ الإزالة...",
|
||||
},
|
||||
@@ -1831,6 +1835,7 @@ export const ar: TranslationResources = {
|
||||
sendMessage: "أرسل رسالة",
|
||||
queueMessage: "رسالة قائمة الانتظار",
|
||||
muteUnmuteVoiceMode: "كتم وضع الصوت /unmute",
|
||||
switchProject: "تبديل المشروع",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "متاح عندما لا يكون التركيز في حقل نص أو محطة طرفية.",
|
||||
|
||||
@@ -774,6 +774,8 @@ export const en = {
|
||||
closeChangesTab: "Close Changes tab",
|
||||
binaryFile: "Binary file",
|
||||
tooLarge: "Diff too large to display",
|
||||
previewTooLargeTitle: "This diff is too large to preview",
|
||||
previewTooLargeDescription: "Narrow the comparison to preview it here",
|
||||
unified: "Unified diff",
|
||||
split: "Side-by-side diff",
|
||||
switchToUnified: "Switch to unified diff",
|
||||
@@ -918,6 +920,8 @@ export const en = {
|
||||
openSettings: "Open project settings",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Remove project",
|
||||
removing: "Removing...",
|
||||
},
|
||||
@@ -1841,6 +1845,7 @@ export const en = {
|
||||
sendMessage: "Send message",
|
||||
queueMessage: "Queue message",
|
||||
muteUnmuteVoiceMode: "Mute/unmute voice mode",
|
||||
switchProject: "Switch project",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "Available when focus is not in a text field or terminal.",
|
||||
|
||||
@@ -795,6 +795,8 @@ export const es: TranslationResources = {
|
||||
closeChangesTab: "Cerrar la pestaña Cambios",
|
||||
binaryFile: "archivo binario",
|
||||
tooLarge: "La diferencia es demasiado grande para mostrarse",
|
||||
previewTooLargeTitle: "Este diff es demasiado grande para previsualizarlo",
|
||||
previewTooLargeDescription: "Reduce la comparación para previsualizarla aquí",
|
||||
unified: "Diferencia unificada",
|
||||
split: "Diferencia de lado a lado",
|
||||
switchToUnified: "Cambiar a diferencia unificada",
|
||||
@@ -939,6 +941,8 @@ export const es: TranslationResources = {
|
||||
openSettings: "Abrir la configuración del proyecto",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Eliminar proyecto",
|
||||
removing: "Eliminando...",
|
||||
},
|
||||
@@ -1879,6 +1883,7 @@ export const es: TranslationResources = {
|
||||
sendMessage: "enviar mensaje",
|
||||
queueMessage: "mensaje de cola",
|
||||
muteUnmuteVoiceMode: "Silenciar el modo de voz/unmute",
|
||||
switchProject: "Cambiar proyecto",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "Disponible cuando el foco no está en un campo de texto o terminal.",
|
||||
|
||||
@@ -794,6 +794,8 @@ export const fr: TranslationResources = {
|
||||
closeChangesTab: "Fermer l'onglet Modifications",
|
||||
binaryFile: "Fichier binaire",
|
||||
tooLarge: "Diff trop grand pour être affiché",
|
||||
previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé",
|
||||
previewTooLargeDescription: "Réduisez la comparaison pour la prévisualiser ici",
|
||||
unified: "Différentiel unifié",
|
||||
split: "Différent côte à côte",
|
||||
switchToUnified: "Passer au diff unifié",
|
||||
@@ -938,6 +940,8 @@ export const fr: TranslationResources = {
|
||||
openSettings: "Ouvrir les paramètres du projet",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Supprimer le projet",
|
||||
removing: "Suppression...",
|
||||
},
|
||||
@@ -1881,6 +1885,7 @@ export const fr: TranslationResources = {
|
||||
sendMessage: "Envoyer un message",
|
||||
queueMessage: "Message de file d'attente",
|
||||
muteUnmuteVoiceMode: "Mode vocal/unmutemuet",
|
||||
switchProject: "Changer de projet",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts:
|
||||
|
||||
@@ -775,6 +775,8 @@ export const ja: TranslationResources = {
|
||||
closeChangesTab: "変更タブを閉じる",
|
||||
binaryFile: "バイナリファイル",
|
||||
tooLarge: "差分が大きすぎて表示できません",
|
||||
previewTooLargeTitle: "この差分は大きすぎるためプレビューできません",
|
||||
previewTooLargeDescription: "比較範囲を狭めると、ここでプレビューできます",
|
||||
unified: "ユニファイド差分",
|
||||
split: "左右比較",
|
||||
switchToUnified: "ユニファイド差分に切り替え",
|
||||
@@ -919,6 +921,8 @@ export const ja: TranslationResources = {
|
||||
openSettings: "プロジェクト設定を開く",
|
||||
openNewWindow: "新しいウィンドウで開く",
|
||||
openNewWindowFailed: "新しいウィンドウを開けませんでした",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "プロジェクトを削除",
|
||||
removing: "削除中...",
|
||||
},
|
||||
@@ -1847,6 +1851,7 @@ export const ja: TranslationResources = {
|
||||
sendMessage: "メッセージを送信",
|
||||
queueMessage: "メッセージをキューに追加",
|
||||
muteUnmuteVoiceMode: "音声モードのミュートを切り替え",
|
||||
switchProject: "プロジェクトを切り替え",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts:
|
||||
|
||||
@@ -786,6 +786,8 @@ export const ptBR: TranslationResources = {
|
||||
closeChangesTab: "Fechar a aba Alterações",
|
||||
binaryFile: "Arquivo binário",
|
||||
tooLarge: "Diff grande demais para exibir",
|
||||
previewTooLargeTitle: "Este diff é grande demais para visualizar",
|
||||
previewTooLargeDescription: "Reduza a comparação para visualizá-la aqui",
|
||||
unified: "Diff unificado",
|
||||
split: "Diff lado a lado",
|
||||
switchToUnified: "Alternar para diff unificado",
|
||||
@@ -930,6 +932,8 @@ export const ptBR: TranslationResources = {
|
||||
openSettings: "Abrir configurações do projeto",
|
||||
openNewWindow: "Abrir em nova janela",
|
||||
openNewWindowFailed: "Não foi possível abrir uma nova janela",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Remover projeto",
|
||||
removing: "Removendo...",
|
||||
},
|
||||
@@ -1862,6 +1866,7 @@ export const ptBR: TranslationResources = {
|
||||
sendMessage: "Enviar mensagem",
|
||||
queueMessage: "Enfileirar mensagem",
|
||||
muteUnmuteVoiceMode: "Silenciar/ativar modo de voz",
|
||||
switchProject: "Trocar projeto",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts:
|
||||
|
||||
@@ -786,6 +786,8 @@ export const ru: TranslationResources = {
|
||||
closeChangesTab: "Закрыть вкладку «Изменения»",
|
||||
binaryFile: "Бинарный файл",
|
||||
tooLarge: "Разница слишком велика для отображения",
|
||||
previewTooLargeTitle: "Этот diff слишком велик для предпросмотра",
|
||||
previewTooLargeDescription: "Сузьте сравнение, чтобы просмотреть его здесь",
|
||||
unified: "Единый дифференциал",
|
||||
split: "Параллельная разница",
|
||||
switchToUnified: "Переключить на объединенный diff",
|
||||
@@ -930,6 +932,8 @@ export const ru: TranslationResources = {
|
||||
openSettings: "Открыть настройки проекта",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Удалить проект",
|
||||
removing: "Удаление...",
|
||||
},
|
||||
@@ -1869,6 +1873,7 @@ export const ru: TranslationResources = {
|
||||
sendMessage: "Отправить сообщение",
|
||||
queueMessage: "Сообщение в очереди",
|
||||
muteUnmuteVoiceMode: "Отключить голосовой режим /unmute",
|
||||
switchProject: "Сменить проект",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "Доступно, когда фокус находится не в текстовом поле или терминале.",
|
||||
|
||||
@@ -756,6 +756,8 @@ export const zhCN: TranslationResources = {
|
||||
closeChangesTab: "关闭“更改”标签页",
|
||||
binaryFile: "二进制文件",
|
||||
tooLarge: "Diff 过大,无法显示",
|
||||
previewTooLargeTitle: "此 diff 过大,无法预览",
|
||||
previewTooLargeDescription: "缩小比较范围即可在此预览",
|
||||
unified: "Unified diff",
|
||||
split: "Side-by-side diff",
|
||||
switchToUnified: "切换到统一差异视图",
|
||||
@@ -899,6 +901,8 @@ export const zhCN: TranslationResources = {
|
||||
openSettings: "打开 project 设置",
|
||||
openNewWindow: "在新窗口中打开",
|
||||
openNewWindowFailed: "无法打开新窗口",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "移除 project",
|
||||
removing: "正在移除...",
|
||||
},
|
||||
@@ -1810,6 +1814,7 @@ export const zhCN: TranslationResources = {
|
||||
sendMessage: "发送消息",
|
||||
queueMessage: "消息排队",
|
||||
muteUnmuteVoiceMode: "静音/取消静音语音模式",
|
||||
switchProject: "切换项目",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "焦点不在文本输入框或终端内时可用。",
|
||||
|
||||
@@ -44,6 +44,7 @@ export type KeyboardActionId =
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.new"
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "workspace.pin"
|
||||
|
||||
@@ -29,6 +29,7 @@ export type KeyboardActionId =
|
||||
| "workspace.terminal.new"
|
||||
| "sidebar.toggle.right"
|
||||
| "workspace.new"
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "workspace.pin";
|
||||
@@ -62,6 +63,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.archive"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pin"; scope: KeyboardActionScope };
|
||||
|
||||
@@ -142,6 +142,18 @@ describe("keyboard-shortcuts", () => {
|
||||
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
|
||||
action: "workspace.new",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+P to switch project on mac",
|
||||
event: { key: "p", code: "KeyP", metaKey: true },
|
||||
context: { isMac: true, commandCenterOpen: false },
|
||||
action: "workspace.project.pick",
|
||||
},
|
||||
{
|
||||
name: "matches Ctrl+P to switch project on non-mac",
|
||||
event: { key: "p", code: "KeyP", ctrlKey: true },
|
||||
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
|
||||
action: "workspace.project.pick",
|
||||
},
|
||||
{
|
||||
name: "matches question-mark shortcut to toggle the shortcuts dialog",
|
||||
event: { key: "?", code: "Slash", shiftKey: true },
|
||||
@@ -410,6 +422,16 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "?", code: "Slash", shiftKey: true },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not switch project with Ctrl+P on non-mac while terminal is focused",
|
||||
event: { key: "p", code: "KeyP", ctrlKey: true },
|
||||
context: { isMac: false, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not switch project with Cmd+P while the command center is open",
|
||||
event: { key: "p", code: "KeyP", metaKey: true },
|
||||
context: { isMac: true, commandCenterOpen: true },
|
||||
},
|
||||
{
|
||||
name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)",
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
|
||||
@@ -131,6 +131,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
|
||||
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"new-agent": "settings.shortcuts.help.openProject",
|
||||
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
||||
"switch-project": "settings.shortcuts.help.switchProject",
|
||||
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
|
||||
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
||||
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
||||
@@ -231,6 +232,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- Switch project (New Workspace screen) ---
|
||||
{
|
||||
id: "workspace-project-pick-cmd-p-mac",
|
||||
action: "workspace.project.pick",
|
||||
combo: "Cmd+P",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "switch-project",
|
||||
section: "projects",
|
||||
label: "Switch project",
|
||||
keys: ["mod", "P"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-project-pick-ctrl-p-non-mac",
|
||||
action: "workspace.project.pick",
|
||||
combo: "Ctrl+P",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "switch-project",
|
||||
section: "projects",
|
||||
label: "Switch project",
|
||||
keys: ["mod", "P"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Archive workspace ---
|
||||
{
|
||||
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
|
||||
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
|
||||
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
|
||||
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
|
||||
["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }],
|
||||
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
|
||||
["workspace.pin", { id: "workspace.pin", scope: "sidebar" }],
|
||||
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
|
||||
|
||||
@@ -42,6 +42,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
|
||||
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
|
||||
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
|
||||
"workspace.new": { id: "workspace.new", scope: "sidebar" },
|
||||
"workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" },
|
||||
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
|
||||
"workspace.pin": { id: "workspace.pin", scope: "sidebar" },
|
||||
"worktree.new": { id: "worktree.new", scope: "sidebar" },
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveDiffLayout,
|
||||
SharedDiffView,
|
||||
} from "@/git/diff-pane";
|
||||
import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
||||
import { useCommitDiffFiles } from "@/git/use-diff-files";
|
||||
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
@@ -120,6 +121,9 @@ function WorkingDiffBody({
|
||||
if (workingDiff.notGit) {
|
||||
return <PanelState message={t("workspace.git.diff.notRepository")} />;
|
||||
}
|
||||
if (workingDiff.diffTooLarge) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
if (workingDiff.diffPayloadError) {
|
||||
return (
|
||||
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />
|
||||
|
||||
@@ -4,14 +4,7 @@ import type {
|
||||
WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
|
||||
export interface HostProjectListItem {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
hosts: WorkspaceStructureHostPlacement[];
|
||||
workspaceKeys: string[];
|
||||
}
|
||||
export type HostProjectListItem = WorkspaceStructureProject;
|
||||
|
||||
export interface HostProjectRouteContext {
|
||||
serverId: string;
|
||||
@@ -31,33 +24,21 @@ export function canCreateWorktreeForProjectKind(
|
||||
return projectKind === "git";
|
||||
}
|
||||
|
||||
export function buildHostProjectList(input: {
|
||||
projects: readonly WorkspaceStructureProject[];
|
||||
}): HostProjectListItem[] {
|
||||
return input.projects.map((project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.iconWorkingDir,
|
||||
hosts: project.hosts,
|
||||
workspaceKeys: project.workspaceKeys,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
|
||||
const projectKey = trimOptional(route.projectId);
|
||||
const projectId = route.projectId?.trim() || undefined;
|
||||
const iconWorkingDir = trimOptional(route.sourceDirectory);
|
||||
if (!projectKey || !iconWorkingDir) {
|
||||
if (!projectId || !iconWorkingDir) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
projectKey,
|
||||
projectName: trimOptional(route.displayName) ?? projectKey,
|
||||
projectKey: projectId,
|
||||
projectName: trimOptional(route.displayName) || projectId,
|
||||
projectKind: "git",
|
||||
iconWorkingDir,
|
||||
hosts: [
|
||||
{
|
||||
serverId: route.serverId,
|
||||
projectId,
|
||||
iconWorkingDir,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
@@ -73,20 +54,24 @@ export function hostProjectFromWorkspace(input: {
|
||||
if (!input.workspace) {
|
||||
return null;
|
||||
}
|
||||
const projectKey = input.workspace.projectId.trim();
|
||||
const projectId = input.workspace.projectId.trim() || undefined;
|
||||
if (!projectId) {
|
||||
return null;
|
||||
}
|
||||
const iconWorkingDir = input.workspace.projectRootPath.trim();
|
||||
if (!projectKey || !iconWorkingDir) {
|
||||
if (!iconWorkingDir) {
|
||||
return null;
|
||||
}
|
||||
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
|
||||
return {
|
||||
projectKey,
|
||||
projectName: input.workspace.projectDisplayName || projectKey,
|
||||
projectKey: projectId,
|
||||
projectName: input.workspace.projectDisplayName || projectId,
|
||||
projectKind: input.workspace.projectKind,
|
||||
iconWorkingDir,
|
||||
hosts: [
|
||||
{
|
||||
serverId: input.serverId,
|
||||
projectId: input.workspace.projectId,
|
||||
iconWorkingDir,
|
||||
canCreateWorktree: canCreate,
|
||||
},
|
||||
@@ -99,11 +84,25 @@ function projectCanCreateWorktree(project: HostProjectListItem): boolean {
|
||||
return project.hosts.some((h) => h.canCreateWorktree);
|
||||
}
|
||||
|
||||
function getHostProjectPlacement(
|
||||
project: HostProjectListItem,
|
||||
serverId: string,
|
||||
): WorkspaceStructureHostPlacement | null {
|
||||
for (const host of project.hosts) {
|
||||
if (host.serverId === serverId) return host;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getHostProjectSourceDirectory(
|
||||
project: HostProjectListItem,
|
||||
serverId: string,
|
||||
): string | null {
|
||||
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
|
||||
return getHostProjectPlacement(project, serverId)?.iconWorkingDir ?? null;
|
||||
}
|
||||
|
||||
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
|
||||
return getHostProjectPlacement(project, serverId)?.projectId ?? null;
|
||||
}
|
||||
|
||||
export function canCreateWorkspaceForHostProject(input: {
|
||||
@@ -111,7 +110,7 @@ export function canCreateWorkspaceForHostProject(input: {
|
||||
serverId: string;
|
||||
allowAllProjects: boolean;
|
||||
}): boolean {
|
||||
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
|
||||
const host = getHostProjectPlacement(input.project, input.serverId);
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
@@ -144,8 +143,20 @@ export function resolveInitialWorkspaceProject(input: {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const candidatePlacement = candidate.hosts.find(
|
||||
(host) => host.serverId === input.serverId && host.projectId,
|
||||
);
|
||||
const hydratedProject =
|
||||
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
|
||||
(candidatePlacement
|
||||
? input.projects.find((project) =>
|
||||
project.hosts.some(
|
||||
(host) =>
|
||||
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId,
|
||||
),
|
||||
)
|
||||
: undefined) ??
|
||||
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
|
||||
candidate;
|
||||
if (
|
||||
canCreateWorkspaceForHostProject({
|
||||
project: hydratedProject,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user