diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 9b46abe..2795813 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -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//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-`. +# +# ## Lifecycle +# +# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most +# recent `v..` 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 diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 56ffaee..3d63327 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -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}" diff --git a/CLAUDE.md b/CLAUDE.md index 9dae98a..5235e1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:` and `home:`; `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//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 diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 09c1979..d0e479e 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -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 diff --git a/VERSION b/VERSION index 1d71ef9..e6adf3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3 \ No newline at end of file +0.4 \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 4a3a24e..b35ded4 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index cf64567..3c02859 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "triple-c", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 087d3f6..7efb86a 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -5163,7 +5163,7 @@ dependencies = [ [[package]] name = "triple-c" -version = "0.3.0" +version = "0.4.0" dependencies = [ "axum", "base64 0.22.1", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 52cff6f..67f1ea7 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "triple-c" -version = "0.3.0" +version = "0.4.0" edition = "2021" [lib] diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs index 293b0f8..0b3a58e 100644 --- a/app/src-tauri/src/browser_view/commands.rs +++ b/app/src-tauri/src/browser_view/commands.rs @@ -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 { + 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 { + 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 { + 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 { + 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 diff --git a/app/src-tauri/src/browser_view/detect.rs b/app/src-tauri/src/browser_view/detect.rs index 280343d..241eac9 100644 --- a/app/src-tauri/src/browser_view/detect.rs +++ b/app/src-tauri/src/browser_view/detect.rs @@ -93,6 +93,32 @@ pub struct PlaywrightDetection { /// user's own scripts and not for the MCP plugin. #[serde(default)] pub chrome_channel: Option, + /// 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, + /// 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, + /// 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, + #[serde(default)] + pub script_chromium_executable_exists: bool, /// Where the probe looked, echoed back for the "not found" message. #[serde(default)] pub searched: Vec, @@ -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 { + 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( diff --git a/app/src-tauri/src/browser_view/install.rs b/app/src-tauri/src/browser_view/install.rs index 27b8de9..c859b82 100644 --- a/app/src-tauri/src/browser_view/install.rs +++ b/app/src-tauri/src/browser_view/install.rs @@ -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 { + 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] diff --git a/app/src-tauri/src/browser_view/mod.rs b/app/src-tauri/src/browser_view/mod.rs index cde1369..adac57c 100644 --- a/app/src-tauri/src/browser_view/mod.rs +++ b/app/src-tauri/src/browser_view/mod.rs @@ -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)); } diff --git a/app/src-tauri/src/browser_view/page.rs b/app/src-tauri/src/browser_view/page.rs new file mode 100644 index 0000000..0d82111 --- /dev/null +++ b/app/src-tauri/src/browser_view/page.rs @@ -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, + #[serde(default)] + pub viewport: Option, + #[serde(default)] + pub error: Option, +} + +/// 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 { + 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 { + 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 { + 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()); + } +} diff --git a/app/src-tauri/src/browser_view/popout.rs b/app/src-tauri/src/browser_view/popout.rs new file mode 100644 index 0000000..1b84cf3 --- /dev/null +++ b/app/src-tauri/src/browser_view/popout.rs @@ -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>> = 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> { + 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::(); + 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); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d442ede..a918c19 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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::(); 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, diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index e6094b3..1b98dc9 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -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", diff --git a/app/src/components/layout/MainTabs.test.tsx b/app/src/components/layout/MainTabs.test.tsx new file mode 100644 index 0000000..855b789 --- /dev/null +++ b/app/src/components/layout/MainTabs.test.tsx @@ -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(); + 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(); + 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(); + const tabs = laidOut(); + + dragTab(tabs[0], 50, 800); + + expect(order()).toEqual([S1, S2, HOME]); + }); + + it("dragging does not steal the selection", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + for (const tab of screen.getAllByRole("tab")) { + expect(tab).not.toHaveAttribute("draggable", "true"); + } + }); +}); diff --git a/app/src/components/layout/MainTabs.tsx b/app/src/components/layout/MainTabs.tsx index 5d768c6..0ac7287 100644 --- a/app/src/components/layout/MainTabs.tsx +++ b/app/src/components/layout/MainTabs.tsx @@ -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 = { 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 = /** * 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(null); const [renamingId, setRenamingId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); const renameInputRef = useRef(null); + /** The tab being dragged, and the slot it would drop into. */ + const [dragKey, setDragKey] = useState(null); + const [dropIndex, setDropIndex] = useState(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(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 (
@@ -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("[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) => { + // 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) => { + 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) => { + 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 = ( +