Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e379c58684 | ||
|
|
ab747ce53d | ||
|
|
85ea3956e8 | ||
|
|
5bd80a05bc | ||
|
|
f239fa1c82 | ||
|
|
5b18ce804f | ||
|
|
63f3c54b95 | ||
|
|
f68d9c5788 | ||
|
|
bd72781482 | ||
|
|
1207a21aae | ||
|
|
a41d93ea46 | ||
|
|
d73096c937 |
@@ -1,10 +1,67 @@
|
||||
name: Build App (Preview)
|
||||
|
||||
# Builds the Tauri app for branches other than main and exposes the bundles as
|
||||
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
|
||||
# smoke-testing of feature branches before they merge.
|
||||
# Builds the Tauri app for branches other than main and publishes the bundles as
|
||||
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
|
||||
# sync.
|
||||
#
|
||||
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
||||
# a push that breaks any of them fails here. build-app.yml used to do that job
|
||||
# in parallel and publish nothing, which meant six OS builds per push and one
|
||||
# unreachable set of bundles; it is now releases-only.
|
||||
#
|
||||
# The cost of the swap, stated plainly: one prerelease per PR commit that
|
||||
# touches `app/**` — so the workflow prunes its own, keeping the newest
|
||||
# KEEP_PREVIEWS (see Lifecycle).
|
||||
#
|
||||
# ## Why not workflow artifacts
|
||||
#
|
||||
# Two attempts failed before this one, and both failure modes are worth knowing:
|
||||
#
|
||||
# * `actions/upload-artifact@v4` cannot run here at all. It bundles
|
||||
# `@actions/artifact` v2, whose `isGhes()` treats any GITHUB_SERVER_URL that
|
||||
# is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and
|
||||
# throws before making a single request. act_runner sets that variable to this
|
||||
# Gitea instance, so every platform died with "GHESNotSupportedError" — after
|
||||
# the whole Tauri build had been paid for (run #265).
|
||||
# * `@v3` uploads *succeed*, and the files are downloadable by direct URL — but
|
||||
# Gitea does not **list** them: `/api/v1/…/runs/<id>/artifacts` reports
|
||||
# `total_count: 0` and the run page shows nothing (verified on run #267).
|
||||
# A build nobody can find is not a build.
|
||||
#
|
||||
# So previews publish the same way every other workflow here does: curl to the
|
||||
# Gitea releases API. One release per preview, tagged `preview-<sha>`.
|
||||
#
|
||||
# ## Lifecycle
|
||||
#
|
||||
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
|
||||
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
|
||||
# whose tag does *not* start with `v[0-9]` — so previews never crowd the real
|
||||
# release list, and a manual cleanup sweeps any this workflow missed.
|
||||
#
|
||||
# But that cleanup is a manual, dry-run-by-default action, and one prerelease per
|
||||
# pushed commit accumulates faster than anyone runs it. So the last job here
|
||||
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
|
||||
# release; the point of a preview is the build you are testing now.
|
||||
#
|
||||
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
# How many preview releases survive a run, newest first — including the one
|
||||
# just published.
|
||||
KEEP_PREVIEWS: "2"
|
||||
|
||||
on:
|
||||
# Every push to an open PR: this *is* the branch's build check — it compiles
|
||||
# Linux, macOS and Windows — and publishing the result costs nothing extra
|
||||
# once they are built. build-app.yml deliberately no longer runs on PRs.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app-preview.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -12,6 +69,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
sha: ${{ steps.version.outputs.SHA }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -23,13 +81,88 @@ jobs:
|
||||
run: |
|
||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}"
|
||||
# From the checkout, not from `gitea.sha`: on a pull_request event
|
||||
# that variable can be the merge ref, which is not the commit anyone
|
||||
# is testing and not something to hang a tag on.
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The patch number is computed exactly as build-app.yml does it, so a
|
||||
# preview is labelled with the version the release it previews would
|
||||
# carry. This used to be hard-coded `.0`, which made every preview
|
||||
# installer claim to be x.y.0 no matter what it contained.
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
|
||||
else
|
||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Computed preview version: ${VERSION}"
|
||||
|
||||
build-linux:
|
||||
# One release, created once. The three build jobs run concurrently, so
|
||||
# get-or-create in each of them would race on the same tag: whoever loses gets
|
||||
# a 409 and (the way the old build-app.yml parsed it) an empty release id that
|
||||
# still reported success. Creating it in a job they all depend on removes the
|
||||
# race rather than handling it.
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version]
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.RELEASE_ID }}
|
||||
tag: ${{ steps.release.outputs.TAG }}
|
||||
steps:
|
||||
- name: Create the preview release
|
||||
id: release
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
SHA: ${{ needs.compute-version.outputs.sha }}
|
||||
BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="preview-${VERSION##*.}"
|
||||
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Idempotent: re-dispatching the same commit must update the existing
|
||||
# release rather than fail on the duplicate tag.
|
||||
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||
case "${HTTP_CODE}" in
|
||||
200) echo "Release ${TAG} already exists, reusing" ;;
|
||||
404)
|
||||
echo "Creating release ${TAG}"
|
||||
# prerelease: true keeps it off "latest" — this is a branch build,
|
||||
# not something anyone should install by accident.
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
|
||||
cat release.json >&2 || true
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "Failed to parse release id; response was:" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
||||
echo "Release ${TAG} is id ${RELEASE_ID}"
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, create-release]
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
@@ -128,17 +261,47 @@ jobs:
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||
- name: Upload Linux bundles to the preview release
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
files=(artifacts/*)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No Linux bundles were produced" >&2
|
||||
exit 1
|
||||
fi
|
||||
for file in "${files[@]}"; do
|
||||
filename=$(basename "$file")
|
||||
EXISTING_ID=$(curl -sS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
echo "Replacing existing asset ${filename}"
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||
fi
|
||||
echo "Uploading ${filename}..."
|
||||
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
needs: [compute-version]
|
||||
needs: [compute-version, create-release]
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
@@ -209,17 +372,47 @@ jobs:
|
||||
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||
- name: Upload macOS bundles to the preview release
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
files=(artifacts/*)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No macOS bundles were produced" >&2
|
||||
exit 1
|
||||
fi
|
||||
for file in "${files[@]}"; do
|
||||
filename=$(basename "$file")
|
||||
EXISTING_ID=$(curl -sS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
echo "Replacing existing asset ${filename}"
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||
fi
|
||||
echo "Uploading ${filename}..."
|
||||
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
needs: [compute-version]
|
||||
needs: [compute-version, create-release]
|
||||
defaults:
|
||||
run:
|
||||
shell: cmd
|
||||
@@ -308,10 +501,78 @@ jobs:
|
||||
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
||||
dir artifacts\
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# PowerShell, because this job's default shell is cmd. Same
|
||||
# delete-then-upload shape as the other two.
|
||||
- name: Upload Windows bundles to the preview release
|
||||
shell: powershell
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
||||
$files = @(Get-ChildItem -File -Path artifacts\*)
|
||||
if ($files.Count -eq 0) { throw "No Windows bundles were produced" }
|
||||
|
||||
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
|
||||
foreach ($file in $files) {
|
||||
$name = $file.Name
|
||||
$dupe = $existing | Where-Object { $_.name -eq $name }
|
||||
if ($dupe) {
|
||||
Write-Host "Replacing existing asset $name"
|
||||
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
|
||||
}
|
||||
Write-Host "Uploading $name..."
|
||||
$uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
|
||||
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
||||
-X POST -H "Authorization: token $env:TOKEN" `
|
||||
-H "Content-Type: application/octet-stream" `
|
||||
--data-binary "@$($file.FullName)" $uploadUri
|
||||
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# Keep the preview list short. Runs after the builds and only if all three
|
||||
# succeeded: a half-published run must not be what evicts a good older build.
|
||||
prune-previews:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [create-release, build-linux, build-macos, build-windows]
|
||||
steps:
|
||||
- name: Delete all but the newest preview releases
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
KEEP_TAG: ${{ needs.create-release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsS -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json
|
||||
|
||||
# Newest first by creation time, `preview-` only, and never the one
|
||||
# this run just published — a clock skew must not delete it.
|
||||
DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
|
||||
import json, sys
|
||||
keep, keep_tag = int(sys.argv[1]), sys.argv[2]
|
||||
previews = [r for r in json.load(open("releases.json"))
|
||||
if r["tag_name"].startswith("preview-")]
|
||||
previews.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
for r in previews[keep:]:
|
||||
if r["tag_name"] != keep_tag:
|
||||
print(r["id"], r["tag_name"])
|
||||
PY
|
||||
)
|
||||
|
||||
if [ -z "${DOOMED}" ]; then
|
||||
echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "${DOOMED}" | while read -r ID TAG; do
|
||||
[ -z "${ID}" ] && continue
|
||||
echo "Deleting ${TAG} (id ${ID})"
|
||||
# Best effort: a preview someone deleted by hand mid-run is not a
|
||||
# reason to fail a build that otherwise succeeded.
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
|
||||
done
|
||||
|
||||
@@ -7,14 +7,14 @@ on:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
||||
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
|
||||
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
|
||||
# the same three and publishes them. Six OS builds per push, one set of which
|
||||
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -47,8 +47,12 @@ jobs:
|
||||
echo "Latest matching tag: ${LATEST_TAG}"
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
else
|
||||
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
|
||||
PATCH=$(git rev-list --count HEAD)
|
||||
# A minor line nobody has tagged yet is a *new* line, and a new line
|
||||
# starts at .0 — that is what "we are moving to 0.4.x" means. The
|
||||
# old fallback here counted every commit in the repository, which
|
||||
# would have made the first 0.4 build 0.4.234.
|
||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||
|
||||
@@ -59,6 +59,17 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
|
||||
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
|
||||
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
|
||||
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
|
||||
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
|
||||
`moveTab` deliberately does not activate what it moves.
|
||||
- **The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way.** Tauri's
|
||||
`dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||
turned off: `TerminalView` needs Tauri's native drag-drop event because it is the only one
|
||||
that carries dropped *file paths*. An HTML5 drag also carries a `DataTransfer`, which the
|
||||
default handler types into any text field the drag is released over.
|
||||
- **A new app-level shortcut must not swallow a text-editing chord.** `useKeyboardShortcuts`
|
||||
binds on `document` in the capture phase, so `inTextField()` guards the arrow bindings —
|
||||
excluding xterm's helper textarea, which is an input-method shim rather than a field.
|
||||
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||
@@ -84,8 +95,10 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
Use `--text-disabled` rather than `disabled:opacity-50`.
|
||||
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
|
||||
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
|
||||
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump.
|
||||
`Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal.
|
||||
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
|
||||
`Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's
|
||||
`kill-word` inside the terminal, and plain `Ctrl+←/→` is its word-wise cursor motion, which is
|
||||
why tab-moving takes Shift.
|
||||
|
||||
### Backend Structure (`app/src-tauri/src/`)
|
||||
|
||||
@@ -104,6 +117,35 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
OAuth listener, wrong for remote control of a browser. Host ports are confined to
|
||||
`47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them;
|
||||
a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
|
||||
- **`popout.rs` puts the same URL in a second OS window** (`WebviewUrl::External`), so the view
|
||||
can be watched on another monitor or pinned on top while the main window is used for work.
|
||||
Three things it rests on: no capability lists that window, so it has **no IPC surface** — do
|
||||
not give it one; the app CSP does not apply, because it is a top-level document rather than a
|
||||
frame, and the token gate is what protects the port in both cases; and the window is owned by
|
||||
the *session*, so the supervisor's teardown closes it rather than leaving a window onto a
|
||||
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
|
||||
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
||||
the browser.
|
||||
- **`page.rs` opens a page, which is the one thing the pane could not do.** A URL plus a
|
||||
viewport: launch a browser in the container, `browser.bind()` it so the pane shows it, and
|
||||
keep the handle. Serves auth (the OAuth callback listener is *in* the container, so a
|
||||
container-side browser closes the loop with no host round trip and no auth bridge) and dev
|
||||
servers on container loopback. **Verified: a second client cannot join a bound browser** —
|
||||
`chromium.connect()` against the published endpoint times out in every URL form, because that
|
||||
socket speaks the dashboard's transport, not the public connect protocol. So whoever launches
|
||||
is the only process that can drive, which is why the helper is resident and why live resize
|
||||
applies to pages *we* opened and never to `@playwright/mcp`'s (those take `--viewport-size` /
|
||||
`PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch). Control is a polled JSON file in `/tmp` — no port,
|
||||
no second listener — and a re-open with a helper already up *navigates* rather than
|
||||
relaunching, so a session signed in on one page survives to the next.
|
||||
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger
|
||||
window is the same pixels drawn larger. `page.setViewportSize()` is what reflows (measured
|
||||
against a `@media (max-width: 900px)` rule), and match-window mode pushes the pop-out's
|
||||
settled `Resized` size into it — debounced by generation counter, since a drag emits
|
||||
continuously and each one costs a container exec.
|
||||
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
|
||||
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
|
||||
container stopped, process exited.
|
||||
- **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest`
|
||||
installs into `~/.npm/_npx/<hash>/node_modules`, not any `node_modules`, so `detect.rs`
|
||||
globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also
|
||||
|
||||
+64
-1
@@ -191,6 +191,12 @@ Anthropic-backend project uses that token without its own login. See
|
||||
terminal tab to rename it, jump to its project home, or close it; double-click to rename inline.
|
||||
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
|
||||
a terminal.
|
||||
|
||||
**Drag a tab to reorder it.** A line shows where it will land; **Escape** abandons the drag.
|
||||
Dropping does not change which tab you are looking at — so you can rearrange the strip without
|
||||
pulling focus away from a terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the
|
||||
*active* tab the same way without the mouse (they leave text fields alone, where that chord
|
||||
still selects by word). The order is per-session: it is not saved when you quit.
|
||||
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs
|
||||
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
|
||||
the built-in help.
|
||||
@@ -211,7 +217,7 @@ for selecting a project and for two quick controls that appear on hover — star
|
||||
Claude terminal. Everything else about a project lives in Project Home.
|
||||
|
||||
The header shows the project name, its status, how long the container has been up, and the action
|
||||
buttons. Below that are five tabs:
|
||||
buttons. Below that are six tabs:
|
||||
|
||||
| Tab | What it's for |
|
||||
|---|---|
|
||||
@@ -220,6 +226,7 @@ buttons. Below that are five tabs:
|
||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||
|
||||
### Sessions
|
||||
|
||||
@@ -257,6 +264,61 @@ included, and each tile opens a list of what it found.
|
||||
|
||||
The counts are only available while the container is running.
|
||||
|
||||
### The Browser Tab
|
||||
|
||||
When Claude drives a browser with Playwright inside the container, the **Browser** tab shows you
|
||||
that browser live — and lets you take it over with your own mouse and keyboard.
|
||||
|
||||
It is **off by default and opted into per project**, and it never installs anything on its own.
|
||||
Opening the tab only *probes* the container, so it can tell you what is missing before you ask for
|
||||
a view; installing Playwright and downloading a browser are separate, labelled buttons that state
|
||||
what they cost before you press them. See
|
||||
[What's Inside the Container](#whats-inside-the-container) for why the browser itself is not
|
||||
pre-installed.
|
||||
|
||||
Press **Start browser view** and the pane fills with Playwright's own dashboard, running inside the
|
||||
container and reached over a token-gated listener on your machine's loopback address. Nothing is
|
||||
exposed off the machine.
|
||||
|
||||
#### Opening a page yourself
|
||||
|
||||
**Open a page…** launches a browser inside the container at a URL and viewport you choose, and
|
||||
publishes it to this pane. Two uses:
|
||||
|
||||
- **A sign-in page.** The callback the tool is waiting for is a listener *inside* the container, so
|
||||
a container-side browser completes the login without anything crossing to your host browser.
|
||||
When a long URL appears in a terminal, the prompt that offers to open it on your host now also
|
||||
offers **In container**, which does the same thing in one click.
|
||||
- **A dev server.** `http://localhost:5173` inside the container is reachable with no port mapping
|
||||
and nothing exposed to your network — which is how you watch a UI Claude is building, and click
|
||||
around it yourself.
|
||||
|
||||
The **viewport** is the page's own resolution, and it is not the same thing as the window size.
|
||||
The pane shows a video of the browser, so a bigger window draws the same pixels larger; changing
|
||||
the viewport is what makes the layout actually reflow. Pick a preset or type a size.
|
||||
|
||||
Note the limit, because it is not obvious: a browser Claude opened through `@playwright/mcp` can
|
||||
be *watched* but not resized — a published browser admits only the client that launched it. Set
|
||||
its size with `PLAYWRIGHT_MCP_VIEWPORT_SIZE=1920x1080` in the project's environment variables
|
||||
instead.
|
||||
|
||||
#### Watching it while you work
|
||||
|
||||
Press **Open in own window** and the view moves out of the tab into a window of its own — put it on
|
||||
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
|
||||
terminal. **Match window** goes further: the page's viewport follows the window as you drag it, so
|
||||
the pop-out becomes a responsive-design ruler. It applies to pages opened with **Open a page…**,
|
||||
for the reason above. This is a window change only: the browser and the view keep running throughout, so
|
||||
popping out and back costs nothing and interrupts nothing.
|
||||
|
||||
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
|
||||
two viewers would both be able to *drive* the browser, and two cursors on one page is not useful.
|
||||
**Put back in tab**, or just closing the window, brings it back.
|
||||
|
||||
The window belongs to the view, not to the tab: closing the project's home tab leaves it open, and
|
||||
stopping the view — by pressing **Stop**, stopping the container, or removing the project — closes
|
||||
it, because a window showing a viewer that no longer exists is worse than no window.
|
||||
|
||||
---
|
||||
|
||||
## Project Management
|
||||
@@ -1119,6 +1181,7 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
|
||||
| **Ctrl+Tab** | Switch to the next tab |
|
||||
| **Ctrl+Shift+Tab** | Switch to the previous tab |
|
||||
| **Ctrl+1** … **Ctrl+9** | Jump to the first through ninth tab |
|
||||
| **Ctrl+Shift+←** / **Ctrl+Shift+→** | Move the active tab one place along the strip (the mouse equivalent is dragging it) |
|
||||
|
||||
> **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word
|
||||
> before the cursor, and it is used constantly in the terminal this app is built around. Binding it
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "triple-c",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5163,7 +5163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "triple-c"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "triple-c"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||
use crate::browser_view::{manager, BrowserViewStatus};
|
||||
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the pane on or off for a project.
|
||||
@@ -97,6 +97,220 @@ pub async fn install_browser_view_browser(
|
||||
install::install_browser(&app_handle, &project_id, &container_id, target).await
|
||||
}
|
||||
|
||||
/// Detach the view into a window of its own, or raise the one already open.
|
||||
///
|
||||
/// Host-side and window-only: the viewer keeps running exactly as it was, and
|
||||
/// this touches neither the container nor the proxy. Requires a *live* view,
|
||||
/// because a window with nothing behind it is not worth opening — the pane
|
||||
/// only offers the button in that state, and this enforces it.
|
||||
#[tauri::command]
|
||||
pub async fn open_browser_view_popout(
|
||||
project_id: String,
|
||||
always_on_top: bool,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let status = manager().status(&project_id).await;
|
||||
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
|
||||
return Err(
|
||||
"The browser view isn't running. Start it before opening it in its own window."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let name = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| "Triple-C".to_string());
|
||||
|
||||
popout::open(&app_handle, &project_id, &name, url, always_on_top)
|
||||
}
|
||||
|
||||
/// Close the pop-out, putting the view back in the tab. No-op if it is closed.
|
||||
///
|
||||
/// Propagates a failed close rather than reporting success: the pane restores
|
||||
/// its iframe on success, and doing that with the window still up puts two
|
||||
/// viewers on one browser.
|
||||
#[tauri::command]
|
||||
pub async fn close_browser_view_popout(
|
||||
project_id: String,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
popout::close(&app_handle, &project_id)
|
||||
}
|
||||
|
||||
/// Whether the pop-out is open, and whether it is pinned on top.
|
||||
///
|
||||
/// Read on every pane mount: the window outlives the pane — which is unmounted
|
||||
/// whenever another Project Home sub-tab is selected — so neither fact can be
|
||||
/// carried in component state.
|
||||
#[tauri::command]
|
||||
pub async fn get_browser_view_popout_state(
|
||||
project_id: String,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<popout::PopoutState, String> {
|
||||
Ok(popout::state(&app_handle, &project_id))
|
||||
}
|
||||
|
||||
/// Pin the pop-out above other windows, so it can be watched while working in
|
||||
/// the main one.
|
||||
#[tauri::command]
|
||||
pub async fn set_browser_view_popout_always_on_top(
|
||||
project_id: String,
|
||||
on_top: bool,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
popout::set_always_on_top(&app_handle, &project_id, on_top)
|
||||
}
|
||||
|
||||
/// Open a URL in a browser *inside* the container, published so the pane shows
|
||||
/// it.
|
||||
///
|
||||
/// Two uses, one action: an auth URL — where the OAuth callback listener is in
|
||||
/// the container too, so the loop closes without the host being involved at all
|
||||
/// — and a dev server on container loopback, which is how you watch a UI Claude
|
||||
/// is building.
|
||||
///
|
||||
/// The scheme allow-list mirrors the URL relay's: `http`/`https` only, so this
|
||||
/// can never be talked into opening `file:` on the container's filesystem.
|
||||
#[tauri::command]
|
||||
pub async fn open_page_in_container_browser(
|
||||
project_id: String,
|
||||
url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
show_window: bool,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<page::PageState, String> {
|
||||
let trimmed = url.trim();
|
||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||
return Err("Only http:// and https:// URLs can be opened in the browser.".to_string());
|
||||
}
|
||||
let container_id = running_container(&state, &project_id, "opening a page").await?;
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"Checking the container for Playwright…",
|
||||
);
|
||||
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||
let opened = page::open(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
&container_id,
|
||||
&detection,
|
||||
trimmed,
|
||||
page::Viewport::sane(width, height),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// A page nobody can see is not an opened page. Opening one used to leave
|
||||
// the user to go and press Start in the Browser tab themselves — and from
|
||||
// the terminal's URL prompt, with no indication that was even needed.
|
||||
// Asking for a page *is* asking to watch it, so the viewer comes up too.
|
||||
let status = manager().status(&project_id).await;
|
||||
if status.state != BrowserViewState::Running {
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"Starting the viewer…",
|
||||
);
|
||||
manager()
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// From the terminal there is no pane on screen to fill, so the page needs a
|
||||
// window of its own or it lands somewhere the user isn't looking.
|
||||
if show_window {
|
||||
let status = manager().status(&project_id).await;
|
||||
if let Some(url) = status.url.as_deref() {
|
||||
let name = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| "Triple-C".to_string());
|
||||
popout::open(&app_handle, &project_id, &name, url, false)?;
|
||||
}
|
||||
}
|
||||
|
||||
crate::commands::project_commands::emit_progress(&app_handle, &project_id, "");
|
||||
Ok(opened)
|
||||
}
|
||||
|
||||
/// Resize the page this opened. The pop-out's "match window" mode calls this on
|
||||
/// every settled resize, so it is deliberately cheap: one control-file write.
|
||||
#[tauri::command]
|
||||
pub async fn set_container_page_viewport(
|
||||
project_id: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = running_container(&state, &project_id, "resizing the page").await?;
|
||||
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||
}
|
||||
|
||||
/// State of the page this opened, if any. Never fails: "no page" is an answer.
|
||||
#[tauri::command]
|
||||
pub async fn get_container_page_state(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<page::PageState, String> {
|
||||
let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else {
|
||||
return Ok(page::PageState::default());
|
||||
};
|
||||
Ok(page::state(&container_id).await)
|
||||
}
|
||||
|
||||
/// Close the page this opened, leaving the view itself running.
|
||||
#[tauri::command]
|
||||
pub async fn close_container_page(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = running_container(&state, &project_id, "closing the page").await?;
|
||||
page::close(&container_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make the page track the pop-out window's size as it is dragged.
|
||||
///
|
||||
/// Only affects a page **this app opened**: a bound browser admits no second
|
||||
/// client, so one `@playwright/mcp` launched keeps the viewport it was given.
|
||||
/// Turning it on applies the window's current size immediately, so the toggle
|
||||
/// has a visible effect without waiting for a drag.
|
||||
#[tauri::command]
|
||||
pub async fn set_browser_view_match_window(
|
||||
project_id: String,
|
||||
enabled: bool,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
popout::set_match_window(&project_id, enabled);
|
||||
if !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let container_id = running_container(&state, &project_id, "matching the window").await?;
|
||||
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||
}
|
||||
|
||||
/// Whether match-window mode is on. Read on mount, like the rest of the
|
||||
/// pop-out's state — the pane is unmounted whenever another sub-tab is shown.
|
||||
#[tauri::command]
|
||||
pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, String> {
|
||||
Ok(popout::match_window(&project_id))
|
||||
}
|
||||
|
||||
/// The project's container, or a sentence saying why there isn't one.
|
||||
///
|
||||
/// Every command here needs a *running* container, and every one of them used
|
||||
|
||||
@@ -93,6 +93,32 @@ pub struct PlaywrightDetection {
|
||||
/// user's own scripts and not for the MCP plugin.
|
||||
#[serde(default)]
|
||||
pub chrome_channel: Option<String>,
|
||||
/// The Chromium binary the *resolved* Playwright would launch, asked of the
|
||||
/// build itself rather than derived from the cache listing.
|
||||
#[serde(default)]
|
||||
pub chromium_executable: Option<String>,
|
||||
/// Whether that binary is actually on disk.
|
||||
///
|
||||
/// False with a non-empty [`Self::browsers`] is the revision-skew case: two
|
||||
/// Playwright copies in one container pin different revisions, so the cache
|
||||
/// can be full of browsers and every launch still fail.
|
||||
#[serde(default)]
|
||||
pub chromium_executable_exists: bool,
|
||||
/// The version a *script's* `require("playwright")` resolves to.
|
||||
///
|
||||
/// Tracked separately from [`Self::playwright_version`] because they are
|
||||
/// routinely different in one directory: `@playwright/cli` pins its own
|
||||
/// `playwright-core`, npm hoists that, and a separately-installed
|
||||
/// `playwright` then nests a second core beside it. The viewer uses one,
|
||||
/// Claude's scripts use the other.
|
||||
#[serde(default)]
|
||||
pub script_playwright_version: Option<String>,
|
||||
/// The Chromium that copy would launch, and whether it is there. This is
|
||||
/// the pair that decides whether a script Claude writes actually runs.
|
||||
#[serde(default)]
|
||||
pub script_chromium_executable: Option<String>,
|
||||
#[serde(default)]
|
||||
pub script_chromium_executable_exists: bool,
|
||||
/// Where the probe looked, echoed back for the "not found" message.
|
||||
#[serde(default)]
|
||||
pub searched: Vec<String>,
|
||||
@@ -155,13 +181,82 @@ impl PlaywrightDetection {
|
||||
None
|
||||
}
|
||||
|
||||
/// The revision-skew sentence, for the pane's browser step.
|
||||
///
|
||||
/// Separate from [`Self::blocker`] because it does not block the *viewer* —
|
||||
/// the dashboard runs fine; it is the browser that cannot start. Names both
|
||||
/// halves, because "install a browser" over a cache that visibly already
|
||||
/// has one reads as nonsense without them.
|
||||
pub fn skew_message(&self) -> Option<String> {
|
||||
if !self.revision_skew() {
|
||||
return None;
|
||||
}
|
||||
// Which half is broken changes what the user sees, so say the one that
|
||||
// is. The scripts case is the one that looks like a lie: the pane is
|
||||
// green, the viewer works, and every script Claude writes dies.
|
||||
if self.scripts_cannot_launch() {
|
||||
return Some(format!(
|
||||
"This container has {}, and the viewer works — but `require(\"playwright\")` \
|
||||
resolves Playwright {}, which launches {}. That file isn't there, so every \
|
||||
script Claude writes fails with “Executable doesn't exist”. Two copies ended \
|
||||
up in one tree: `@playwright/cli` pins its own `playwright-core`, and a \
|
||||
separately-installed `playwright` nests a second one beside it. “Set up \
|
||||
Playwright” below reinstalls them as one consistent set.",
|
||||
self.browsers.join(", "),
|
||||
self.script_playwright_version.as_deref().unwrap_or("?"),
|
||||
self.script_chromium_executable.as_deref().unwrap_or("?"),
|
||||
));
|
||||
}
|
||||
Some(format!(
|
||||
"This container has {}, but Playwright {} launches {} — which isn't there, so \
|
||||
every `chromium.launch()` fails with “Executable doesn't exist”. That happens \
|
||||
when two Playwright copies share a container (typically an npx `@playwright/mcp` \
|
||||
alongside this one); each pins its own browser revision. “Install Chromium” below \
|
||||
fetches the revision this build needs — it runs that build's own installer, so it \
|
||||
cannot pick the wrong one again.",
|
||||
self.browsers.join(", "),
|
||||
self.playwright_version.as_deref().unwrap_or("?"),
|
||||
self.chromium_executable.as_deref().unwrap_or("?"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether Playwright is present but has no browser at all to drive —
|
||||
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
||||
/// still runs, it just has nothing to show until a browser is bound.
|
||||
pub fn needs_browser(&self) -> bool {
|
||||
self.playwright_version.is_some()
|
||||
&& self.browsers.is_empty()
|
||||
&& self.chrome_channel.is_none()
|
||||
&& (self.browsers.is_empty() || self.revision_skew())
|
||||
}
|
||||
|
||||
/// Browsers are installed, but not the revision this Playwright launches.
|
||||
///
|
||||
/// The container looks equipped and every `chromium.launch()` fails with
|
||||
/// "Executable doesn't exist". It happens whenever two Playwright copies
|
||||
/// share a container — the npx `@playwright/mcp` one and a `/workspace`
|
||||
/// one — because each pins its own revision and installs into the same
|
||||
/// cache. The install action fixes it: it runs the *resolved* build's own
|
||||
/// CLI, so it fetches exactly the revision that was missing.
|
||||
///
|
||||
/// Requires the probe to have answered: an older container image, or a
|
||||
/// Playwright too broken to `require`, leaves `chromium_executable` unset,
|
||||
/// and "didn't answer" must not read as "skewed".
|
||||
pub fn revision_skew(&self) -> bool {
|
||||
!self.browsers.is_empty() && (self.viewer_cannot_launch() || self.scripts_cannot_launch())
|
||||
}
|
||||
|
||||
/// The copy serving the viewer would not find its browser.
|
||||
fn viewer_cannot_launch(&self) -> bool {
|
||||
self.chromium_executable.is_some() && !self.chromium_executable_exists
|
||||
}
|
||||
|
||||
/// `require("playwright")` — what every script Claude writes uses — would
|
||||
/// not find its browser. Independent of the above, and the more common of
|
||||
/// the two: `@playwright/cli` pins a `playwright-core`, npm hoists it, and
|
||||
/// a separately-installed `playwright` nests a second one that no browser
|
||||
/// was ever downloaded for.
|
||||
fn scripts_cannot_launch(&self) -> bool {
|
||||
self.script_chromium_executable.is_some() && !self.script_chromium_executable_exists
|
||||
}
|
||||
|
||||
/// The searched roots as prose, so a message never trails off into "Looked
|
||||
@@ -277,6 +372,30 @@ const PROBE: &str = concat!(
|
||||
// the pane claim a browser is present when none is.
|
||||
r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#,
|
||||
r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#,
|
||||
// What this Playwright would *actually launch*, and whether it is there.
|
||||
//
|
||||
// A cache listing is not the same question. Two Playwright copies in one
|
||||
// container — the npx `@playwright/mcp` one and a `/workspace` one — pin
|
||||
// different browser revisions, and each installs its own. So the cache can
|
||||
// hold `chromium-1237` while the resolved build wants `chromium-1234` and
|
||||
// every `chromium.launch()` dies with "Executable doesn't exist", *while
|
||||
// the pane reports a browser installed*. Asking the build itself sidesteps
|
||||
// revision arithmetic entirely: this is the path a launch would use.
|
||||
r#"const exe=(dir)=>{try{const bt=require(dir).chromium;"#,
|
||||
r#"const ep=bt&&bt.executablePath?bt.executablePath():null;"#,
|
||||
r#"return ep?[ep,fs.existsSync(ep)]:null;}catch(e){return null;}};"#,
|
||||
r#"if(core){const r=exe(path.dirname(core));"#,
|
||||
r#"if(r){out.chromium_executable=r[0];out.chromium_executable_exists=r[1];}}"#,
|
||||
// And separately: what a *script* gets. `require("playwright")` is what
|
||||
// every Playwright example writes, and it resolves the wrapper — which
|
||||
// carries its own nested `playwright-core` whenever npm could not settle on
|
||||
// one version. That copy can want a different browser revision than the one
|
||||
// the viewer's copy installed, so it is asked its own question.
|
||||
r#"try{const w=res("playwright/package.json");"#,
|
||||
r#"if(w){const j=JSON.parse(fs.readFileSync(w,"utf8"));out.script_playwright_version=j.version;"#,
|
||||
r#"const wc=at("playwright-core/package.json",path.dirname(w));"#,
|
||||
r#"const r=exe(path.dirname(wc||w));"#,
|
||||
r#"if(r){out.script_chromium_executable=r[0];out.script_chromium_executable_exists=r[1];}}}catch(e){}"#,
|
||||
// The Chrome *channel* is an apt package, not a Playwright download, so it
|
||||
// is looked for where apt puts it.
|
||||
r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#,
|
||||
@@ -467,6 +586,66 @@ mod tests {
|
||||
assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_probe_asks_playwright_what_it_would_launch() {
|
||||
// Not derived from the cache listing — asked of the build, because the
|
||||
// cache can hold a browser this build will never launch.
|
||||
assert!(PROBE.contains("executablePath"), "{}", PROBE);
|
||||
assert!(PROBE.contains("out.chromium_executable_exists"), "{}", PROBE);
|
||||
}
|
||||
|
||||
/// A container carrying browsers from a *different* Playwright copy.
|
||||
fn skewed() -> PlaywrightDetection {
|
||||
parse_probe_output(&payload(concat!(
|
||||
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"],"#,
|
||||
r#""chromium_executable":"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome","#,
|
||||
r#""chromium_executable_exists":false}"#,
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_cache_full_of_the_wrong_revision_counts_as_no_browser() {
|
||||
let d = skewed();
|
||||
// The viewer still serves — it is the browser that cannot start.
|
||||
assert!(d.is_usable());
|
||||
assert_eq!(d.blocker(), None);
|
||||
assert!(d.revision_skew());
|
||||
assert!(d.needs_browser(), "a browser that cannot launch is not a browser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_skew_message_names_both_revisions_and_the_way_out() {
|
||||
let msg = skewed().skew_message().unwrap();
|
||||
assert!(msg.contains("chromium-1237"), "{}", msg); // what is there
|
||||
assert!(msg.contains("chromium-1234"), "{}", msg); // what it wants
|
||||
assert!(msg.contains("Install Chromium"), "{}", msg); // what fixes it
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chrome_channel_covers_a_skewed_cache() {
|
||||
// The channel is an apt binary at a fixed path, so a revision mismatch
|
||||
// cannot affect it: there is still something to drive.
|
||||
let mut d = skewed();
|
||||
d.chrome_channel = Some("/usr/bin/google-chrome-stable".to_string());
|
||||
assert!(!d.needs_browser());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_probe_that_could_not_answer_is_not_reported_as_skew() {
|
||||
// Older container, or a Playwright too broken to `require`: unset is
|
||||
// "unknown", and unknown must never render as "your browsers are wrong".
|
||||
let d = parse_probe_output(&payload(concat!(
|
||||
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"]}"#,
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(!d.revision_skew());
|
||||
assert!(!d.needs_browser());
|
||||
assert_eq!(d.skew_message(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_viewer_package_is_reported_separately() {
|
||||
let d = parse_probe_output(&payload(
|
||||
|
||||
@@ -73,14 +73,33 @@ use crate::docker::exec::{
|
||||
|
||||
use super::detect::{self, PlaywrightDetection};
|
||||
|
||||
/// The two packages the pane genuinely needs, pinned to `@latest` because
|
||||
/// `browser.bind()` is recent and the viewer tracks it.
|
||||
/// The viewer package — installed **first**, and it decides the version of
|
||||
/// `playwright` installed after it.
|
||||
///
|
||||
/// This is the *minimum* set. A user who followed the old guidance ended up
|
||||
/// with a global install as well as these; only these are required. Note what
|
||||
/// is not here: `@playwright/mcp` is Claude's MCP configuration to make, not
|
||||
/// this pane's, and it contributes nothing to serving a viewer.
|
||||
pub const PACKAGES: [&str; 2] = ["playwright@latest", "@playwright/cli@latest"];
|
||||
/// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP
|
||||
/// configuration to make, and it contributes nothing to serving a viewer.
|
||||
///
|
||||
/// **Order matters here, and `playwright` is deliberately not `@latest`.**
|
||||
///
|
||||
/// Installing both at `@latest` produces a tree that looks right and is broken.
|
||||
/// Verified on a real container: `@playwright/cli@0.1.18` pins
|
||||
/// `playwright-core@1.63.0-alpha`, npm hoists that to the root, and
|
||||
/// `playwright@latest` (1.62.1) then nests its own `playwright-core@1.62.1`
|
||||
/// beside it. The two cores want *different browser revisions*. The browser
|
||||
/// step runs the resolved — hoisted — CLI, so it downloads 1237; every script
|
||||
/// Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies
|
||||
/// with "Executable doesn't exist … chromium_headless_shell-1234". The pane
|
||||
/// meanwhile reports a browser installed, because one is.
|
||||
///
|
||||
/// So the viewer package goes first and its own pinned `playwright` version is
|
||||
/// what gets installed second — one core, one browser revision, both halves
|
||||
/// agreeing. See [`pinned_playwright_spec`].
|
||||
pub const VIEWER_PACKAGE: &str = "@playwright/cli@latest";
|
||||
|
||||
/// Fallback when the viewer's manifest can't be read: better a possibly-skewed
|
||||
/// tree than no Playwright at all, and [`detect`](super::detect) reports the
|
||||
/// skew either way.
|
||||
pub const PLAYWRIGHT_FALLBACK: &str = "playwright@latest";
|
||||
|
||||
/// Where the packages are installed. Container storage, not a bind mount — see
|
||||
/// the module docs.
|
||||
@@ -213,49 +232,33 @@ pub async fn install_packages(
|
||||
emit_progress(
|
||||
app,
|
||||
project_id,
|
||||
&format!(
|
||||
"Installing playwright and @playwright/cli into {}/node_modules…",
|
||||
INSTALL_DIR
|
||||
),
|
||||
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||
);
|
||||
|
||||
// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
|
||||
// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
|
||||
// involved. The guard matters because these are `@latest`: current
|
||||
// Playwright has no postinstall (verified — `playwright@1.62.1` declares no
|
||||
// `scripts` at all), but if a future release brings the browser download
|
||||
// back, this step must stay small and the download must stay the step the
|
||||
// user explicitly asked for.
|
||||
let mut cmd = vec![
|
||||
"env".to_string(),
|
||||
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
||||
"npm".to_string(),
|
||||
"install".to_string(),
|
||||
// Leaves any package.json and lockfile at /workspace untouched.
|
||||
"--no-save".to_string(),
|
||||
"--no-fund".to_string(),
|
||||
"--no-audit".to_string(),
|
||||
];
|
||||
cmd.extend(PACKAGES.iter().map(|p| p.to_string()));
|
||||
|
||||
let step = run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
"claude",
|
||||
INSTALL_DIR,
|
||||
cmd,
|
||||
NPM_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?;
|
||||
if step.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install Playwright in this container (exit {}).\n\nnpm said:\n{}",
|
||||
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
||||
step.exit_code,
|
||||
step.log_or("it produced no output at all")
|
||||
));
|
||||
}
|
||||
|
||||
// Second, `playwright` at the version the viewer package pins — see
|
||||
// `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
|
||||
let spec = pinned_playwright_spec(container_id).await;
|
||||
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
||||
let second = npm_install(app, project_id, container_id, &spec).await?;
|
||||
if second.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
||||
spec,
|
||||
second.exit_code,
|
||||
second.log_or("it produced no output at all")
|
||||
));
|
||||
}
|
||||
step.log = merge_logs(step.log, second.log);
|
||||
|
||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||
let detection = detect::detect(container_id).await?;
|
||||
|
||||
@@ -265,7 +268,12 @@ pub async fn install_packages(
|
||||
// saying so here is what stops someone walking away from a pane that will
|
||||
// never show them anything.
|
||||
let mut warning = detection.blocker();
|
||||
if detection.needs_browser() {
|
||||
// Skew outranks "no browser": a container in that state *has* browsers, and
|
||||
// telling someone to install one they can see already installed is how a
|
||||
// real user ends up doing it three times.
|
||||
if let Some(skew) = detection.skew_message() {
|
||||
warning = merge(warning, skew);
|
||||
} else if detection.needs_browser() {
|
||||
warning = merge(
|
||||
warning,
|
||||
"Playwright is installed, but this container has no browser to drive yet. Install \
|
||||
@@ -282,6 +290,87 @@ pub async fn install_packages(
|
||||
})
|
||||
}
|
||||
|
||||
/// One `npm install` of one spec, into [`INSTALL_DIR`], as `claude`.
|
||||
///
|
||||
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
|
||||
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
|
||||
/// involved. The guard matters because these are `@latest`: current Playwright
|
||||
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
|
||||
/// all), but if a future release brings the browser download back, this step
|
||||
/// must stay small and the download must stay the step the user asked for.
|
||||
async fn npm_install(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
spec: &str,
|
||||
) -> Result<StepResult, String> {
|
||||
let cmd = vec![
|
||||
"env".to_string(),
|
||||
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
||||
"npm".to_string(),
|
||||
"install".to_string(),
|
||||
// Leaves any package.json and lockfile at /workspace untouched.
|
||||
"--no-save".to_string(),
|
||||
"--no-fund".to_string(),
|
||||
"--no-audit".to_string(),
|
||||
spec.to_string(),
|
||||
];
|
||||
run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
"claude",
|
||||
INSTALL_DIR,
|
||||
cmd,
|
||||
NPM_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The `playwright` spec to install: the exact version `@playwright/cli`
|
||||
/// depends on, so both halves share one `playwright-core`.
|
||||
///
|
||||
/// Read from the manifest npm just wrote rather than guessed, and falling back
|
||||
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
|
||||
/// install something, not nothing.
|
||||
async fn pinned_playwright_spec(container_id: &str) -> String {
|
||||
let script = format!(
|
||||
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
|
||||
process.stdout.write(d.playwright||'');}}catch(e){{}}",
|
||||
INSTALL_DIR
|
||||
);
|
||||
let (out, _code) = exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let version: &str = out.trim();
|
||||
// A version, not a range or a URL: anything else goes to the fallback
|
||||
// rather than into an npm command line.
|
||||
if !version.is_empty()
|
||||
&& version
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
|
||||
{
|
||||
format!("playwright@{}", version)
|
||||
} else {
|
||||
PLAYWRIGHT_FALLBACK.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep both npm runs' output, so a failure in either is diagnosable.
|
||||
fn merge_logs(first: String, second: String) -> String {
|
||||
match (first.trim().is_empty(), second.trim().is_empty()) {
|
||||
(true, _) => second,
|
||||
(_, true) => first,
|
||||
_ => format!("{}\n{}", first.trim_end(), second),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a browser: its system libraries first, then the browser, then prove
|
||||
/// one actually starts.
|
||||
///
|
||||
@@ -865,10 +954,20 @@ mod tests {
|
||||
fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
|
||||
// The viewer package is not optional, and `@playwright/mcp` is not a
|
||||
// member: it can bind sessions, it can never serve the UI.
|
||||
assert!(PACKAGES.iter().any(|p| p.starts_with("playwright@")));
|
||||
assert!(PACKAGES.iter().any(|p| p.starts_with("@playwright/cli@")));
|
||||
assert!(!PACKAGES.iter().any(|p| p.contains("@playwright/mcp")));
|
||||
assert_eq!(PACKAGES.len(), 2);
|
||||
assert!(VIEWER_PACKAGE.starts_with("@playwright/cli@"));
|
||||
assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@"));
|
||||
assert!(!VIEWER_PACKAGE.contains("@playwright/mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playwright_is_not_installed_at_latest_alongside_the_viewer() {
|
||||
// `@latest` for both is exactly what splits the tree into two
|
||||
// `playwright-core`s wanting different browser revisions — the viewer
|
||||
// green, every `require("playwright")` dead. The version comes from the
|
||||
// viewer's own manifest instead; `@latest` is only the fallback for an
|
||||
// unreadable one.
|
||||
assert!(!VIEWER_PACKAGE.contains("playwright@latest"));
|
||||
assert_eq!(PLAYWRIGHT_FALLBACK, "playwright@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
pub mod install;
|
||||
pub mod page;
|
||||
pub mod popout;
|
||||
pub mod proxy;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -467,13 +469,34 @@ async fn supervise(
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
|
||||
// Deregister, unless a newer session has already taken this project's slot.
|
||||
{
|
||||
let superseded = {
|
||||
let mut map = sessions.lock().await;
|
||||
if map.get(&project_id).is_some_and(|s| s.epoch == epoch) {
|
||||
map.remove(&project_id);
|
||||
match map.get(&project_id) {
|
||||
Some(session) if session.epoch == epoch => {
|
||||
map.remove(&project_id);
|
||||
false
|
||||
}
|
||||
// Someone else owns this project now: `stop` removes the session
|
||||
// from the map *before* awaiting this task, and teardown below is
|
||||
// seconds of Docker work, so a restart in that window is ordinary.
|
||||
Some(_) => true,
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
|
||||
// Everything past here speaks for the project as a whole, so a superseded
|
||||
// supervisor must say nothing: closing the pop-out would destroy the *new*
|
||||
// session's window, and the off-status would report a running view as
|
||||
// stopped.
|
||||
if superseded {
|
||||
return;
|
||||
}
|
||||
|
||||
// A pop-out outlives the tab, so nothing else would take it down: the
|
||||
// window would sit there showing a frozen last frame of a viewer that no
|
||||
// longer exists. The session owns it, and this is where the session ends.
|
||||
let _ = popout::close(&app, &project_id);
|
||||
|
||||
let enabled = manager().is_enabled(&project_id).await;
|
||||
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Open a page in the container's browser, and resize it while it runs.
|
||||
//!
|
||||
//! The pane [watches](super) browsers something else published. This opens one:
|
||||
//! the user hands it a URL, it launches a browser inside the container,
|
||||
//! publishes it with `browser.bind()` so the pane picks it up, and holds the
|
||||
//! handle so the page can be navigated and **resized** afterwards.
|
||||
//!
|
||||
//! ## Why the handle has to be held
|
||||
//!
|
||||
//! Verified against a real bound browser: a second client cannot join one.
|
||||
//! `chromium.connect()` against the published endpoint times out in every URL
|
||||
//! form — the descriptor's socket speaks the dashboard's own transport, not the
|
||||
//! public connect protocol. So whoever launches the browser is the only process
|
||||
//! that can ever drive it. That is the whole reason this helper is a resident
|
||||
//! process rather than a one-shot `node -e` that exits.
|
||||
//!
|
||||
//! It also draws the line for the feature: pages *this* opens can be resized
|
||||
//! live; a browser `@playwright/mcp` launched can only be watched, and its size
|
||||
//! is whatever `--viewport-size` it was given.
|
||||
//!
|
||||
//! ## Control channel
|
||||
//!
|
||||
//! A JSON file in `/tmp`, polled by the helper. No port, no second listener, no
|
||||
//! addition to the proxy's attack surface — and it composes with the one exec
|
||||
//! path this codebase already has. Writes go through `node -e` rather than
|
||||
//! shell redirection so a URL never touches a shell.
|
||||
//!
|
||||
//! ## Viewport, and why it is the interesting part
|
||||
//!
|
||||
//! `page.setViewportSize()` genuinely reflows: measured on a page carrying a
|
||||
//! `@media (max-width: 900px)` rule, the rule fires at 800×600 and clears at
|
||||
//! 1440×900. Resizing the *window* the pane lives in does nothing of the sort —
|
||||
//! the viewer is a CDP screencast, so a bigger window is the same pixels drawn
|
||||
//! larger. This is what makes the pop-out usable as a responsive-design ruler.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::exec::exec_oneshot_as;
|
||||
|
||||
use super::detect::PlaywrightDetection;
|
||||
|
||||
/// Control file the helper polls, and the state file it writes back.
|
||||
const CONTROL_PATH: &str = "/tmp/triple-c-page-control.json";
|
||||
const STATE_PATH: &str = "/tmp/triple-c-page-state.json";
|
||||
/// Where the detached helper's own output goes, so a failed start has a trail.
|
||||
const HELPER_LOG: &str = "/tmp/triple-c-page.log";
|
||||
|
||||
/// How long to wait for the helper to report that the page is up.
|
||||
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
|
||||
/// Navigating a browser that is already up. One page load, not a cold start.
|
||||
const REUSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35);
|
||||
const READY_POLL: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
|
||||
/// A viewport, in CSS pixels.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Viewport {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
/// Clamped to something a browser will accept. A window dragged to nothing
|
||||
/// must not ask Chromium for a zero-width page.
|
||||
pub fn sane(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width: width.clamp(200, 7680),
|
||||
height: height.clamp(200, 4320),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the helper reports about itself.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct PageState {
|
||||
#[serde(default)]
|
||||
pub ready: bool,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub viewport: Option<Viewport>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Open `url` in a freshly launched, bound browser.
|
||||
///
|
||||
/// Replaces any page this opened before: one helper per container, because the
|
||||
/// pane shows one browser and a second would just compete for the pane.
|
||||
pub async fn open(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
detection: &PlaywrightDetection,
|
||||
url: &str,
|
||||
viewport: Viewport,
|
||||
) -> Result<PageState, String> {
|
||||
let core = detection.playwright_path.as_deref().ok_or_else(|| {
|
||||
"Playwright isn't installed in this container — set it up from the Browser tab first."
|
||||
.to_string()
|
||||
})?;
|
||||
// The directory of the resolved manifest is what `require()` wants.
|
||||
let core_dir = core.trim_end_matches("/package.json");
|
||||
|
||||
// The executable is passed explicitly rather than left to Playwright's
|
||||
// revision lookup: a container can hold browsers a given copy will not
|
||||
// launch (see `detect::revision_skew`), and this is the one place we know
|
||||
// which binary is actually on disk.
|
||||
let executable = detection
|
||||
.chromium_executable
|
||||
.as_deref()
|
||||
.filter(|_| detection.chromium_executable_exists);
|
||||
|
||||
// Reuse a helper that is already up. Relaunching would throw away the
|
||||
// browser's cookies and storage — which for the auth case means signing in
|
||||
// again to reach the second page, having just signed in on the first.
|
||||
if state(container_id).await.ready {
|
||||
emit_progress(app, project_id, "Navigating the container's browser…");
|
||||
set_viewport(container_id, viewport).await?;
|
||||
navigate(container_id, url).await?;
|
||||
if let Some(state) = wait_for_url(container_id, url).await {
|
||||
return Ok(state);
|
||||
}
|
||||
// It stopped answering; fall through and start a fresh one.
|
||||
}
|
||||
|
||||
close(container_id).await;
|
||||
// Cold start: a browser launch plus a page load, which is the several
|
||||
// seconds the user would otherwise spend wondering whether the click
|
||||
// registered.
|
||||
emit_progress(app, project_id, "Launching a browser in the container…");
|
||||
|
||||
let config = serde_json::json!({
|
||||
"core": core_dir,
|
||||
"executable": executable,
|
||||
"url": url,
|
||||
"viewport": viewport,
|
||||
"control": CONTROL_PATH,
|
||||
"state": STATE_PATH,
|
||||
});
|
||||
let script = format!("const CFG={};{}", config, HELPER);
|
||||
|
||||
// Detached, for the same reason the viewer is: the process has to outlive
|
||||
// the exec that started it, or the page closes the moment we return.
|
||||
let launcher = format!(
|
||||
"cd /workspace 2>/dev/null || true; rm -f {} {}; nohup node -e {} >{} 2>&1 &",
|
||||
STATE_PATH,
|
||||
CONTROL_PATH,
|
||||
shell_quote(&script),
|
||||
HELPER_LOG
|
||||
);
|
||||
exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||
|
||||
emit_progress(app, project_id, "Waiting for the page to load…");
|
||||
wait_until_ready(container_id).await
|
||||
}
|
||||
|
||||
/// Resize the open page. Cheap enough to call from a window-resize handler.
|
||||
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
|
||||
write_control(
|
||||
container_id,
|
||||
serde_json::json!({ "viewport": viewport }).to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Navigate the open page without relaunching the browser.
|
||||
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
|
||||
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
|
||||
}
|
||||
|
||||
/// Ask the helper to shut down. Best effort: a container that has none is the
|
||||
/// normal case, and the caller is usually about to start one anyway.
|
||||
pub async fn close(container_id: &str) {
|
||||
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
|
||||
}
|
||||
|
||||
/// Current state, or a default when no helper has ever run here.
|
||||
pub async fn state(container_id: &str) -> PageState {
|
||||
let script = format!(
|
||||
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
|
||||
STATE_PATH
|
||||
);
|
||||
let Ok((out, _)) = exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return PageState::default();
|
||||
};
|
||||
serde_json::from_str(out.trim()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Write the control file through Node rather than a shell redirect, so a URL
|
||||
/// is never interpreted by `sh`.
|
||||
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||
let script = format!(
|
||||
"require('fs').writeFileSync('{}',process.argv[1]);",
|
||||
CONTROL_PATH
|
||||
);
|
||||
exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||
}
|
||||
|
||||
/// Wait for a *running* helper to report the URL we just asked it for.
|
||||
///
|
||||
/// Bounded much tighter than a cold start: the browser is already up, so this
|
||||
/// is one navigation. `None` means it stopped answering, and the caller starts
|
||||
/// a fresh helper rather than reporting a page that isn't there.
|
||||
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
|
||||
loop {
|
||||
let state = state(container_id).await;
|
||||
if state.ready && state.url.as_deref() == Some(url) {
|
||||
return Some(state);
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
tokio::time::sleep(READY_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the state file until the helper says the page is up, or says why not.
|
||||
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
|
||||
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let state = state(container_id).await;
|
||||
if let Some(error) = state.error.clone() {
|
||||
return Err(error);
|
||||
}
|
||||
if state.ready {
|
||||
return Ok(state);
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"The browser didn't come up within {}s. Its log is at {} inside the container.",
|
||||
READY_TIMEOUT.as_secs(),
|
||||
HELPER_LOG
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(READY_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-quote for `sh`, the same way [`super`] does for the viewer's paths.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// The resident helper, appended to a `const CFG={…};` prelude.
|
||||
///
|
||||
/// Deliberately one string passed as a single `argv` element — no shell parsing
|
||||
/// of any part of it, exactly like `detect`'s probe. It launches, binds, and
|
||||
/// then polls the control file; every failure path writes the state file, so a
|
||||
/// helper that dies during startup is reported rather than waited out.
|
||||
const HELPER: &str = concat!(
|
||||
r#"const fs=require('fs');"#,
|
||||
r#"const {chromium}=require(CFG.core);"#,
|
||||
r#"const write=(o)=>{try{fs.writeFileSync(CFG.state,JSON.stringify(o));}catch(e){}};"#,
|
||||
r#"const fail=(e)=>{write({ready:false,error:String(e&&e.message||e)});process.exit(1);};"#,
|
||||
r#"process.on('unhandledRejection',fail);"#,
|
||||
r#"(async()=>{"#,
|
||||
// `chromiumSandbox:false` because the container has no user namespaces to
|
||||
// give Chromium; headless because there is no display, which is also the
|
||||
// only mode the dashboard can screencast anyway.
|
||||
r#"const opts={headless:true,chromiumSandbox:false};"#,
|
||||
r#"if(CFG.executable)opts.executablePath=CFG.executable;"#,
|
||||
r#"const browser=await chromium.launch(opts);"#,
|
||||
r#"const ctx=await browser.newContext({viewport:CFG.viewport});"#,
|
||||
r#"const page=await ctx.newPage();"#,
|
||||
// Bind before navigating: the pane should show the page loading rather than
|
||||
// appearing once it is done.
|
||||
r#"await browser.bind('claude',{metadata:{source:'triple-c'}});"#,
|
||||
r#"let current=CFG.url,viewport=CFG.viewport;"#,
|
||||
r#"const report=()=>write({ready:true,url:current,viewport});"#,
|
||||
r#"try{await page.goto(CFG.url,{waitUntil:'domcontentloaded',timeout:30000});}catch(e){}"#,
|
||||
r#"report();"#,
|
||||
// The control loop. A poll, not a watcher: `fs.watch` misses writes on some
|
||||
// filesystems and this costs nothing at 4 Hz.
|
||||
r#"setInterval(async()=>{let c;try{c=JSON.parse(fs.readFileSync(CFG.control,'utf8'));}catch(e){return;}"#,
|
||||
r#"try{fs.unlinkSync(CFG.control);}catch(e){}"#,
|
||||
r#"if(c.close){await browser.close().catch(()=>{});write({ready:false});process.exit(0);}"#,
|
||||
r#"if(c.viewport){viewport=c.viewport;await page.setViewportSize(c.viewport).catch(()=>{});}"#,
|
||||
r#"if(c.url&&c.url!==current){current=c.url;await page.goto(c.url,{waitUntil:'domcontentloaded',timeout:30000}).catch(()=>{});}"#,
|
||||
r#"report();},250);"#,
|
||||
// A browser that dies (crash, or the user closing the last page) must not
|
||||
// leave a helper claiming a live page.
|
||||
r#"browser.on('disconnected',()=>{write({ready:false});process.exit(0);});"#,
|
||||
r#"})().catch(fail);"#,
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_helper_is_one_argv_element_with_no_shell_hazards() {
|
||||
// Same rule as the detect probe: it is passed as a single argument, so
|
||||
// it must contain neither a newline nor a single quote that would end
|
||||
// the quoting `open` wraps it in.
|
||||
assert!(!HELPER.contains('\n'), "{}", HELPER);
|
||||
assert!(HELPER.contains("chromium.launch"), "{}", HELPER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_helper_binds_so_the_pane_can_see_the_page() {
|
||||
// Without this the page opens and the pane shows nothing — the whole
|
||||
// feature hinges on the browser being published.
|
||||
assert!(HELPER.contains("browser.bind('claude'"), "{}", HELPER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_helper_reports_startup_failures_instead_of_hanging() {
|
||||
// `wait_until_ready` polls the state file; a helper that dies silently
|
||||
// would turn every failure into a 45-second timeout.
|
||||
assert!(HELPER.contains("unhandledRejection"), "{}", HELPER);
|
||||
assert!(HELPER.contains("error:String"), "{}", HELPER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewport_is_clamped_to_something_a_browser_accepts() {
|
||||
assert_eq!(Viewport::sane(0, 0), Viewport { width: 200, height: 200 });
|
||||
assert_eq!(
|
||||
Viewport::sane(99_999, 99_999),
|
||||
Viewport { width: 7680, height: 4320 }
|
||||
);
|
||||
assert_eq!(
|
||||
Viewport::sane(1440, 900),
|
||||
Viewport { width: 1440, height: 900 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_is_never_parsed_by_a_shell() {
|
||||
// The launcher runs through `sh -c`, so the script is quoted with the
|
||||
// POSIX close-escape-reopen form: the embedded quote becomes `'\''`,
|
||||
// which leaves the `;rm` inside the string rather than starting a new
|
||||
// command. (A naive "the output must not contain ';rm'" check fails
|
||||
// here and would be wrong — that substring is *inside* the quoting.)
|
||||
assert_eq!(
|
||||
shell_quote("http://x/?a=1&b=2';rm -rf /"),
|
||||
r"'http://x/?a=1&b=2'\'';rm -rf /'"
|
||||
);
|
||||
// The control channel doesn't go near a shell at all: the JSON travels
|
||||
// as an argv element to `node`.
|
||||
assert!(!HELPER.contains("exec("), "{}", HELPER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_defaults_to_not_ready_rather_than_failing() {
|
||||
// An empty/absent state file is the normal case before anything runs.
|
||||
let s: PageState = serde_json::from_str("{}").unwrap();
|
||||
assert!(!s.ready);
|
||||
assert!(s.error.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! The browser view in a window of its own.
|
||||
//!
|
||||
//! Watching a browser and working in a terminal are the same task done at the
|
||||
//! same time, and a tab can only be one of them. So the pane can be detached
|
||||
//! into a second OS window — put on the other monitor, or pinned on top of
|
||||
//! whatever else is in front.
|
||||
//!
|
||||
//! ## Why this is a native window and not a second iframe
|
||||
//!
|
||||
//! The window loads the *same* token-bearing loopback URL the pane's iframe
|
||||
//! uses ([`crate::browser_view::BrowserViewStatus::url`]), as its top-level
|
||||
//! document. That has two consequences worth stating:
|
||||
//!
|
||||
//! - It is a **remote-origin** webview. No capability lists this window, so it
|
||||
//! has no IPC surface at all — `invoke` is not reachable from it, which is
|
||||
//! exactly right for a page served out of a container. Do not add one.
|
||||
//! - The app CSP does not apply, and does not need to: `frame-src` exists to
|
||||
//! constrain what the *app's* document may embed, and this is not embedded.
|
||||
//! The port is still confined to [`crate::browser_view::proxy`]'s range and
|
||||
//! still gated by the session token, which is what actually protects it.
|
||||
//!
|
||||
//! ## Lifetime
|
||||
//!
|
||||
//! The window is owned by the session, not by the user's patience: when a view
|
||||
//! stops — the user pressed Stop, the container went away, the viewer died —
|
||||
//! the supervisor's teardown calls [`close`], because a window left showing a
|
||||
//! dead viewer is worse than no window. The reverse is not true; closing the
|
||||
//! window leaves the view running, and the pane takes it back into the tab.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||
|
||||
/// Emitted when a pop-out opens or closes. Payload: [`PopoutState`] plus the
|
||||
/// project id.
|
||||
///
|
||||
/// The window can close without the app asking it to — the user hits its X, or
|
||||
/// a teardown takes it — so the pane learns about it the same way it learns
|
||||
/// about everything else here, by listening.
|
||||
const POPOUT_EVENT: &str = "browser-view-popout-changed";
|
||||
|
||||
/// What the pane needs to render its pop-out controls.
|
||||
///
|
||||
/// Both fields are read from the window itself rather than remembered on either
|
||||
/// side: the pane is unmounted whenever another Project Home sub-tab is
|
||||
/// selected, so anything it merely *remembers* about the window is gone by the
|
||||
/// time the user comes back, while the window is still there.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct PopoutState {
|
||||
pub open: bool,
|
||||
pub always_on_top: bool,
|
||||
}
|
||||
|
||||
impl PopoutState {
|
||||
const CLOSED: Self = Self {
|
||||
open: false,
|
||||
always_on_top: false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Tauri window labels admit `[a-zA-Z0-9-/:_]` only. Project ids are UUIDs, so
|
||||
/// this never fires in practice; it exists so a hand-edited `projects.json`
|
||||
/// cannot produce a label Tauri rejects at build time.
|
||||
pub fn window_label(project_id: &str) -> String {
|
||||
let id: String = project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
format!("browser-view-{}", id)
|
||||
}
|
||||
|
||||
/// Open the pop-out, or raise it if it is already open.
|
||||
///
|
||||
/// `url` is the live session's URL; the caller has already established that the
|
||||
/// view is running, because there is nothing to show otherwise.
|
||||
pub fn open(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
project_name: &str,
|
||||
url: &str,
|
||||
always_on_top: bool,
|
||||
) -> Result<(), String> {
|
||||
let label = window_label(project_id);
|
||||
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
// Asking twice means "I can't see it", not "open another".
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.set_always_on_top(always_on_top);
|
||||
emit(app, project_id, state(app, project_id));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let parsed = url
|
||||
.parse()
|
||||
.map_err(|e| format!("The browser view's address is not a URL: {}", e))?;
|
||||
|
||||
let project_id_owned = project_id.to_string();
|
||||
let app_for_event = app.clone();
|
||||
|
||||
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed))
|
||||
.title(format!("{} — browser", project_name))
|
||||
.inner_size(1100.0, 820.0)
|
||||
.min_inner_size(480.0, 360.0)
|
||||
.always_on_top(always_on_top)
|
||||
.build()
|
||||
.map_err(|e| format!("Could not open the browser window: {}", e))?;
|
||||
|
||||
// Closed from its own titlebar, this is the only thing that tells the pane
|
||||
// to take the view back into the tab. `Resized` drives match-window mode —
|
||||
// see `set_match_window`.
|
||||
window.on_window_event(move |event| match event {
|
||||
WindowEvent::Destroyed => {
|
||||
set_match_window(&project_id_owned, false);
|
||||
emit(&app_for_event, &project_id_owned, PopoutState::CLOSED);
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
on_resized(&app_for_event, &project_id_owned, size.width, size.height);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
log::info!("Browser view: popped out for project {}", project_id);
|
||||
emit(app, project_id, state(app, project_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the pop-out if there is one. Safe to call when there isn't.
|
||||
///
|
||||
/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's
|
||||
/// window-event handler treats that as a request to quit for the main window.
|
||||
/// Nothing here should ever be able to be mistaken for that.
|
||||
///
|
||||
/// A failure is **returned, not logged and forgotten**. The pane puts its
|
||||
/// iframe back the moment it believes the window is gone, so reporting a close
|
||||
/// that did not happen is how you end up with two viewers driving one browser —
|
||||
/// the exact state the iframe is dropped to prevent.
|
||||
pub fn close(app: &AppHandle, project_id: &str) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window(&window_label(project_id)) {
|
||||
window.destroy().map_err(|e| {
|
||||
log::warn!(
|
||||
"Browser view: could not close the pop-out for project {}: {}",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
format!("Could not close the browser window: {}", e)
|
||||
})?;
|
||||
}
|
||||
// `Destroyed` covers the normal path; a window that was already gone still
|
||||
// owes the pane an answer.
|
||||
emit(app, project_id, PopoutState::CLOSED);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the window exists and how it is stacked, read from the window.
|
||||
pub fn state(app: &AppHandle, project_id: &str) -> PopoutState {
|
||||
match app.get_webview_window(&window_label(project_id)) {
|
||||
Some(window) => PopoutState {
|
||||
open: true,
|
||||
// A window that cannot answer is not a reason to fail the call; the
|
||||
// pin is a preference, and "not pinned" is the safe reading.
|
||||
always_on_top: window.is_always_on_top().unwrap_or(false),
|
||||
},
|
||||
None => PopoutState::CLOSED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin the pop-out above other windows, or unpin it. No-op when it is closed.
|
||||
pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Result<(), String> {
|
||||
let Some(window) = app.get_webview_window(&window_label(project_id)) else {
|
||||
return Ok(());
|
||||
};
|
||||
window
|
||||
.set_always_on_top(on_top)
|
||||
.map_err(|e| format!("Could not change the window's stacking: {}", e))?;
|
||||
emit(app, project_id, state(app, project_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Match-window mode
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Projects whose pop-out is driving the page's viewport, and the generation of
|
||||
/// the latest resize for each — the debounce is "did anything else arrive while
|
||||
/// I slept?", which needs no timer to cancel.
|
||||
static MATCH_WINDOW: OnceLock<Mutex<HashMap<String, (bool, u64)>>> = OnceLock::new();
|
||||
|
||||
/// How long the window has to stop moving before the page is resized.
|
||||
///
|
||||
/// A drag emits `Resized` continuously; each one costs a container exec, and
|
||||
/// Chromium relayouts the page. Settling first turns a drag into one resize.
|
||||
const RESIZE_SETTLE: Duration = Duration::from_millis(300);
|
||||
|
||||
fn match_window_map() -> &'static Mutex<HashMap<String, (bool, u64)>> {
|
||||
MATCH_WINDOW.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Turn match-window mode on or off for a project.
|
||||
///
|
||||
/// Only ever affects a page **Triple-C opened** — a bound browser cannot be
|
||||
/// joined by a second client, so a page `@playwright/mcp` launched keeps
|
||||
/// whatever viewport it was given. See [`super::page`].
|
||||
pub fn set_match_window(project_id: &str, enabled: bool) {
|
||||
let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = map.entry(project_id.to_string()).or_insert((false, 0));
|
||||
entry.0 = enabled;
|
||||
}
|
||||
|
||||
pub fn match_window(project_id: &str) -> bool {
|
||||
match_window_map()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(project_id)
|
||||
.map(|(on, _)| *on)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The pop-out's current inner size, for applying match-window immediately
|
||||
/// rather than only on the next drag.
|
||||
pub fn inner_size(app: &AppHandle, project_id: &str) -> Option<(u32, u32)> {
|
||||
let window = app.get_webview_window(&window_label(project_id))?;
|
||||
let size = window.inner_size().ok()?;
|
||||
Some((size.width, size.height))
|
||||
}
|
||||
|
||||
/// Debounce a resize, then push the settled size into the page's viewport.
|
||||
fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) {
|
||||
let generation = {
|
||||
let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(entry) = map.get_mut(project_id) else {
|
||||
return;
|
||||
};
|
||||
if !entry.0 {
|
||||
return;
|
||||
}
|
||||
entry.1 += 1;
|
||||
entry.1
|
||||
};
|
||||
|
||||
let app = app.clone();
|
||||
let project_id = project_id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(RESIZE_SETTLE).await;
|
||||
// Superseded by a later resize: that one will do the work.
|
||||
{
|
||||
let map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||
match map.get(&project_id) {
|
||||
Some((true, latest)) if *latest == generation => {}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
|
||||
let state = app.state::<crate::AppState>();
|
||||
let Some(container_id) = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.and_then(|p| p.container_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = super::page::set_viewport(
|
||||
&container_id,
|
||||
super::page::Viewport::sane(width, height),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::debug!("Browser view: could not match the page to the window: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
|
||||
let _ = app.emit(
|
||||
POPOUT_EVENT,
|
||||
serde_json::json!({
|
||||
"project_id": project_id,
|
||||
"open": state.open,
|
||||
"always_on_top": state.always_on_top,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn labels_are_derived_from_the_project_and_are_tauri_safe() {
|
||||
assert_eq!(
|
||||
window_label("6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"),
|
||||
"browser-view-6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"
|
||||
);
|
||||
assert_eq!(window_label("a b/c.d"), "browser-view-a_b_c_d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_projects_get_distinct_windows() {
|
||||
assert_ne!(window_label("alpha"), window_label("beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_window_is_off_until_asked_for_and_is_per_project() {
|
||||
assert!(!match_window("mw-a"));
|
||||
set_match_window("mw-a", true);
|
||||
assert!(match_window("mw-a"));
|
||||
// Another project's window must not start driving its page too.
|
||||
assert!(!match_window("mw-b"));
|
||||
set_match_window("mw-a", false);
|
||||
assert!(!match_window("mw-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resize_supersedes_the_one_before_it() {
|
||||
// The debounce is a generation counter, not a cancellable timer: only
|
||||
// the newest resize of a drag survives to touch the container.
|
||||
set_match_window("mw-gen", true);
|
||||
let read = || {
|
||||
match_window_map()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("mw-gen")
|
||||
.map(|(_, g)| *g)
|
||||
.unwrap()
|
||||
};
|
||||
let before = read();
|
||||
{
|
||||
let mut map = match_window_map().lock().unwrap();
|
||||
let entry = map.get_mut("mw-gen").unwrap();
|
||||
entry.1 += 1;
|
||||
}
|
||||
assert!(read() > before);
|
||||
set_match_window("mw-gen", false);
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,14 @@ pub fn run() {
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// This handler fires for *every* window, and what follows stops
|
||||
// containers and exits the process. Only the main window means
|
||||
// that. Secondary windows — the browser view's pop-out — are
|
||||
// closed and reopened freely and must just close.
|
||||
if window.label() != "main" {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = window.state::<AppState>();
|
||||
let lifecycle = state.lifecycle.clone();
|
||||
|
||||
@@ -428,6 +436,16 @@ pub fn run() {
|
||||
browser_view::commands::check_browser_view_support,
|
||||
browser_view::commands::install_browser_view_support,
|
||||
browser_view::commands::install_browser_view_browser,
|
||||
browser_view::commands::open_browser_view_popout,
|
||||
browser_view::commands::close_browser_view_popout,
|
||||
browser_view::commands::get_browser_view_popout_state,
|
||||
browser_view::commands::set_browser_view_popout_always_on_top,
|
||||
browser_view::commands::open_page_in_container_browser,
|
||||
browser_view::commands::set_container_page_viewport,
|
||||
browser_view::commands::get_container_page_state,
|
||||
browser_view::commands::close_container_page,
|
||||
browser_view::commands::set_browser_view_match_window,
|
||||
browser_view::commands::get_browser_view_match_window,
|
||||
// Shared Claude Code auth token
|
||||
commands::auth_token_commands::acquire_claude_token,
|
||||
commands::auth_token_commands::submit_claude_token_code,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "Triple-C",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "com.triple-c.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import MainTabs from "./MainTabs";
|
||||
import { useAppState, homeTabKey, terminalTabKey } from "../../store/appState";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
const close = vi.fn();
|
||||
|
||||
const sessions: TerminalSession[] = [
|
||||
{
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
projectName: "api-server",
|
||||
sessionName: "claude",
|
||||
sessionType: "claude",
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
projectId: "p1",
|
||||
projectName: "api-server",
|
||||
sessionName: "shell",
|
||||
sessionType: "bash",
|
||||
},
|
||||
] as unknown as TerminalSession[];
|
||||
|
||||
const projects: Project[] = [
|
||||
{
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
status: "running",
|
||||
permission_mode: "bypass",
|
||||
renamed_session_names: {},
|
||||
},
|
||||
] as unknown as Project[];
|
||||
|
||||
vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: () => ({ sessions, close }),
|
||||
}));
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
useProjects: () => ({ projects, update: vi.fn() }),
|
||||
}));
|
||||
|
||||
const HOME = homeTabKey("p1");
|
||||
const S1 = terminalTabKey("s1");
|
||||
const S2 = terminalTabKey("s2");
|
||||
|
||||
/**
|
||||
* A pointer event carrying a real `clientX`.
|
||||
*
|
||||
* jsdom implements no `PointerEvent`, so Testing Library's synthesized one has
|
||||
* no coordinates — and the coordinate is the whole point here, since it decides
|
||||
* which slot the drop lands in. `MouseEvent` has one, and React dispatches on
|
||||
* the event's type name either way.
|
||||
*/
|
||||
function pointer(el: Element, type: string, clientX: number) {
|
||||
fireEvent(el, new MouseEvent(type, { bubbles: true, cancelable: true, clientX, button: 0 }));
|
||||
}
|
||||
|
||||
/** Press, move past the drag threshold, and release over `endX`. */
|
||||
function dragTab(el: Element, fromX: number, endX: number) {
|
||||
pointer(el, "pointerdown", fromX);
|
||||
pointer(el, "pointermove", endX);
|
||||
pointer(el, "pointerup", endX);
|
||||
}
|
||||
|
||||
/** Pin a tab's geometry so "past the midpoint" means something in jsdom. */
|
||||
function place(el: Element, left: number, width = 100) {
|
||||
el.getBoundingClientRect = () =>
|
||||
({ left, width, right: left + width, top: 0, bottom: 30, height: 30, x: left, y: 0 }) as DOMRect;
|
||||
}
|
||||
|
||||
/** Lay the strip out as three 100px tabs starting at x=0. */
|
||||
function laidOut() {
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
||||
return tabs;
|
||||
}
|
||||
|
||||
const order = () => useAppState.getState().tabOrder;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({
|
||||
tabOrder: [HOME, S1, S2],
|
||||
activeTabKey: HOME,
|
||||
activeSessionId: null,
|
||||
projects,
|
||||
});
|
||||
});
|
||||
|
||||
describe("MainTabs reordering", () => {
|
||||
it("drags a tab to the front", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
// Left half of the first tab — the tab lands before it.
|
||||
dragTab(tabs[2], 250, 10);
|
||||
|
||||
expect(order()).toEqual([S2, HOME, S1]);
|
||||
});
|
||||
|
||||
it("drops after the tab when the pointer is past its midpoint", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
dragTab(tabs[0], 50, 190);
|
||||
|
||||
expect(order()).toEqual([S1, HOME, S2]);
|
||||
});
|
||||
|
||||
it("drops at the end when released past the last tab", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
dragTab(tabs[0], 50, 800);
|
||||
|
||||
expect(order()).toEqual([S1, S2, HOME]);
|
||||
});
|
||||
|
||||
it("dragging does not steal the selection", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
dragTab(tabs[1], 150, 290);
|
||||
|
||||
expect(order()).toEqual([HOME, S2, S1]);
|
||||
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||
});
|
||||
|
||||
it("does not let a drag select the tab's text", () => {
|
||||
// A pointer-driven drag is still a mouse drag as far as the browser is
|
||||
// concerned, so without this the label highlights blue while you move it.
|
||||
// The rename field is exempt — selecting there is the whole point.
|
||||
render(<MainTabs />);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
expect(tab.className).toContain("select-none");
|
||||
}
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByRole("tab")[1]);
|
||||
expect(screen.getByLabelText("Rename tab").className).toContain("select-text");
|
||||
});
|
||||
|
||||
it("shows the tab itself under the cursor while dragging", () => {
|
||||
// A dimmed source tab and a thin line do not read as "I am holding this
|
||||
// tab" — the dragged copy is what makes the gesture legible.
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 120);
|
||||
|
||||
const ghost = screen.getByTestId("tab-drag-ghost");
|
||||
expect(ghost).toHaveTextContent("shell (bash)");
|
||||
expect(ghost).toHaveTextContent("▣");
|
||||
|
||||
pointer(tabs[2], "pointerup", 120);
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
});
|
||||
|
||||
it("carries the project name when a home tab is dragged", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
pointer(tabs[0], "pointerdown", 50);
|
||||
pointer(tabs[0], "pointermove", 250);
|
||||
|
||||
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("api-server");
|
||||
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("⌂");
|
||||
});
|
||||
|
||||
it("drops the dragged copy when the drag is abandoned", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 10);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the drop marker only while a drag is under way", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 10);
|
||||
expect(screen.getByTestId("tab-drop-marker")).toBeInTheDocument();
|
||||
|
||||
pointer(tabs[2], "pointerup", 10);
|
||||
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||
});
|
||||
|
||||
it("abandons the drag on Escape, leaving the order alone", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 10);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||
pointer(tabs[2], "pointerup", 10);
|
||||
expect(order()).toEqual([HOME, S1, S2]);
|
||||
});
|
||||
|
||||
it("treats a press that barely moves as a click, not a drag", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
// Two pixels of tremble, under the threshold.
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 252);
|
||||
pointer(tabs[2], "pointerup", 252);
|
||||
fireEvent.click(tabs[2]);
|
||||
|
||||
expect(order()).toEqual([HOME, S1, S2]);
|
||||
expect(useAppState.getState().activeTabKey).toBe(S2);
|
||||
});
|
||||
|
||||
it("does not select the tab it just dropped", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
dragTab(tabs[2], 250, 10);
|
||||
// The browser fires a click after the pointerup that ended the drag.
|
||||
fireEvent.click(tabs[2]);
|
||||
|
||||
expect(order()).toEqual([S2, HOME, S1]);
|
||||
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||
});
|
||||
|
||||
it("ignores a press that starts on the close button", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
const close = screen.getByRole("button", { name: "Close shell (bash)" });
|
||||
|
||||
fireEvent(close, new MouseEvent("pointerdown", { bubbles: true, clientX: 290, button: 0 }));
|
||||
pointer(tabs[2], "pointermove", 10);
|
||||
|
||||
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||
expect(order()).toEqual([HOME, S1, S2]);
|
||||
});
|
||||
|
||||
it("does not drag a tab that is being renamed — that drag selects text", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
fireEvent.doubleClick(tabs[1]);
|
||||
expect(screen.getByLabelText("Rename tab")).toBeInTheDocument();
|
||||
|
||||
dragTab(screen.getAllByRole("tab")[1], 150, 10);
|
||||
|
||||
expect(order()).toEqual([HOME, S1, S2]);
|
||||
});
|
||||
|
||||
it("carries no drag payload that another element could receive", () => {
|
||||
// An HTML5 drag would put the tab key in a DataTransfer, and releasing over
|
||||
// any text field in the app would type `term:…` into it. Pointer events
|
||||
// have nothing to hand over, and the tabs are not draggable at all.
|
||||
render(<MainTabs />);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
expect(tab).not.toHaveAttribute("draggable", "true");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
@@ -18,6 +18,9 @@ interface ContextMenuState {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Pixels of horizontal travel before a press becomes a drag rather than a click. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
||||
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||
@@ -28,22 +31,46 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
|
||||
/**
|
||||
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
||||
* terminals (▣).
|
||||
*
|
||||
* Tabs are draggable, on pointer events rather than HTML5 drag-and-drop — see
|
||||
* `pointerProps` for why neither of the two obvious alternatives works.
|
||||
* `Ctrl+Shift+←/→` does the same thing without a mouse.
|
||||
*/
|
||||
export default function MainTabs() {
|
||||
const { sessions, close } = useTerminal();
|
||||
const { projects, update } = useProjects();
|
||||
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState(
|
||||
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
|
||||
useShallow((s) => ({
|
||||
tabOrder: s.tabOrder,
|
||||
activeTabKey: s.activeTabKey,
|
||||
setActiveTabKey: s.setActiveTabKey,
|
||||
closeHomeTab: s.closeHomeTab,
|
||||
moveTab: s.moveTab,
|
||||
})),
|
||||
);
|
||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
/** The tab being dragged, and the slot it would drop into. */
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
/** Where the dragged tab is drawn, and how it looked when the drag started. */
|
||||
const [ghost, setGhost] = useState<{ x: number; y: number; label: string; icon: string } | null>(
|
||||
null,
|
||||
);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
/** A press that has not yet moved far enough to be a drag. */
|
||||
const pending = useRef<{
|
||||
key: string;
|
||||
startX: number;
|
||||
dragging: boolean;
|
||||
offsetX: number;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
const suppressClick = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
@@ -63,6 +90,21 @@ export default function MainTabs() {
|
||||
}
|
||||
}, [renamingId]);
|
||||
|
||||
// Escape abandons a drag — the one affordance a pointer-event drag has to
|
||||
// supply for itself, since the OS is not running this one.
|
||||
useEffect(() => {
|
||||
if (!dragKey) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
pending.current = null;
|
||||
setDragKey(null);
|
||||
setDropIndex(null);
|
||||
setGhost(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [dragKey]);
|
||||
|
||||
if (tabOrder.length === 0) {
|
||||
return (
|
||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||
@@ -135,136 +177,307 @@ export default function MainTabs() {
|
||||
}
|
||||
};
|
||||
|
||||
const tabClass = (active: boolean) =>
|
||||
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
||||
const tabClass = (active: boolean, dragging: boolean) =>
|
||||
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer select-none border-r border-[var(--border-color)] transition-colors ${
|
||||
active
|
||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`;
|
||||
}${dragging ? " opacity-40" : ""}`;
|
||||
|
||||
/**
|
||||
* What a tab reads as, for the dragged copy. Same sources the tab itself
|
||||
* uses — a ghost showing a different name from the tab it came from would be
|
||||
* worse than no ghost.
|
||||
*/
|
||||
const tabLabel = (key: string): string => {
|
||||
if (isHomeTab(key)) {
|
||||
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
|
||||
}
|
||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||
if (!session) return "";
|
||||
const custom = getCustomName(session.projectId, session.id);
|
||||
return custom
|
||||
? `${session.projectName}: ${custom}`
|
||||
: (session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
pending.current = null;
|
||||
setDragKey(null);
|
||||
setDropIndex(null);
|
||||
setGhost(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Which slot the pointer is currently over, as an insertion index into
|
||||
* `tabOrder`.
|
||||
*
|
||||
* Measured from the tabs actually on screen rather than from the event's
|
||||
* target, so the answer is the same whatever the pointer happens to be over —
|
||||
* including the drop marker itself, and including a `tabOrder` entry whose
|
||||
* session has already gone and which therefore renders nothing.
|
||||
*/
|
||||
const dropIndexAt = (clientX: number): number => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return tabOrder.length;
|
||||
for (const el of strip.querySelectorAll<HTMLElement>("[data-tab-index]")) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (clientX < rect.left + rect.width / 2) return Number(el.dataset.tabIndex);
|
||||
}
|
||||
return tabOrder.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dragging is done with pointer events, not HTML5 drag-and-drop.
|
||||
*
|
||||
* Two reasons, both load-bearing. Tauri's `dragDropEnabled` — which the
|
||||
* terminal needs left on, because only the native drag-drop event carries
|
||||
* dropped *file paths* — blocks HTML5 drag inside the webview on Windows, so
|
||||
* an HTML5 implementation is simply dead there. And an HTML5 drag carries a
|
||||
* `DataTransfer`: released over any text field in the app, the default
|
||||
* handler types the payload into it.
|
||||
*/
|
||||
const pointerProps = (key: string, renaming: boolean) => ({
|
||||
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
// Left button only, never from the close button, and never while the
|
||||
// rename input is up — that drag is a text selection.
|
||||
if (e.button !== 0 || renaming) return;
|
||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
pending.current = {
|
||||
key,
|
||||
startX: e.clientX,
|
||||
dragging: false,
|
||||
// Where inside the tab the pointer grabbed it, so the ghost sits under
|
||||
// the cursor exactly where the real tab was — the thing that makes a
|
||||
// drag feel like moving an object rather than nudging a setting.
|
||||
offsetX: e.clientX - rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
top: rect.top,
|
||||
};
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = pending.current;
|
||||
if (!drag) return;
|
||||
// A few pixels of slop, so a click that trembles stays a click.
|
||||
if (!drag.dragging && Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD) return;
|
||||
drag.dragging = true;
|
||||
setDragKey(drag.key);
|
||||
setDropIndex(dropIndexAt(e.clientX));
|
||||
setGhost({
|
||||
x: e.clientX - drag.offsetX,
|
||||
y: drag.top,
|
||||
label: tabLabel(drag.key),
|
||||
icon: isHomeTab(drag.key) ? "⌂" : "▣",
|
||||
});
|
||||
},
|
||||
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = pending.current;
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
if (!drag?.dragging) {
|
||||
pending.current = null;
|
||||
return; // a plain click: leave it to `onClick` to select the tab
|
||||
}
|
||||
const to = dropIndexAt(e.clientX);
|
||||
const from = tabOrder.indexOf(drag.key);
|
||||
// `to` is a slot in the strip as it looks *now*; `moveTab` places the tab
|
||||
// after pulling it out, so every slot past its own shifts down one.
|
||||
if (from !== -1) moveTab(drag.key, to > from ? to - 1 : to);
|
||||
// The click that follows this pointerup is the drag's, not a selection.
|
||||
suppressClick.current = true;
|
||||
endDrag();
|
||||
},
|
||||
onPointerCancel: endDrag,
|
||||
});
|
||||
|
||||
/** A drag in progress swallows the click it ends with. */
|
||||
const activateTab = (key: string) => {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
setActiveTabKey(key);
|
||||
};
|
||||
|
||||
const dropMarker = (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="tab-drop-marker"
|
||||
className="w-0.5 -mx-px h-full bg-[var(--accent)] flex-shrink-0 pointer-events-none"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTab = (key: string, index: number) => {
|
||||
const active = activeTabKey === key;
|
||||
|
||||
if (isHomeTab(key)) {
|
||||
const projectId = tabKeyId(key);
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project) return null;
|
||||
return (
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
data-tab-index={index}
|
||||
onClick={() => activateTab(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(key);
|
||||
}
|
||||
}}
|
||||
{...pointerProps(key, false)}
|
||||
className={tabClass(active, dragKey === key)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||
{project.name}
|
||||
</span>
|
||||
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeHomeTab(projectId);
|
||||
}}
|
||||
aria-label={`Close ${project.name} home tab`}
|
||||
title="Close tab"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = tabKeyId(key);
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return null;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const isRenaming = renamingId === session.id;
|
||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
data-tab-index={index}
|
||||
onClick={() => activateTab(terminalTabKey(session.id))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(terminalTabKey(session.id));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onDoubleClick={() => startRename(session.id)}
|
||||
{...pointerProps(key, isRenaming)}
|
||||
className={tabClass(active, dragKey === key)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameDraft}
|
||||
aria-label="Rename tab"
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => commitRename(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="max-w-[180px] px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
||||
title={`Permission mode: ${badge.text}`}
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close(session.id);
|
||||
}}
|
||||
aria-label={`Close ${displayLabel}`}
|
||||
title="Close terminal"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// The marker goes before the first tab that is *actually on screen* at or
|
||||
// past the drop slot. Addressing it by raw index would lose it whenever a
|
||||
// `tabOrder` entry renders nothing — the window between a session ending and
|
||||
// the store dropping its key — leaving the drag with no visible target.
|
||||
let markerPending = dragKey !== null && dropIndex !== null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||
{tabOrder.map((key) => {
|
||||
const active = activeTabKey === key;
|
||||
|
||||
if (isHomeTab(key)) {
|
||||
const projectId = tabKeyId(key);
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project) return null;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(key);
|
||||
}
|
||||
}}
|
||||
className={tabClass(active)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||
{project.name}
|
||||
</span>
|
||||
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeHomeTab(projectId);
|
||||
}}
|
||||
aria-label={`Close ${project.name} home tab`}
|
||||
title="Close tab"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = tabKeyId(key);
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return null;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const isRenaming = renamingId === session.id;
|
||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||
|
||||
<div ref={stripRef} className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||
{tabOrder.map((key, index) => {
|
||||
const tab = renderTab(key, index);
|
||||
if (!tab) return null;
|
||||
const marker = markerPending && index >= (dropIndex ?? 0);
|
||||
if (marker) markerPending = false;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(terminalTabKey(session.id));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onDoubleClick={() => startRename(session.id)}
|
||||
className={tabClass(active)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameDraft}
|
||||
aria-label="Rename tab"
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => commitRename(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
||||
title={`Permission mode: ${badge.text}`}
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close(session.id);
|
||||
}}
|
||||
aria-label={`Close ${displayLabel}`}
|
||||
title="Close terminal"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<Fragment key={key}>
|
||||
{marker && dropMarker}
|
||||
{tab}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The empty run after the last tab is a drop target too — it is where
|
||||
the hand naturally goes to say "put it at the end". */}
|
||||
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
||||
|
||||
{ghost && (
|
||||
// A copy of the tab, following the pointer. Without it the only
|
||||
// feedback is a dimmed source and a thin line, which reads as "some
|
||||
// setting changed" rather than "I am holding this tab".
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="tab-drag-ghost"
|
||||
className="fixed z-50 flex items-center gap-1.5 px-3 h-8 text-xs rounded-[var(--radius-control)] bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--accent)] pointer-events-none select-none"
|
||||
style={{
|
||||
left: ghost.x,
|
||||
top: ghost.y,
|
||||
boxShadow: "var(--shadow-overlay)",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
<span className="text-[var(--text-secondary)]">{ghost.icon}</span>
|
||||
<span className="truncate max-w-[180px]">{ghost.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menu && (() => {
|
||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||
const hasCustom = session
|
||||
|
||||
@@ -13,6 +13,15 @@ const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||
const checkBrowserViewSupport = vi.fn<() => Promise<PlaywrightDetection>>();
|
||||
const installBrowserViewSupport = vi.fn<() => Promise<BrowserSetupOutcome>>();
|
||||
const installBrowserViewBrowser = vi.fn<(id: string, b: string) => Promise<BrowserSetupOutcome>>();
|
||||
const openBrowserViewPopout = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||
const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
|
||||
const getBrowserViewPopoutState =
|
||||
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
|
||||
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||
const openPageInContainerBrowser =
|
||||
vi.fn<(id: string, url: string, w: number, h: number) => Promise<{ error: string | null }>>();
|
||||
const setBrowserViewMatchWindow = vi.fn<(id: string, on: boolean) => Promise<void>>();
|
||||
const getBrowserViewMatchWindow = vi.fn<() => Promise<boolean>>();
|
||||
const pushToast = vi.fn();
|
||||
const setContainerProgress = vi.fn();
|
||||
|
||||
@@ -22,6 +31,15 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
checkBrowserViewSupport: () => checkBrowserViewSupport(),
|
||||
installBrowserViewSupport: () => installBrowserViewSupport(),
|
||||
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
|
||||
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
|
||||
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
|
||||
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
|
||||
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||
openPageInContainerBrowser: (id: string, url: string, w: number, h: number) =>
|
||||
openPageInContainerBrowser(id, url, w, h),
|
||||
setBrowserViewMatchWindow: (id: string, on: boolean) => setBrowserViewMatchWindow(id, on),
|
||||
getBrowserViewMatchWindow: () => getBrowserViewMatchWindow(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -59,6 +77,11 @@ const NOTHING: PlaywrightDetection = {
|
||||
cli_entry: null,
|
||||
browsers: [],
|
||||
chrome_channel: null,
|
||||
chromium_executable: null,
|
||||
chromium_executable_exists: false,
|
||||
script_playwright_version: null,
|
||||
script_chromium_executable: null,
|
||||
script_chromium_executable_exists: false,
|
||||
searched: [
|
||||
"/workspace",
|
||||
"/usr/lib/node_modules",
|
||||
@@ -111,8 +134,37 @@ beforeEach(() => {
|
||||
storeState.containerProgress = {};
|
||||
getBrowserViewStatus.mockResolvedValue(OFF);
|
||||
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||
getBrowserViewPopoutState.mockResolvedValue({ open: false, always_on_top: false });
|
||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||
setBrowserViewMatchWindow.mockResolvedValue(undefined);
|
||||
getBrowserViewMatchWindow.mockResolvedValue(false);
|
||||
openPageInContainerBrowser.mockResolvedValue({ error: null });
|
||||
});
|
||||
|
||||
const LIVE: BrowserViewStatus = {
|
||||
...OFF,
|
||||
enabled: true,
|
||||
state: "running",
|
||||
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||||
host_port: 47820,
|
||||
container_port: 39321,
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
};
|
||||
|
||||
/** Render with the view already live, which is the only state that pops out. */
|
||||
async function renderLive() {
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
setBrowserViewEnabled.mockResolvedValue(LIVE);
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||||
});
|
||||
await screen.findByTitle("Playwright browser view for api-server");
|
||||
}
|
||||
|
||||
describe("BrowserTab", () => {
|
||||
it("does not offer to start anything while the container is stopped", async () => {
|
||||
render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
|
||||
@@ -325,4 +377,198 @@ describe("BrowserTab", () => {
|
||||
expect(await screen.findByText("Off")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("names both halves when the installed browser isn\u2019t the one Playwright launches", async () => {
|
||||
// The cache is full and every script fails \u2014 "install a browser" alone
|
||||
// would read as nonsense, so the copy has to say which copy wants what.
|
||||
checkBrowserViewSupport.mockResolvedValue({
|
||||
...READY,
|
||||
browsers: ["chromium-1237"],
|
||||
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome",
|
||||
chromium_executable_exists: true,
|
||||
script_playwright_version: "1.62.1",
|
||||
script_chromium_executable:
|
||||
"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
|
||||
script_chromium_executable_exists: false,
|
||||
});
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
|
||||
expect(await screen.findByText(/isn\u2019t the one Playwright launches/i)).toBeInTheDocument();
|
||||
// Both revisions appear in the explanation: what is installed, and what
|
||||
// the failing copy actually wants.
|
||||
expect(screen.getAllByText(/chromium-1237/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/chromium-1234/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Set up Playwright/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not call an unanswered probe a skew", async () => {
|
||||
// A container older than these fields omits them; unknown is not broken.
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
|
||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/isn\u2019t the one Playwright launches/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("only offers a window of its own once there is something to watch", async () => {
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: /own window/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("pops the live view out, and drops the iframe so only one viewer drives", async () => {
|
||||
await renderLive();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
expect(openBrowserViewPopout).toHaveBeenCalledWith("p1", false);
|
||||
// The window is showing it now — a second copy here would be a second
|
||||
// cursor on the same page.
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||
// Still live, and still stoppable from the tab.
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("puts the view back in the tab when the window is closed from here", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /put back in tab/i })[0]);
|
||||
});
|
||||
|
||||
expect(closeBrowserViewPopout).toHaveBeenCalledWith("p1");
|
||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pins the window on top on request", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// The accessible name is the visible text, as with every other Toggle.
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Keep on top" }));
|
||||
});
|
||||
|
||||
expect(setBrowserViewPopoutAlwaysOnTop).toHaveBeenCalledWith("p1", true);
|
||||
});
|
||||
|
||||
it("keeps a pop-out that outlived the tab, rather than showing an empty pane", async () => {
|
||||
// The window belongs to the backend, so remounting the pane has to read its
|
||||
// state back — otherwise the pane would render an iframe alongside it.
|
||||
getBrowserViewPopoutState.mockResolvedValue({ open: true, always_on_top: true });
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
|
||||
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
// The pin is read from the window too — the pane is unmounted every time
|
||||
// another sub-tab is selected, so remembering it would show Off over a
|
||||
// window that is still floating on top.
|
||||
expect(screen.getByRole("switch", { name: "Keep on top" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("never mounts the iframe before the window's state is known", async () => {
|
||||
// The status and the pop-out state are two separate round trips. If the
|
||||
// status wins the race, guessing "not popped out" would flash a second
|
||||
// viewer onto a browser the window is already driving.
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||
let answer: (s: { open: boolean; always_on_top: boolean }) => void = () => {};
|
||||
getBrowserViewPopoutState.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(screen.getByText("Live")).toBeInTheDocument());
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
answer({ open: false, always_on_top: false });
|
||||
});
|
||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a page in the container’s browser at the chosen viewport", async () => {
|
||||
await renderLive();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||
target: { value: "http://localhost:5173" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open page/i }));
|
||||
});
|
||||
|
||||
expect(openPageInContainerBrowser).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
"http://localhost:5173",
|
||||
1920,
|
||||
1080,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a URL scheme the backend would reject, before the round trip", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||
target: { value: "file:///etc/passwd" },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled();
|
||||
expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument();
|
||||
expect(openPageInContainerBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers match-window only once the view is in its own window", async () => {
|
||||
await renderLive();
|
||||
expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Match window" }));
|
||||
});
|
||||
|
||||
expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true);
|
||||
});
|
||||
|
||||
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||
await renderLive();
|
||||
openBrowserViewPopout.mockRejectedValue("no display");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
expect(pushToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "error", detail: "no display" }),
|
||||
);
|
||||
// The view is still in the tab, where it was.
|
||||
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,21 +4,31 @@ import type {
|
||||
BrowserInstallTarget,
|
||||
BrowserSetupOutcome,
|
||||
BrowserViewChangedEvent,
|
||||
BrowserViewPopoutChangedEvent,
|
||||
BrowserViewStatus,
|
||||
PlaywrightDetection,
|
||||
Project,
|
||||
} from "../../../lib/types";
|
||||
import {
|
||||
checkBrowserViewSupport,
|
||||
closeBrowserViewPopout,
|
||||
getBrowserViewStatus,
|
||||
installBrowserViewBrowser,
|
||||
installBrowserViewSupport,
|
||||
getBrowserViewMatchWindow,
|
||||
getBrowserViewPopoutState,
|
||||
openBrowserViewPopout,
|
||||
openPageInContainerBrowser,
|
||||
setBrowserViewEnabled,
|
||||
setBrowserViewMatchWindow,
|
||||
setBrowserViewPopoutAlwaysOnTop,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import OpenPageDialog from "./OpenPageDialog";
|
||||
import AccordionSection from "../../ui/AccordionSection";
|
||||
import Button from "../../ui/Button";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
import Toggle from "../../ui/Toggle";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -66,6 +76,18 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
const [job, setJob] = useState<SetupJob>(null);
|
||||
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
/**
|
||||
* Whether the view is in its own window instead of this pane, and whether
|
||||
* that window is pinned. `null` means "not asked yet" — a distinct state from
|
||||
* "not popped out", because rendering the iframe on a guess is what puts a
|
||||
* second viewer on the browser.
|
||||
*/
|
||||
const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
|
||||
const [onTop, setOnTop] = useState(false);
|
||||
/** The "open a page" dialog, and the request it is running. */
|
||||
const [matchWindow, setMatchWindow] = useState(false);
|
||||
const [askPage, setAskPage] = useState(false);
|
||||
const [openingPage, setOpeningPage] = useState(false);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||
const progress = useAppState((s) => s.containerProgress[project.id]);
|
||||
@@ -95,8 +117,37 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
return () => dispose?.();
|
||||
}, [projectId]);
|
||||
|
||||
// The window is the backend's, not this component's: it survives the tab
|
||||
// being closed, the pane being unmounted and the view being torn down from
|
||||
// elsewhere. So its state is listened for, never assumed.
|
||||
useEffect(() => {
|
||||
let dispose: (() => void) | undefined;
|
||||
listen<BrowserViewPopoutChangedEvent>("browser-view-popout-changed", (event) => {
|
||||
if (event.payload.project_id === projectId && mounted.current) {
|
||||
setPoppedOut(event.payload.open);
|
||||
setOnTop(event.payload.always_on_top);
|
||||
}
|
||||
}).then((un) => {
|
||||
if (mounted.current) dispose = un;
|
||||
else un();
|
||||
});
|
||||
return () => dispose?.();
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !running) return;
|
||||
getBrowserViewPopoutState(projectId)
|
||||
.then((s) => {
|
||||
if (!mounted.current) return;
|
||||
setPoppedOut(s.open);
|
||||
setOnTop(s.always_on_top);
|
||||
})
|
||||
// Unreachable in practice, but a pane stuck at "not asked yet" would
|
||||
// never show the view at all — so fail towards the tab.
|
||||
.catch(() => mounted.current && setPoppedOut(false));
|
||||
getBrowserViewMatchWindow(projectId)
|
||||
.then((on) => mounted.current && setMatchWindow(on))
|
||||
.catch(() => {});
|
||||
getBrowserViewStatus(projectId)
|
||||
.then((s) => mounted.current && setStatus(s))
|
||||
.catch(() => {});
|
||||
@@ -129,6 +180,105 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pop the view out, or pull it back.
|
||||
*
|
||||
* Both are window operations only — the viewer keeps running either way — so
|
||||
* this is cheap enough to toggle freely and never interrupts what the agent
|
||||
* is doing in the browser.
|
||||
*/
|
||||
const popOut = useCallback(async () => {
|
||||
try {
|
||||
await openBrowserViewPopout(projectId, onTop);
|
||||
if (mounted.current) setPoppedOut(true);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open the browser in its own window",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
}, [projectId, onTop, pushToast]);
|
||||
|
||||
const popIn = useCallback(async () => {
|
||||
try {
|
||||
await closeBrowserViewPopout(projectId);
|
||||
if (mounted.current) setPoppedOut(false);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not close the browser window",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
}, [projectId, pushToast]);
|
||||
|
||||
const toggleOnTop = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOnTop(next);
|
||||
try {
|
||||
await setBrowserViewPopoutAlwaysOnTop(projectId, next);
|
||||
} catch (e) {
|
||||
if (mounted.current) setOnTop(!next);
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not change the window's stacking",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/**
|
||||
* Open a URL in a browser inside the container.
|
||||
*
|
||||
* The pane only ever *watched* browsers something else published; this is the
|
||||
* one action that opens one. It also means the page can be resized later —
|
||||
* whoever launches a bound browser is the only process that can drive it.
|
||||
*/
|
||||
const openPage = useCallback(
|
||||
async (url: string, width: number, height: number) => {
|
||||
setOpeningPage(true);
|
||||
try {
|
||||
const result = await openPageInContainerBrowser(projectId, url, width, height);
|
||||
if (!mounted.current) return;
|
||||
setAskPage(false);
|
||||
if (result.error) {
|
||||
pushToast({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||||
} else {
|
||||
pushToast({ kind: "success", message: `Opened ${url} at ${width}×${height}` });
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open the page in the container’s browser",
|
||||
detail: String(e),
|
||||
});
|
||||
} finally {
|
||||
if (mounted.current) setOpeningPage(false);
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
const toggleMatchWindow = useCallback(
|
||||
async (next: boolean) => {
|
||||
setMatchWindow(next);
|
||||
try {
|
||||
await setBrowserViewMatchWindow(projectId, next);
|
||||
} catch (e) {
|
||||
if (mounted.current) setMatchWindow(!next);
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not match the page to the window",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/** Run one install. Every path clears the progress line it started. */
|
||||
const install = useCallback(
|
||||
async (which: Exclude<SetupJob, null>) => {
|
||||
@@ -193,7 +343,9 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
// apt package, so it never shows up in `browsers`, and a container that has
|
||||
// it is not missing a browser.
|
||||
const needsBrowser =
|
||||
probed !== null && probed.browsers.length === 0 && probed.chrome_channel === null;
|
||||
probed !== null &&
|
||||
probed.chrome_channel === null &&
|
||||
(probed.browsers.length === 0 || revisionSkew(probed));
|
||||
const needsSetup = probed !== null && (!ready || needsBrowser);
|
||||
|
||||
return (
|
||||
@@ -225,11 +377,48 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{live && (
|
||||
{progress && (
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] min-w-0"
|
||||
aria-live="polite"
|
||||
>
|
||||
<StatusIndicator tone="busy" label="" />
|
||||
<span className="truncate">{progress}</span>
|
||||
</span>
|
||||
)}
|
||||
{live && poppedOut === true && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
Keep on top
|
||||
{/* The accessible name matches the visible text, as everywhere else
|
||||
a Toggle is used — a `<label>` around it would be inert anyway,
|
||||
since a Toggle renders a button. */}
|
||||
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
|
||||
</span>
|
||||
)}
|
||||
{live && poppedOut === true && (
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"
|
||||
title="Resize the page itself as the window is dragged, so the layout actually reflows. Applies to pages opened from here."
|
||||
>
|
||||
Match window
|
||||
<Toggle checked={matchWindow} onChange={toggleMatchWindow} label="Match window" />
|
||||
</span>
|
||||
)}
|
||||
{live && poppedOut === false && (
|
||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
{live && (
|
||||
<Button size="md" onClick={() => setAskPage(true)}>
|
||||
Open a page…
|
||||
</Button>
|
||||
)}
|
||||
{live && poppedOut !== null && (
|
||||
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
||||
{poppedOut ? "Put back in tab" : "Open in own window"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="md"
|
||||
variant={live ? "secondary" : "primary"}
|
||||
@@ -240,7 +429,28 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{live ? (
|
||||
{live && poppedOut === true ? (
|
||||
// The iframe is unmounted while the window is up, on purpose. Two
|
||||
// viewers on one browser both work, but both also *drive* it — two
|
||||
// cursors taking over the same page is not a feature.
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||
<div className="max-w-[28rem] text-center">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||
This view is in its own window.
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
Move it to another screen, or keep it on top, and watch the browser while
|
||||
you work here. The view keeps running either way — closing the window
|
||||
brings it back into this tab.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<Button size="md" variant="primary" onClick={popIn}>
|
||||
Put back in tab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : live && poppedOut === false ? (
|
||||
<iframe
|
||||
key={reloadKey}
|
||||
// Loopback only, and the URL carries the one-time session token the
|
||||
@@ -249,6 +459,11 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
title={`Playwright browser view for ${project.name}`}
|
||||
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||
/>
|
||||
) : live ? (
|
||||
// Live, but the window's state hasn't come back yet. An instant, and
|
||||
// deliberately empty: guessing "not popped out" here is what would
|
||||
// flash a second viewer onto the browser.
|
||||
<div className="flex-1 min-h-0" />
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Setup stays on screen while an install is running and after it
|
||||
@@ -283,6 +498,14 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askPage && (
|
||||
<OpenPageDialog
|
||||
busy={openingPage}
|
||||
onOpen={openPage}
|
||||
onClose={() => setAskPage(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -292,6 +515,47 @@ function isUsable(d: PlaywrightDetection | null): boolean {
|
||||
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rust `PlaywrightDetection::revision_skew`.
|
||||
*
|
||||
* Browsers are installed, but not the revision one of the two Playwright copies
|
||||
* would launch — so the cache looks full and launches fail. A probe that didn't
|
||||
* answer leaves the executable null, and "unknown" must not read as "broken".
|
||||
*/
|
||||
function revisionSkew(d: PlaywrightDetection | null): boolean {
|
||||
if (!d || d.browsers.length === 0) return false;
|
||||
// `!= null`, not `!== null`: a probe from a container that predates these
|
||||
// fields omits them entirely, and `undefined` is "didn't answer" — which must
|
||||
// never render as "your browsers are wrong".
|
||||
const viewerBroken = d.chromium_executable != null && !d.chromium_executable_exists;
|
||||
const scriptsBroken =
|
||||
d.script_chromium_executable != null && !d.script_chromium_executable_exists;
|
||||
return viewerBroken || scriptsBroken;
|
||||
}
|
||||
|
||||
/**
|
||||
* The skew sentence, naming both halves.
|
||||
*
|
||||
* "Install a browser" over a cache that visibly already holds one reads as
|
||||
* nonsense, so the copy has to say which copy of Playwright wants what.
|
||||
*/
|
||||
function skewText(d: PlaywrightDetection | null): string {
|
||||
if (!d) return "";
|
||||
const scriptsBroken =
|
||||
d.script_chromium_executable !== null && !d.script_chromium_executable_exists;
|
||||
const [version, wanted] = scriptsBroken
|
||||
? [d.script_playwright_version, d.script_chromium_executable]
|
||||
: [d.playwright_version, d.chromium_executable];
|
||||
return (
|
||||
`This container has ${d.browsers.join(", ")}, but ` +
|
||||
`${scriptsBroken ? 'the Playwright a script gets from require("playwright")' : "the Playwright serving the viewer"}` +
|
||||
` — ${version ?? "?"} — launches ${wanted ?? "?"}, which isn’t there. ` +
|
||||
(scriptsBroken
|
||||
? "Two copies ended up in one tree, each pinning its own browser revision, so the viewer works and every script Claude writes fails. Re-run “Set up Playwright” to reinstall them as one consistent set."
|
||||
: "Install Chromium below: it runs that build’s own installer, so it fetches exactly the revision that is missing.")
|
||||
);
|
||||
}
|
||||
|
||||
/** What the container is short of, as a list rather than as prose. */
|
||||
function missingParts(d: PlaywrightDetection | null): string[] {
|
||||
if (!d) return [];
|
||||
@@ -339,6 +603,10 @@ function Setup({
|
||||
const browsers = detection?.browsers ?? [];
|
||||
const chrome = detection?.chrome_channel ?? null;
|
||||
const noBrowser = browsers.length === 0 && chrome === null;
|
||||
// Installed browsers that cannot be launched. Handled apart from `noBrowser`
|
||||
// because the fix is the same button but the sentence must not be "install a
|
||||
// browser" over a cache that visibly has one.
|
||||
const skew = revisionSkew(detection) && chrome === null;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[46rem] space-y-4">
|
||||
@@ -346,17 +614,21 @@ function Setup({
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||
{!havePackages
|
||||
? "This container can’t serve a browser view yet"
|
||||
: noBrowser
|
||||
? "Playwright is ready — but there’s no browser to drive yet"
|
||||
: "This container is set up"}
|
||||
: skew
|
||||
? "The installed browser isn’t the one Playwright launches"
|
||||
: noBrowser
|
||||
? "Playwright is ready — but there’s no browser to drive yet"
|
||||
: "This container is set up"}
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
{message ??
|
||||
(missing.length > 0
|
||||
? `Missing: ${missing.join(", ")}.`
|
||||
: noBrowser
|
||||
? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
|
||||
: "Start the view from the button above once Claude has a browser open.")}
|
||||
: skew
|
||||
? skewText(detection)
|
||||
: noBrowser
|
||||
? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
|
||||
: "Start the view from the button above once Claude has a browser open.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../../ui/Modal";
|
||||
import Button from "../../ui/Button";
|
||||
|
||||
/**
|
||||
* Viewport presets. These are the *page's* resolution, not the window's — the
|
||||
* pane is a screencast, so a bigger window shows the same pixels drawn larger
|
||||
* while this is what actually reflows the layout.
|
||||
*/
|
||||
const PRESETS: { label: string; width: number; height: number }[] = [
|
||||
{ label: "1280 × 720", width: 1280, height: 720 },
|
||||
{ label: "1920 × 1080", width: 1920, height: 1080 },
|
||||
{ label: "1440 × 900", width: 1440, height: 900 },
|
||||
{ label: "390 × 844 (phone)", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
/** Prefilled URL — an auth URL from the terminal, or the last one used. */
|
||||
initialUrl?: string;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
busy?: boolean;
|
||||
onOpen: (url: string, width: number, height: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a URL and a viewport, then open it in the container's browser.
|
||||
*
|
||||
* Deliberately modal and short-lived — the convention for a task with one
|
||||
* question and one button. The URL is not opened here; the caller runs the
|
||||
* command so failures land in its toast.
|
||||
*/
|
||||
export default function OpenPageDialog({
|
||||
initialUrl = "",
|
||||
initialWidth = 1280,
|
||||
initialHeight = 720,
|
||||
busy = false,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const [width, setWidth] = useState(initialWidth);
|
||||
const [height, setHeight] = useState(initialHeight);
|
||||
|
||||
const trimmed = url.trim();
|
||||
// Mirrors the backend's allow-list, so the error arrives before the click
|
||||
// rather than after a round trip.
|
||||
const valid = /^https?:\/\/\S+$/i.test(trimmed);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Open a page in the container's browser"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={!valid || busy}
|
||||
onClick={() => onOpen(trimmed, width, height)}
|
||||
>
|
||||
{busy ? "Opening…" : "Open page"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
Launches a browser <em>inside</em> this container and publishes it to the
|
||||
Browser tab. Use it for a sign-in page — the callback listener is in the
|
||||
container too, so the login completes without involving your host browser —
|
||||
or for a dev server on container loopback.
|
||||
</p>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--text-secondary)]">URL</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && valid && !busy) onOpen(trimmed, width, height);
|
||||
}}
|
||||
placeholder="http://localhost:5173"
|
||||
spellCheck={false}
|
||||
className="mt-1 w-full px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] font-mono text-[var(--text-primary)]"
|
||||
/>
|
||||
{trimmed !== "" && !valid && (
|
||||
<span className="mt-1 block text-xs text-[var(--error)]">
|
||||
Only http:// and https:// URLs can be opened.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">Viewport</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((p) => {
|
||||
const active = p.width === width && p.height === height;
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setWidth(p.width);
|
||||
setHeight(p.height);
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded-[var(--radius-control)] border transition-colors ${
|
||||
active
|
||||
? "border-[var(--accent)] bg-[var(--accent-muted)] text-[var(--accent)]"
|
||||
: "border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Viewport width"
|
||||
value={width}
|
||||
min={200}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
<span aria-hidden="true" className="text-xs text-[var(--text-secondary)]">×</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Viewport height"
|
||||
value={height}
|
||||
min={200}
|
||||
onChange={(e) => setHeight(Number(e.target.value))}
|
||||
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-secondary)]">CSS pixels</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const { projects, remove } = useProjects();
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||
|
||||
// Somewhere else asked for this project on a particular sub-tab — currently
|
||||
// "I opened a page in the container's browser, show me it". Consumed once, so
|
||||
// it cannot fight the user's own clicking afterwards.
|
||||
const pendingHomeTab = useAppState((s) => s.pendingHomeTab);
|
||||
useEffect(() => {
|
||||
if (pendingHomeTab?.projectId !== projectId) return;
|
||||
setTab(pendingHomeTab.tab as ProjectHomeTabId);
|
||||
useAppState.getState().clearPendingHomeTab();
|
||||
}, [pendingHomeTab, projectId]);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
|
||||
@@ -7,7 +7,11 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||
import {
|
||||
awsSsoRefresh,
|
||||
openPageInContainerBrowser,
|
||||
uploadHostFileToTerminal,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import {
|
||||
@@ -370,8 +374,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// Handle backend output -> terminal
|
||||
let aborted = false;
|
||||
|
||||
const detector = new UrlDetector((url) =>
|
||||
promptUrl(url, "Long URL detected"),
|
||||
// The width is read per scan, not captured: only a break the terminal
|
||||
// itself inserted may be deleted, and where that is moves with every
|
||||
// resize.
|
||||
const detector = new UrlDetector(
|
||||
(url) => promptUrl(url, "Long URL detected"),
|
||||
() => termRef.current?.cols ?? 0,
|
||||
);
|
||||
detectorRef.current = detector;
|
||||
|
||||
@@ -529,6 +537,51 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
}, [urlPrompt]);
|
||||
|
||||
/**
|
||||
* Open the prompted URL in the container's own browser instead of the host's.
|
||||
*
|
||||
* For a sign-in this is the shorter path: the callback listener the tool is
|
||||
* waiting on is inside the container, so a container-side browser closes the
|
||||
* loop with nothing crossing to the host. The page is published to the
|
||||
* project's Browser tab, which is where the user completes it by hand.
|
||||
*/
|
||||
const handleOpenUrlInContainer = useCallback(() => {
|
||||
if (!urlPrompt) return;
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
setUrlPrompt(null);
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
if (!projectId) return;
|
||||
// Land on the pane that will show it, before the work starts: opening takes
|
||||
// several seconds, and the progress line lives there.
|
||||
useAppState.getState().openProjectHomeTab(projectId, "browser");
|
||||
// A sign-in page is the one case where the *window* size matters least and
|
||||
// the layout matters most, so it gets the ordinary desktop viewport.
|
||||
// `true`: from a terminal there is no Browser pane on screen, so the page
|
||||
// needs a window of its own or it opens somewhere the user isn't looking.
|
||||
openPageInContainerBrowser(projectId, safe, 1280, 720, true)
|
||||
.then((result) => {
|
||||
const push = useAppState.getState().pushToast;
|
||||
if (result.error) {
|
||||
push({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||||
} else {
|
||||
push({
|
||||
kind: "success",
|
||||
message: "Opened in the container’s browser",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) =>
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open it in the container’s browser",
|
||||
detail: String(e),
|
||||
}),
|
||||
);
|
||||
}, [urlPrompt, projectId]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
@@ -606,6 +659,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
url={urlPrompt.url}
|
||||
label={urlPrompt.label}
|
||||
onOpen={handleOpenUrl}
|
||||
onOpenInContainer={handleOpenUrlInContainer}
|
||||
onDismiss={() => setUrlPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,9 @@ interface Props {
|
||||
/** Heading above the URL. Says why the toast appeared. */
|
||||
label?: string;
|
||||
onOpen: () => void;
|
||||
/** Open it in the container's own browser instead of the host's. Omitted when
|
||||
* the project has no browser to open it in. */
|
||||
onOpenInContainer?: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ export default function UrlToast({
|
||||
url,
|
||||
label = "Long URL detected",
|
||||
onOpen,
|
||||
onOpenInContainer,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const origin = urlOrigin(url);
|
||||
@@ -131,6 +135,30 @@ export default function UrlToast({
|
||||
Open
|
||||
</button>
|
||||
|
||||
{onOpenInContainer && (
|
||||
// A sign-in completed in the *container's* browser lands its callback
|
||||
// on the container's own loopback, which is where the tool waiting for
|
||||
// it is listening — no host round trip, no auth bridge.
|
||||
<button
|
||||
onClick={onOpenInContainer}
|
||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
In container
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||
import { useAppState, homeTabKey, terminalTabKey } from "../store/appState";
|
||||
|
||||
vi.mock("./useTerminal", () => ({
|
||||
useTerminal: () => ({ open: vi.fn(), close: vi.fn() }),
|
||||
}));
|
||||
|
||||
const HOME = homeTabKey("p1");
|
||||
const S1 = terminalTabKey("s1");
|
||||
const S2 = terminalTabKey("s2");
|
||||
|
||||
const order = () => useAppState.getState().tabOrder;
|
||||
|
||||
/** Press a chord, from whatever is focused. */
|
||||
function press(key: string, { shift = false } = {}) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, ctrlKey: true, shiftKey: shift, bubbles: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Focus a real element of the given kind, inside `parent` if given. */
|
||||
function focus(tag: "input" | "textarea", parentClass?: string): HTMLElement {
|
||||
const el = document.createElement(tag);
|
||||
if (parentClass) {
|
||||
const parent = document.createElement("div");
|
||||
parent.className = parentClass;
|
||||
parent.appendChild(el);
|
||||
document.body.appendChild(parent);
|
||||
} else {
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.focus();
|
||||
return el;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAppState.setState({ tabOrder: [HOME, S1, S2], activeTabKey: S1, activeSessionId: "s1" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("Ctrl+Shift+←/→", () => {
|
||||
it("moves the active tab along the strip", () => {
|
||||
renderHook(() => useKeyboardShortcuts());
|
||||
|
||||
press("ArrowLeft", { shift: true });
|
||||
expect(order()).toEqual([S1, HOME, S2]);
|
||||
|
||||
press("ArrowRight", { shift: true });
|
||||
expect(order()).toEqual([HOME, S1, S2]);
|
||||
});
|
||||
|
||||
it("leaves word-wise selection alone in a text field", () => {
|
||||
// Ctrl+Shift+←/→ already means "extend the selection by a word" in every
|
||||
// input in the app — the rename field, Config fields, Settings fields.
|
||||
// Taking it there would break selection *and* silently reorder tabs.
|
||||
renderHook(() => useKeyboardShortcuts());
|
||||
focus("input");
|
||||
|
||||
press("ArrowLeft", { shift: true });
|
||||
|
||||
expect(order()).toEqual([HOME, S1, S2]);
|
||||
});
|
||||
|
||||
it("still moves tabs from the terminal, whose focus lives in a textarea", () => {
|
||||
// xterm keeps a hidden textarea focused as its input-method shim. It is
|
||||
// not a field anyone edits word-wise, and the terminal is where these
|
||||
// shortcuts matter most, so it is not treated as one.
|
||||
renderHook(() => useKeyboardShortcuts());
|
||||
focus("textarea", "xterm xterm-helper-textarea-host");
|
||||
|
||||
press("ArrowLeft", { shift: true });
|
||||
|
||||
expect(order()).toEqual([S1, HOME, S2]);
|
||||
});
|
||||
|
||||
it("does nothing without Shift — that chord is readline's word motion", () => {
|
||||
renderHook(() => useKeyboardShortcuts());
|
||||
|
||||
press("ArrowLeft");
|
||||
|
||||
expect(order()).toEqual([HOME, S1, S2]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,22 @@ import { useEffect } from "react";
|
||||
import { useAppState, isTerminalTab, tabKeyId } from "../store/appState";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
|
||||
/**
|
||||
* Whether the focus is in something the user is editing text in.
|
||||
*
|
||||
* xterm's hidden textarea is deliberately excluded: it is an input-method
|
||||
* shim, not a field anyone edits word-wise, and the terminal is exactly where
|
||||
* the tab shortcuts need to keep working.
|
||||
*/
|
||||
function inTextField(el: Element | null): boolean {
|
||||
if (!el || el.closest(".xterm")) return false;
|
||||
return (
|
||||
el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
(el as HTMLElement).isContentEditable === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* App-level shortcuts. Registered on `document` in the *capture* phase so they
|
||||
* win over xterm.js, which would otherwise forward them to the shell inside
|
||||
@@ -11,6 +27,7 @@ import { useTerminal } from "./useTerminal";
|
||||
* Ctrl+Shift+W close the active tab
|
||||
* Ctrl+Tab next tab (Ctrl+Shift+Tab for previous)
|
||||
* Ctrl+1..9 jump to the nth tab
|
||||
* Ctrl+Shift+←/→ move the active tab along the strip
|
||||
*/
|
||||
export function useKeyboardShortcuts() {
|
||||
const { open: openTerminal, close: closeTerminal } = useTerminal();
|
||||
@@ -51,6 +68,22 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+←/→ — move the active tab, the keyboard route to what
|
||||
// dragging a tab does. Shift is what keeps it clear of the terminal:
|
||||
// Ctrl+←/→ is readline's word-wise cursor motion.
|
||||
//
|
||||
// In a text field this chord already means "extend the selection by a
|
||||
// word", which is not ours to take: swallowing it would make word-wise
|
||||
// selection impossible in every input in the app *and* silently reorder
|
||||
// the strip each time someone tried it.
|
||||
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
||||
if (!state.activeTabKey || inTextField(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
state.moveActiveTab(e.key === "ArrowLeft" ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) return;
|
||||
|
||||
// Ctrl+1..9 — jump to tab
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -201,6 +201,77 @@ export const installBrowserViewBrowser = (
|
||||
browser: BrowserInstallTarget,
|
||||
) => invoke<BrowserSetupOutcome>("install_browser_view_browser", { projectId, browser });
|
||||
|
||||
/**
|
||||
* Detach the live view into its own OS window, or raise it if already open.
|
||||
*
|
||||
* Window-only: the viewer, the proxy and the container are untouched, so
|
||||
* popping out and back costs nothing. The window loads the same token-bearing
|
||||
* loopback URL as the pane, and has no IPC access.
|
||||
*/
|
||||
export const openBrowserViewPopout = (projectId: string, alwaysOnTop: boolean) =>
|
||||
invoke<void>("open_browser_view_popout", { projectId, alwaysOnTop });
|
||||
/** Close the pop-out and put the view back in the tab. No-op if it isn't open. */
|
||||
export const closeBrowserViewPopout = (projectId: string) =>
|
||||
invoke<void>("close_browser_view_popout", { projectId });
|
||||
/**
|
||||
* Whether the pop-out is open and whether it is pinned, read from the window.
|
||||
*
|
||||
* Asked on every mount: the pane is unmounted whenever another Project Home
|
||||
* sub-tab is selected, while the window carries on — so neither fact can live
|
||||
* in component state and survive.
|
||||
*/
|
||||
export const getBrowserViewPopoutState = (projectId: string) =>
|
||||
invoke<BrowserViewPopoutState>("get_browser_view_popout_state", { projectId });
|
||||
/**
|
||||
* Open a URL in a browser *inside* the container, published so the pane shows it.
|
||||
*
|
||||
* The same action serves an auth URL — the OAuth callback listener is in the
|
||||
* container too, so the loop closes without the host — and a dev server on
|
||||
* container loopback, which is how you watch a UI Claude is building. Only
|
||||
* http/https; the backend rejects anything else.
|
||||
*
|
||||
* The viewer is started if it isn't already: asking for a page is asking to
|
||||
* watch it, and leaving the user to go and press Start themselves — with no
|
||||
* hint that they had to — is what the first version did.
|
||||
*/
|
||||
export const openPageInContainerBrowser = (
|
||||
projectId: string,
|
||||
url: string,
|
||||
width: number,
|
||||
height: number,
|
||||
/** Also raise the pop-out window — for callers with no pane on screen. */
|
||||
showWindow = false,
|
||||
) =>
|
||||
invoke<BrowserPageState>("open_page_in_container_browser", {
|
||||
projectId,
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
showWindow,
|
||||
});
|
||||
/** Resize that page. Real reflow, not a scaled screencast — see BrowserTab. */
|
||||
export const setContainerPageViewport = (projectId: string, width: number, height: number) =>
|
||||
invoke<void>("set_container_page_viewport", { projectId, width, height });
|
||||
export const getContainerPageState = (projectId: string) =>
|
||||
invoke<BrowserPageState>("get_container_page_state", { projectId });
|
||||
export const closeContainerPage = (projectId: string) =>
|
||||
invoke<void>("close_container_page", { projectId });
|
||||
|
||||
/**
|
||||
* Make the page track the pop-out window's size as it is dragged.
|
||||
*
|
||||
* Only affects a page this app opened: a bound browser admits no second client,
|
||||
* so one `@playwright/mcp` launched keeps the viewport it was given.
|
||||
*/
|
||||
export const setBrowserViewMatchWindow = (projectId: string, enabled: boolean) =>
|
||||
invoke<void>("set_browser_view_match_window", { projectId, enabled });
|
||||
export const getBrowserViewMatchWindow = (projectId: string) =>
|
||||
invoke<boolean>("get_browser_view_match_window", { projectId });
|
||||
|
||||
/** Pin the pop-out above other windows — the point of popping it out at all. */
|
||||
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
|
||||
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
|
||||
|
||||
// Shared Claude Code auth token — one `claude setup-token` run authenticates
|
||||
// every Anthropic-backend project. The token itself is never exposed here: it
|
||||
// lives in the OS keychain and is injected as a container env var.
|
||||
|
||||
@@ -469,6 +469,15 @@ export interface PlaywrightDetection {
|
||||
/** Path to Google Chrome when the `chrome` channel — what `@playwright/mcp`
|
||||
* asks for — is installed. It is an apt package, so it is never in `browsers`. */
|
||||
chrome_channel: string | null;
|
||||
/** The Chromium the *viewer's* Playwright would launch, and whether it exists. */
|
||||
chromium_executable: string | null;
|
||||
chromium_executable_exists: boolean;
|
||||
/** What a script's `require("playwright")` resolves to — routinely a different
|
||||
* copy, pinning a different browser revision. If its Chromium is missing,
|
||||
* every script Claude writes fails while the pane still looks green. */
|
||||
script_playwright_version: string | null;
|
||||
script_chromium_executable: string | null;
|
||||
script_chromium_executable_exists: boolean;
|
||||
/** Module roots the probe searched, echoed back for the "not found" message.
|
||||
* Includes the npx cache (`~/.npm/_npx/*/node_modules`), which is where a
|
||||
* Playwright installed through Claude Code's MCP setup actually lives. */
|
||||
@@ -514,6 +523,47 @@ export interface BrowserViewChangedEvent {
|
||||
status: BrowserViewStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rust `PopoutState` — read from the window, never remembered.
|
||||
*
|
||||
* The pane is unmounted whenever another Project Home sub-tab is selected while
|
||||
* the window carries on, so anything it holds in component state is stale by
|
||||
* the time the user comes back.
|
||||
*/
|
||||
export interface BrowserViewPopoutState {
|
||||
open: boolean;
|
||||
always_on_top: boolean;
|
||||
}
|
||||
|
||||
/** Mirrors Rust `page::Viewport` — CSS pixels, clamped backend-side. */
|
||||
export interface BrowserPageViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rust `page::PageState`: what the container-side helper reports about
|
||||
* the page Triple-C opened. `ready: false` with no error means there is none.
|
||||
*/
|
||||
export interface BrowserPageState {
|
||||
ready: boolean;
|
||||
url: string | null;
|
||||
viewport: BrowserPageViewport | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload of the `browser-view-popout-changed` event: a `BrowserViewPopoutState`
|
||||
* plus the project it belongs to.
|
||||
*
|
||||
* The pop-out window can close without the pane asking — the user hits its X,
|
||||
* or the session tears down and takes it — so this is the only reliable way to
|
||||
* know whether it is on screen.
|
||||
*/
|
||||
export interface BrowserViewPopoutChangedEvent extends BrowserViewPopoutState {
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
/** Payload of the `claude-token-progress` event: milestones during
|
||||
* `acquire_claude_token`. Never contains the token. */
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { UrlDetector, flatten } from "./urlDetector";
|
||||
|
||||
const COLS = 80;
|
||||
const enc = new TextEncoder();
|
||||
|
||||
/** Feed text and let the debounce + confirmation timers run. */
|
||||
function feed(detector: UrlDetector, text: string) {
|
||||
detector.feed(enc.encode(text));
|
||||
vi.advanceTimersByTime(2000);
|
||||
}
|
||||
|
||||
/** Hard-wrap the way a PTY does: a break every `cols` characters, nothing lost. */
|
||||
function ptyWrap(text: string, cols = COLS): string {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < text.length; i += cols) lines.push(text.slice(i, i + cols));
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("flatten", () => {
|
||||
it("rejoins a break the terminal inserted at the width", () => {
|
||||
expect(flatten("abcde\nfghij", 5)).toBe("abcdefghij");
|
||||
});
|
||||
|
||||
it("keeps a break that arrived before the width as a separator", () => {
|
||||
expect(flatten("abc\ndef", 5)).toBe("abc def");
|
||||
});
|
||||
|
||||
it("rejoins nothing when the width isn't known", () => {
|
||||
// Better to lose a wrapped URL than to invent one.
|
||||
expect(flatten("abcde\nfghij", 0)).toBe("abcde fghij");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UrlDetector", () => {
|
||||
it("reconstructs a URL the PTY hard-wrapped mid-token", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
const url =
|
||||
"https://accounts.example.com/o/oauth2/auth?client_id=1234567890-abcdefghijklmnop.apps.example.com&redirect_uri=http%3A%2F%2Flocalhost%3A45678&scope=openid+email+profile";
|
||||
|
||||
feed(d, "Open this link:\r\n" + ptyWrap(url) + "\r\nWaiting for the browser…\r\n");
|
||||
|
||||
expect(seen).toEqual([url]);
|
||||
});
|
||||
|
||||
it("does not glue the text that follows a link onto it", () => {
|
||||
// The bug this file exists for. A terminal wrapping a paragraph emits the
|
||||
// break *instead of* the space, so deleting every break produced
|
||||
// `…/tag/preview-63f3c54Butitprovesyournitpick…` — a different host and
|
||||
// path from the one on screen, opened on the user's machine.
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
const url =
|
||||
"https://repo.anhonesthost.net/CyberCoveLLC/Triple-C/releases/tag/preview-63f3c54-with-a-long-enough-suffix-to-scan";
|
||||
|
||||
feed(
|
||||
d,
|
||||
[
|
||||
url,
|
||||
"But it proves your nitpick perfectly: every file in it says 0.3.0.",
|
||||
"That is the hard-coded patch number.",
|
||||
].join("\r\n") + "\r\n",
|
||||
);
|
||||
|
||||
expect(seen).toEqual([url]);
|
||||
expect(seen[0]).not.toContain("But");
|
||||
// And the host is exactly what was printed — no characters lost.
|
||||
expect(new URL(seen[0]).host).toBe("repo.anhonesthost.net");
|
||||
});
|
||||
|
||||
it("stops at the end of a short line even when more output follows", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
const url = "https://example.com/" + "a".repeat(90);
|
||||
|
||||
feed(d, `${url}\r\nnext line of output\r\n`);
|
||||
|
||||
expect(seen).toEqual([url]);
|
||||
});
|
||||
|
||||
it("joins the next line when a token ends exactly at the width", () => {
|
||||
// The one case the width rule cannot decide: a URL whose length is an exact
|
||||
// multiple of the column count looks identical to one that was cut. Pinned
|
||||
// as known behaviour rather than pretended away — the toast still shows the
|
||||
// whole candidate, and nothing opens without the user pressing Open.
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
const url = "https://example.com/" + "c".repeat(2 * COLS - 20); // exactly 2 lines
|
||||
|
||||
feed(d, `${ptyWrap(url)}\r\nTAIL\r\n`);
|
||||
|
||||
expect(seen).toEqual([url + "TAIL"]);
|
||||
});
|
||||
|
||||
it("ignores anything under the length threshold", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, "see https://example.com/short\r\nmore text\r\n");
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a wrapped URL once, not once per chunk", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
const url = "https://example.com/" + "b".repeat(120);
|
||||
|
||||
feed(d, ptyWrap(url));
|
||||
feed(d, "\r\ndone\r\n");
|
||||
|
||||
expect(seen).toEqual([url]);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,23 @@
|
||||
*
|
||||
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
|
||||
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
|
||||
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
|
||||
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
|
||||
* the buffer (rejoining hard wraps, treating every other break as a
|
||||
* terminator) and matches URLs with a single regex, firing a callback for ones
|
||||
* >= 100 chars.
|
||||
*
|
||||
* ## Which line breaks may be deleted
|
||||
*
|
||||
* Only the ones the *terminal* inserted. A hard wrap happens at exactly the
|
||||
* column width, so a line that reached the width was cut mid-token and its
|
||||
* break must be removed to put the token back together; a line that stopped
|
||||
* short ended for its own reasons and its break is a real separator.
|
||||
*
|
||||
* Deleting every break instead — which this did — glues unrelated output onto
|
||||
* the end of a URL. Observed for real: a wrapped paragraph following a link
|
||||
* became `…/tag/preview-63f3c54Butitprovesyournitpick…`, because a terminal
|
||||
* that wraps at a space emits the break *instead of* the space, so removing
|
||||
* the break removes the separator too. That candidate is a different URL from
|
||||
* the one on screen, and the user is the one who has to notice.
|
||||
*
|
||||
* When a URL match extends to the end of the flattened buffer, emission is
|
||||
* deferred (more chunks may still be arriving). A confirmation timer emits
|
||||
@@ -21,6 +36,45 @@ const MIN_URL_LENGTH = 100;
|
||||
|
||||
export type UrlCallback = (url: string) => void;
|
||||
|
||||
/**
|
||||
* How wide the terminal is right now.
|
||||
*
|
||||
* A getter, not a number: the width changes with every window resize, and a
|
||||
* stale one silently turns joining back into guesswork.
|
||||
*/
|
||||
export type ColumnsGetter = () => number;
|
||||
|
||||
/**
|
||||
* Rejoin the line breaks the terminal inserted; turn the rest into spaces.
|
||||
*
|
||||
* A line of exactly `columns` visible characters was cut by the terminal, so
|
||||
* its break is deleted and the two halves are put back together. Anything
|
||||
* shorter ended on its own and becomes a space — a URL cannot contain one, so
|
||||
* that is also what stops a match running into whatever followed.
|
||||
*
|
||||
* `columns` of 0 or less means "not known yet"; nothing is rejoined, which
|
||||
* costs a wrapped URL rather than inventing one.
|
||||
*
|
||||
* One case stays ambiguous and cannot be resolved here: a token that happens to
|
||||
* end exactly at the width is indistinguishable from one the terminal cut, so
|
||||
* the following line is joined to it. The candidate is still shown in full and
|
||||
* confirmed by the user before anything opens.
|
||||
*/
|
||||
export function flatten(clean: string, columns: number): string {
|
||||
const lines = clean.split(/\r?\n/);
|
||||
let out = "";
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
out += lines[i];
|
||||
if (i === lines.length - 1) break;
|
||||
// `===`, not `>=`. A line *longer* than the width was never cut by the
|
||||
// terminal — the stream simply contained no break there, so the break that
|
||||
// follows it is the application's own and separates two things.
|
||||
const wrapped = columns > 0 && lines[i].length === columns;
|
||||
if (!wrapped) out += " ";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class UrlDetector {
|
||||
private decoder = new TextDecoder();
|
||||
private buffer = "";
|
||||
@@ -29,9 +83,11 @@ export class UrlDetector {
|
||||
private lastEmitted = "";
|
||||
private pendingUrl: string | null = null;
|
||||
private callback: UrlCallback;
|
||||
private columns: ColumnsGetter;
|
||||
|
||||
constructor(callback: UrlCallback) {
|
||||
constructor(callback: UrlCallback, columns: ColumnsGetter) {
|
||||
this.callback = callback;
|
||||
this.columns = columns;
|
||||
}
|
||||
|
||||
/** Feed raw PTY output chunks. */
|
||||
@@ -61,12 +117,8 @@ export class UrlDetector {
|
||||
// 1. Strip ANSI escape sequences
|
||||
const clean = this.buffer.replace(ANSI_RE, "");
|
||||
|
||||
// 2. Flatten the buffer:
|
||||
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
|
||||
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
|
||||
const flat = clean
|
||||
.replace(/(\r?\n){2,}/g, " ")
|
||||
.replace(/[\r\n]/g, "");
|
||||
// 2. Flatten the buffer: rejoin hard wraps, terminate on everything else.
|
||||
const flat = flatten(clean, this.columns());
|
||||
|
||||
if (!flat) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useAppState, homeTabKey, terminalTabKey } from "./appState";
|
||||
|
||||
const A = homeTabKey("a");
|
||||
const B = terminalTabKey("b");
|
||||
const C = terminalTabKey("c");
|
||||
|
||||
function seed(tabOrder: string[], activeTabKey: string | null = null) {
|
||||
useAppState.setState({
|
||||
tabOrder,
|
||||
activeTabKey,
|
||||
activeSessionId: null,
|
||||
selectedProjectId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const order = () => useAppState.getState().tabOrder;
|
||||
|
||||
describe("tab reordering", () => {
|
||||
beforeEach(() => seed([A, B, C]));
|
||||
|
||||
it("moves a tab to an earlier slot", () => {
|
||||
useAppState.getState().moveTab(C, 0);
|
||||
expect(order()).toEqual([C, A, B]);
|
||||
});
|
||||
|
||||
it("moves a tab to a later slot", () => {
|
||||
useAppState.getState().moveTab(A, 2);
|
||||
expect(order()).toEqual([B, C, A]);
|
||||
});
|
||||
|
||||
it("clamps a destination past the ends rather than dropping the tab", () => {
|
||||
useAppState.getState().moveTab(A, 99);
|
||||
expect(order()).toEqual([B, C, A]);
|
||||
useAppState.getState().moveTab(A, -5);
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("ignores a tab that isn't in the strip", () => {
|
||||
useAppState.getState().moveTab("term:gone", 0);
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("does not change what's active — dragging a tab is not selecting it", () => {
|
||||
seed([A, B, C], B);
|
||||
useAppState.getState().moveTab(C, 0);
|
||||
const state = useAppState.getState();
|
||||
expect(state.tabOrder).toEqual([C, A, B]);
|
||||
expect(state.activeTabKey).toBe(B);
|
||||
});
|
||||
|
||||
it("nudges the active tab with the keyboard, in both directions", () => {
|
||||
seed([A, B, C], B);
|
||||
useAppState.getState().moveActiveTab(-1);
|
||||
expect(order()).toEqual([B, A, C]);
|
||||
useAppState.getState().moveActiveTab(1);
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("stops the active tab at the ends instead of wrapping it around", () => {
|
||||
seed([A, B, C], A);
|
||||
useAppState.getState().moveActiveTab(-1);
|
||||
// A held-down key must not teleport the tab to the far end.
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("does nothing when no tab is active", () => {
|
||||
seed([A, B, C], null);
|
||||
useAppState.getState().moveActiveTab(1);
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("keeps Ctrl+1..9 addressing the strip as reordered", () => {
|
||||
seed([A, B, C], A);
|
||||
useAppState.getState().moveTab(C, 0);
|
||||
useAppState.getState().focusTabIndex(0);
|
||||
expect(useAppState.getState().activeTabKey).toBe(C);
|
||||
});
|
||||
});
|
||||
@@ -71,10 +71,26 @@ interface AppState {
|
||||
tabOrder: string[];
|
||||
activeTabKey: string | null;
|
||||
openProjectHome: (projectId: string) => void;
|
||||
/**
|
||||
* Open a project's home tab *on a particular sub-tab*.
|
||||
*
|
||||
* The sub-tab is local state inside `ProjectHome`, so this parks a request
|
||||
* here for it to pick up: an action taken somewhere else entirely — opening a
|
||||
* page in the container's browser from a terminal — has to be able to land
|
||||
* the user on the pane that shows the result.
|
||||
*/
|
||||
openProjectHomeTab: (projectId: string, tab: string) => void;
|
||||
/** Consumed once by `ProjectHome`, then cleared. */
|
||||
pendingHomeTab: { projectId: string; tab: string } | null;
|
||||
clearPendingHomeTab: () => void;
|
||||
closeHomeTab: (projectId: string) => void;
|
||||
setActiveTabKey: (key: string) => void;
|
||||
cycleTab: (delta: number) => void;
|
||||
focusTabIndex: (index: number) => void;
|
||||
/** Reorder: put `key` at `toIndex` in the strip. Never changes what's active. */
|
||||
moveTab: (key: string, toIndex: number) => void;
|
||||
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
||||
moveActiveTab: (delta: number) => void;
|
||||
|
||||
// Inline container progress, replacing the blocking progress modal.
|
||||
containerProgress: Record<string, string>;
|
||||
@@ -231,6 +247,20 @@ export const useAppState = create<AppState>((set) => ({
|
||||
...activation(key),
|
||||
};
|
||||
}),
|
||||
openProjectHomeTab: (projectId, tab) =>
|
||||
set((state) => {
|
||||
const key = homeTabKey(projectId);
|
||||
return {
|
||||
selectedProjectId: projectId,
|
||||
tabOrder: state.tabOrder.includes(key)
|
||||
? state.tabOrder
|
||||
: [...state.tabOrder, key],
|
||||
pendingHomeTab: { projectId, tab },
|
||||
...activation(key),
|
||||
};
|
||||
}),
|
||||
pendingHomeTab: null,
|
||||
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
||||
closeHomeTab: (projectId) =>
|
||||
set((state) => {
|
||||
const key = homeTabKey(projectId);
|
||||
@@ -274,6 +304,35 @@ export const useAppState = create<AppState>((set) => ({
|
||||
? { ...patch, selectedProjectId: tabKeyId(key) }
|
||||
: patch;
|
||||
}),
|
||||
// Reordering is deliberately *only* a reordering: dragging a tab does not
|
||||
// select it, so a drag can be aimed at a background tab without yanking the
|
||||
// main area (and a running terminal's focus) away mid-gesture.
|
||||
moveTab: (key, toIndex) =>
|
||||
set((state) => {
|
||||
const from = state.tabOrder.indexOf(key);
|
||||
if (from === -1) return {};
|
||||
const to = Math.max(0, Math.min(toIndex, state.tabOrder.length - 1));
|
||||
if (from === to) return {};
|
||||
const tabOrder = [...state.tabOrder];
|
||||
tabOrder.splice(from, 1);
|
||||
tabOrder.splice(to, 0, key);
|
||||
return { tabOrder };
|
||||
}),
|
||||
moveActiveTab: (delta) =>
|
||||
set((state) => {
|
||||
const key = state.activeTabKey;
|
||||
if (!key) return {};
|
||||
const from = state.tabOrder.indexOf(key);
|
||||
if (from === -1) return {};
|
||||
// Clamped, not wrapped: a tab dragged off the end would otherwise
|
||||
// reappear at the other end, which reads as a bug on a held-down key.
|
||||
const to = Math.max(0, Math.min(from + delta, state.tabOrder.length - 1));
|
||||
if (from === to) return {};
|
||||
const tabOrder = [...state.tabOrder];
|
||||
tabOrder.splice(from, 1);
|
||||
tabOrder.splice(to, 0, key);
|
||||
return { tabOrder };
|
||||
}),
|
||||
|
||||
// Container progress
|
||||
containerProgress: {},
|
||||
|
||||
Reference in New Issue
Block a user