mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d69e2006 | ||
|
|
4b7c623592 | ||
|
|
b91884bc54 | ||
|
|
963c79265a | ||
|
|
ade1e338ea | ||
|
|
c13972c835 | ||
|
|
d8d04c545e | ||
|
|
613450bac8 | ||
|
|
cafff08a30 | ||
|
|
cb60f2a596 | ||
|
|
58d72bea87 | ||
|
|
953f7898e7 | ||
|
|
fd06e109be | ||
|
|
ba1bb1646e | ||
|
|
3ee11efcd0 | ||
|
|
1930a8a2f2 | ||
|
|
f7fd41a5f8 | ||
|
|
3af5b0f031 | ||
|
|
cce8dee21c | ||
|
|
2d02db6ae0 | ||
|
|
66732a2f48 |
48
.github/workflows/android-apk-release.yml
vendored
48
.github/workflows/android-apk-release.yml
vendored
@@ -34,15 +34,33 @@ jobs:
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[2]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_args=(
|
||||
release create "$RELEASE_TAG"
|
||||
--repo "${{ github.repository }}"
|
||||
--title "Paseo $RELEASE_TAG"
|
||||
--generate-notes
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
if ! gh "${release_args[@]}"; then
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -107,24 +125,6 @@ jobs:
|
||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for GitHub release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 90); do
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Found release for tag $RELEASE_TAG"
|
||||
exit 0
|
||||
fi
|
||||
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
|
||||
sleep 20
|
||||
done
|
||||
|
||||
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
|
||||
exit 1
|
||||
|
||||
- name: Upload APK to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/deploy-app.yml
vendored
2
.github/workflows/deploy-app.yml
vendored
@@ -4,7 +4,9 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-rc.*'
|
||||
- 'app-v*'
|
||||
- '!app-v*-rc.*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
1
.github/workflows/deploy-website.yml
vendored
1
.github/workflows/deploy-website.yml
vendored
@@ -15,6 +15,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
127
.github/workflows/desktop-release.yml
vendored
127
.github/workflows/desktop-release.yml
vendored
@@ -58,24 +58,7 @@ jobs:
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -110,24 +93,6 @@ jobs:
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -167,21 +132,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download manifest artifacts
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
@@ -277,24 +228,7 @@ jobs:
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -329,24 +263,6 @@ jobs:
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -378,24 +294,7 @@ jobs:
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -438,24 +337,6 @@ jobs:
|
||||
npx expo export --platform web
|
||||
working-directory: packages/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
10
.github/workflows/release-notes-sync.yml
vendored
10
.github/workflows/release-notes-sync.yml
vendored
@@ -19,11 +19,6 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
draft:
|
||||
description: "Create missing release as draft."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
@@ -48,7 +43,6 @@ jobs:
|
||||
REF: ${{ github.ref }}
|
||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
||||
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||
INPUT_DRAFT: ${{ github.event.inputs.draft }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -70,8 +64,4 @@ jobs:
|
||||
args+=(--create-if-missing)
|
||||
fi
|
||||
|
||||
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
|
||||
args+=(--draft)
|
||||
fi
|
||||
|
||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.41 - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
|
||||
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
|
||||
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
|
||||
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
|
||||
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
|
||||
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
|
||||
- Fixed context menu for copy/paste across the desktop app.
|
||||
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1–F12).
|
||||
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
|
||||
|
||||
### Improved
|
||||
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
|
||||
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
|
||||
|
||||
## 0.1.40 - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png
|
||||
|
||||
## Cloud build + submit (EAS)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger:
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||
|
||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
All workspaces share one version and release together.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
```bash
|
||||
@@ -12,6 +19,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
|
||||
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
@@ -21,27 +30,44 @@ npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Draft release flow
|
||||
## Release candidate flow
|
||||
|
||||
```bash
|
||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||
# ... test builds from the draft release assets ...
|
||||
npm run release:finalize # Publish npm, promote draft to published
|
||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
||||
```
|
||||
|
||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
||||
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
|
||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
||||
|
||||
Use the RC path when you need to:
|
||||
|
||||
- test a build manually in a Linux or Windows VM
|
||||
- send a build to a user who is hitting a specific problem
|
||||
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
||||
|
||||
## Website behavior
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||
|
||||
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
|
||||
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||
|
||||
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||
|
||||
```bash
|
||||
# Desktop (all platforms)
|
||||
@@ -54,21 +80,29 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
||||
|
||||
# Android APK
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
|
||||
# RC
|
||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
||||
```
|
||||
|
||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||
|
||||
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
|
||||
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
||||
|
||||
## Changelog format
|
||||
|
||||
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
|
||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
@@ -76,11 +110,23 @@ The website depends on the changelog to determine the latest download version. T
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` is for **final stable releases only**.
|
||||
- Do not add or edit changelog entries while iterating on RCs.
|
||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **Only Claude should write changelog entries.**
|
||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-wLXSLXXzB1rwuQXPRFFt4EKZsFydN3HTR99ZJqBdXxk=";
|
||||
npmDepsHash = "sha256-WUTgYClIpHYsIrKjmlxvYxM6dS9jkYtAS0WLAH2nR/I=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34962,16 +34962,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.40",
|
||||
"@getpaseo/highlight": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.41",
|
||||
"@getpaseo/highlight": "0.1.41",
|
||||
"@getpaseo/server": "0.1.41",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35088,11 +35088,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"@getpaseo/relay": "0.1.41",
|
||||
"@getpaseo/server": "0.1.41",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35133,11 +35133,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"@getpaseo/cli": "0.1.41",
|
||||
"@getpaseo/server": "0.1.41",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35171,7 +35171,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35372,7 +35372,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35398,7 +35398,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35414,13 +35414,13 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.40",
|
||||
"@getpaseo/relay": "0.1.40",
|
||||
"@getpaseo/highlight": "0.1.41",
|
||||
"@getpaseo/relay": "0.1.41",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35818,7 +35818,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
25
package.json
25
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -41,18 +41,23 @@
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
|
||||
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
|
||||
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
|
||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-rc.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.40",
|
||||
"@getpaseo/highlight": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.41",
|
||||
"@getpaseo/highlight": "0.1.41",
|
||||
"@getpaseo/server": "0.1.41",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Prevent white flash before React mounts */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
background-color: #181B1A;
|
||||
}
|
||||
}
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { View, Text } from "react-native";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
@@ -69,7 +68,7 @@ import {
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
@@ -477,6 +476,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
@@ -495,10 +495,13 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update title bar theme", error);
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
foregroundColor: theme.colors.foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [settingsLoading, resolvedTheme]);
|
||||
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
@@ -603,6 +606,7 @@ function FaviconStatusSync() {
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -610,7 +614,7 @@ function RootStack() {
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
contentStyle: {
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -654,8 +658,10 @@ function NavigationActiveWorkspaceObserver() {
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export function createNativeFileAttachmentStore() {
|
||||
return createLocalFileAttachmentStore({
|
||||
@@ -8,8 +9,17 @@ export function createNativeFileAttachmentStore() {
|
||||
if (attachment.storageKey.startsWith("file://")) {
|
||||
return attachment.storageKey;
|
||||
}
|
||||
if (attachment.storageKey.startsWith("/")) {
|
||||
return `file://${attachment.storageKey}`;
|
||||
if (isAbsolutePath(attachment.storageKey)) {
|
||||
if (attachment.storageKey.startsWith("/")) {
|
||||
return `file://${attachment.storageKey}`;
|
||||
}
|
||||
|
||||
// UNC paths: \\server\share -> file://server/share
|
||||
if (attachment.storageKey.startsWith("\\\\")) {
|
||||
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
|
||||
}
|
||||
return attachment.storageKey;
|
||||
},
|
||||
|
||||
24
packages/app/src/attachments/utils.test.ts
Normal file
24
packages/app/src/attachments/utils.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pathToFileUri } from "./utils";
|
||||
|
||||
describe("pathToFileUri", () => {
|
||||
it("converts POSIX absolute paths to file URIs", () => {
|
||||
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
|
||||
});
|
||||
|
||||
it("converts Windows drive-letter paths to file URIs", () => {
|
||||
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
|
||||
});
|
||||
|
||||
it("converts UNC paths to host-based file URIs", () => {
|
||||
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
|
||||
});
|
||||
|
||||
it("passes through file URIs unchanged", () => {
|
||||
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
|
||||
});
|
||||
|
||||
it("passes through relative paths unchanged", () => {
|
||||
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export function generateAttachmentId(): string {
|
||||
return `att_${generateMessageId()}`;
|
||||
@@ -53,10 +54,21 @@ export function pathToFileUri(path: string): string {
|
||||
if (path.startsWith("file://")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (!isAbsolutePath(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
return `file://${path}`;
|
||||
}
|
||||
return path;
|
||||
|
||||
// UNC paths: \\server\share -> file://server/share
|
||||
if (path.startsWith("\\\\")) {
|
||||
return `file:${path.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
return `file:///${path.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
export function fileUriToPath(uri: string): string {
|
||||
|
||||
@@ -1444,11 +1444,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
maxHeight: 400,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -1127,14 +1127,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
...theme.shadow.sm,
|
||||
},
|
||||
scrollToBottomIcon: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -268,10 +268,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
|
||||
57
packages/app/src/components/desktop/titlebar-drag-region.tsx
Normal file
57
packages/app/src/components/desktop/titlebar-drag-region.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
|
||||
/**
|
||||
* VS Code-style titlebar drag region for Electron.
|
||||
*
|
||||
* Copied from VS Code at commit daa0a70:
|
||||
* - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
|
||||
* - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
|
||||
* - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
|
||||
*
|
||||
* VS Code's drag region is a static DOM element — no z-index, no pointer-events,
|
||||
* no state, no event listeners. Interactive elements get no-drag from their own
|
||||
* CSS (global backstop in index.html). The drag region never re-renders.
|
||||
*
|
||||
* The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
|
||||
* On macOS, Electron handles edge resize natively.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
|
||||
* Place as FIRST child of any positioned container that should be draggable.
|
||||
*/
|
||||
export function TitlebarDragRegion() {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
|
||||
<div
|
||||
style={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||
WebkitAppRegion: "drag",
|
||||
}}
|
||||
/>
|
||||
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: 4,
|
||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||
WebkitAppRegion: "no-drag",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -108,11 +108,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
@@ -344,6 +345,7 @@ function SidebarContent({
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabsContainer}>
|
||||
{isGit && (
|
||||
<Pressable
|
||||
@@ -434,6 +436,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
position: "relative",
|
||||
height: HEADER_INNER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -119,7 +119,7 @@ function FilePreviewBody({
|
||||
filePath,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#181B1A";
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
|
||||
|
||||
@@ -86,9 +86,8 @@ interface HighlightedTextProps {
|
||||
|
||||
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#181B1A";
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
|
||||
// Get color for a highlight style
|
||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
if (!style) return baseColor;
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
isCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
left?: ReactNode;
|
||||
@@ -31,8 +32,6 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
@@ -45,8 +44,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
||||
},
|
||||
borderless && styles.borderless,
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<TitlebarDragRegion />
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||
</View>
|
||||
@@ -61,6 +60,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
inner: {},
|
||||
row: {
|
||||
position: "relative",
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface PaseoLogoProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function PaseoLogo({ size = 64, color = "white" }: PaseoLogoProps) {
|
||||
export function PaseoLogo({ size = 64, color }: PaseoLogoProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const fill = color ?? theme.colors.foreground;
|
||||
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 700 700" fill="none">
|
||||
<Path
|
||||
d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z"
|
||||
fill={color}
|
||||
fill={fill}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
@@ -638,7 +639,6 @@ function DesktopSidebar({
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
@@ -689,10 +689,13 @@ function DesktopSidebar({
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||
<View style={styles.sidebarHeader}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -831,6 +834,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
sidebarHeader: {
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
|
||||
@@ -269,10 +269,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
|
||||
@@ -2022,11 +2022,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale: 1.02 }],
|
||||
zIndex: 3,
|
||||
elevation: 4,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
...theme.shadow.md,
|
||||
},
|
||||
projectRowLeft: {
|
||||
flexDirection: "row",
|
||||
@@ -2169,11 +2165,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale: 1.02 }],
|
||||
zIndex: 3,
|
||||
elevation: 4,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
...theme.shadow.md,
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import {
|
||||
computeTabDropPreview,
|
||||
type TabDropPreview,
|
||||
@@ -887,6 +888,7 @@ function SplitPaneView({
|
||||
{ paddingLeft: padding.left, paddingRight: padding.right },
|
||||
]}
|
||||
>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused}
|
||||
@@ -997,6 +999,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
paneTabs: {
|
||||
position: "relative",
|
||||
minWidth: 0,
|
||||
},
|
||||
paneContent: {
|
||||
|
||||
@@ -259,11 +259,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
toastSuccess: {
|
||||
borderColor: theme.colors.border,
|
||||
|
||||
@@ -248,11 +248,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
detailLabel: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -275,11 +271,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
|
||||
@@ -136,9 +136,11 @@ export function Button({
|
||||
return <View>{leftIcon}</View>;
|
||||
}
|
||||
|
||||
const color = variant === "ghost"
|
||||
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
|
||||
: theme.colors.foreground;
|
||||
const color = variant === "default"
|
||||
? theme.colors.accentForeground
|
||||
: variant === "ghost"
|
||||
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
|
||||
: theme.colors.foreground;
|
||||
const iconSize = ICON_SIZE[size];
|
||||
|
||||
// Render function
|
||||
|
||||
@@ -845,11 +845,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
maxHeight: 400,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -722,11 +722,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
overflow: "hidden",
|
||||
},
|
||||
sheetBackground: {
|
||||
|
||||
@@ -605,11 +605,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
labelContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
@@ -535,11 +535,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderWidth: theme.borderWidth[2],
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
...theme.shadow.sm,
|
||||
zIndex: 1000,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -25,11 +25,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
buttonIdle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -350,8 +350,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
? HANDLE_OPACITY_VISIBLE
|
||||
: 0;
|
||||
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
|
||||
const isDark = theme.colors.surface0 === "#181B1A";
|
||||
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
|
||||
const handleColor = theme.colors.scrollbarHandle;
|
||||
const handleCursor = isDragging ? "grabbing" : "grab";
|
||||
const handleTravelDurationMs =
|
||||
isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;
|
||||
|
||||
@@ -320,7 +320,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
testID="welcome-screen"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
|
||||
import {
|
||||
getDesktopHost,
|
||||
type DesktopWindowBridge,
|
||||
type DesktopWindowControlsOverlayUpdate,
|
||||
} from "@/desktop/host";
|
||||
|
||||
export function getDesktopWindow(): DesktopWindowBridge | null {
|
||||
const getter = getDesktopHost()?.window?.getCurrentWindow;
|
||||
@@ -28,11 +32,13 @@ export async function isDesktopFullscreen(): Promise<boolean> {
|
||||
return await win.isFullscreen();
|
||||
}
|
||||
|
||||
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
|
||||
export async function updateDesktopWindowControls(
|
||||
update: DesktopWindowControlsOverlayUpdate,
|
||||
): Promise<void> {
|
||||
const win = getDesktopWindow();
|
||||
if (!win || typeof win.setTitleBarTheme !== "function") {
|
||||
if (!win || typeof win.updateWindowControls !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
await win.setTitleBarTheme(theme);
|
||||
await win.updateWindowControls(update);
|
||||
}
|
||||
|
||||
@@ -44,14 +44,17 @@ export interface DesktopMenuBridge {
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopWindowControlsOverlayUpdate {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
}
|
||||
|
||||
export interface DesktopWindowBridge {
|
||||
label?: string;
|
||||
startMove?: (screenX: number, screenY: number) => void;
|
||||
moving?: (screenX: number, screenY: number) => void;
|
||||
endMove?: () => void;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
|
||||
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void,
|
||||
) => Promise<() => void> | (() => void);
|
||||
|
||||
@@ -128,11 +128,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[4],
|
||||
paddingRight: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
maxWidth: 480,
|
||||
},
|
||||
closeButton: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
|
||||
|
||||
@@ -29,12 +30,8 @@ const IMAGE_FILE_EXTENSIONS = [
|
||||
"svg",
|
||||
];
|
||||
|
||||
function isAbsoluteWindowsPath(value: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function shouldTreatAsFileUri(uri: string): boolean {
|
||||
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
|
||||
return uri.startsWith("file://") || isAbsolutePath(uri);
|
||||
}
|
||||
|
||||
async function blobFromUri(uri: string): Promise<Blob> {
|
||||
|
||||
@@ -43,6 +43,17 @@ KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
|
||||
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
|
||||
KEY_MAP["ArrowUp"] = { code: "ArrowUp" };
|
||||
KEY_MAP["ArrowDown"] = { code: "ArrowDown" };
|
||||
KEY_MAP["Tab"] = { code: "Tab" };
|
||||
KEY_MAP["Delete"] = { code: "Delete" };
|
||||
KEY_MAP["Home"] = { code: "Home" };
|
||||
KEY_MAP["End"] = { code: "End" };
|
||||
KEY_MAP["PageUp"] = { code: "PageUp" };
|
||||
KEY_MAP["PageDown"] = { code: "PageDown" };
|
||||
KEY_MAP["Insert"] = { code: "Insert" };
|
||||
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
KEY_MAP[`F${i}`] = { code: `F${i}` };
|
||||
}
|
||||
|
||||
const CODE_TO_KEY: Record<string, string> = {};
|
||||
for (const [humanKey, mapping] of Object.entries(KEY_MAP)) {
|
||||
@@ -170,6 +181,15 @@ export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
|
||||
return s.split(" ").map(comboStringToShortcutKeys);
|
||||
}
|
||||
|
||||
export function heldModifiersFromEvent(event: KeyboardEvent): string | null {
|
||||
const parts: string[] = [];
|
||||
if (event.ctrlKey) parts.push("Ctrl");
|
||||
if (event.altKey) parts.push("Alt");
|
||||
if (event.shiftKey) parts.push("Shift");
|
||||
if (event.metaKey) parts.push("Cmd");
|
||||
return parts.length > 0 ? parts.join("+") : null;
|
||||
}
|
||||
|
||||
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
return null;
|
||||
|
||||
@@ -51,7 +51,7 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
@@ -243,7 +243,6 @@ function DraftAgentScreenContent({
|
||||
const activateExplorerTabForCheckout = usePanelStore(
|
||||
(state) => state.activateExplorerTabForCheckout,
|
||||
);
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const draftIdRef = useRef(generateDraftId());
|
||||
const draftAgentIdRef = useRef(generateDraftId());
|
||||
@@ -999,7 +998,8 @@ function DraftAgentScreenContent({
|
||||
const explorerServerId = draftExplorerCheckout?.serverId ?? null;
|
||||
const explorerIsGit = draftExplorerCheckout?.isGit ?? false;
|
||||
const mainContent = (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={styles.container}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.outerContainer}>
|
||||
<View style={styles.agentPanel}>
|
||||
<View
|
||||
@@ -1292,6 +1292,7 @@ function DraftAgentScreenContent({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||
@@ -18,7 +18,6 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
||||
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompactLayout) {
|
||||
@@ -29,7 +28,8 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader borderless />
|
||||
<View style={styles.content} {...dragHandlers}>
|
||||
<View style={styles.content}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.logo}>
|
||||
<PaseoLogo size={56} />
|
||||
</View>
|
||||
@@ -58,6 +58,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
userSelect: "none",
|
||||
},
|
||||
content: {
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, Platform } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,18 +14,30 @@ import {
|
||||
import {
|
||||
chordStringToShortcutKeys,
|
||||
comboStringToShortcutKeys,
|
||||
heldModifiersFromEvent,
|
||||
keyboardEventToComboString,
|
||||
} from "@/keyboard/shortcut-string";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
|
||||
function ShortcutSequence({ chord }: { chord: string[] | null }) {
|
||||
if (!chord || chord.length === 0) {
|
||||
function ShortcutSequence({
|
||||
chord,
|
||||
heldModifiers,
|
||||
}: {
|
||||
chord: string[] | null;
|
||||
heldModifiers: string | null;
|
||||
}) {
|
||||
if ((!chord || chord.length === 0) && !heldModifiers) {
|
||||
return <Text style={styles.capturingText}>Press shortcut...</Text>;
|
||||
}
|
||||
|
||||
return <Shortcut chord={chord.map(comboStringToShortcutKeys)} />;
|
||||
const displayCombos = [...(chord ?? [])];
|
||||
if (heldModifiers) {
|
||||
displayCombos.push(heldModifiers);
|
||||
}
|
||||
|
||||
return <Shortcut chord={displayCombos.map(comboStringToShortcutKeys)} />;
|
||||
}
|
||||
|
||||
function ShortcutRow({
|
||||
@@ -33,6 +46,7 @@ function ShortcutRow({
|
||||
overrideCombo,
|
||||
isCapturing,
|
||||
capturedCombos,
|
||||
heldModifiers,
|
||||
onRebind,
|
||||
onDone,
|
||||
onCancel,
|
||||
@@ -43,6 +57,7 @@ function ShortcutRow({
|
||||
overrideCombo: string | undefined;
|
||||
isCapturing: boolean;
|
||||
capturedCombos: string[];
|
||||
heldModifiers: string | null;
|
||||
onRebind: () => void;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -55,7 +70,7 @@ function ShortcutRow({
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
<View style={styles.rowActions}>
|
||||
{isCapturing ? (
|
||||
<ShortcutSequence chord={capturedCombos} />
|
||||
<ShortcutSequence chord={capturedCombos} heldModifiers={heldModifiers} />
|
||||
) : (
|
||||
<Shortcut chord={displayChord} />
|
||||
)}
|
||||
@@ -88,22 +103,32 @@ function ShortcutRow({
|
||||
export function KeyboardShortcutsSection() {
|
||||
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
|
||||
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
|
||||
const [heldModifiers, setHeldModifiers] = useState<string | null>(null);
|
||||
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
|
||||
useKeyboardShortcutOverrides();
|
||||
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
|
||||
|
||||
const isFocused = useIsFocused();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused && capturingBindingId !== null) {
|
||||
cancelCapture();
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
function cancelCapture() {
|
||||
setCapturedCombos([]);
|
||||
setHeldModifiers(null);
|
||||
setCapturingBindingId(null);
|
||||
setCapturingShortcut(false);
|
||||
}
|
||||
|
||||
function startCapture(bindingId: string) {
|
||||
setCapturedCombos([]);
|
||||
setHeldModifiers(null);
|
||||
setCapturingBindingId(bindingId);
|
||||
setCapturingShortcut(true);
|
||||
}
|
||||
@@ -132,9 +157,11 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
const comboString = keyboardEventToComboString(event);
|
||||
if (comboString === null) {
|
||||
setHeldModifiers(heldModifiersFromEvent(event));
|
||||
return;
|
||||
}
|
||||
|
||||
setHeldModifiers(null);
|
||||
setCapturedCombos((current) => [...current, comboString]);
|
||||
}
|
||||
|
||||
@@ -194,6 +221,7 @@ export function KeyboardShortcutsSection() {
|
||||
overrideCombo={overrideCombo}
|
||||
isCapturing={capturingBindingId === bindingId}
|
||||
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
|
||||
heldModifiers={capturingBindingId === bindingId ? heldModifiers : null}
|
||||
onRebind={() => {
|
||||
if (bindingId) {
|
||||
startCapture(bindingId);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getDesktopDaemonLogs,
|
||||
type DesktopDaemonLogs,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
type StartupSplashScreenProps = {
|
||||
bootstrapState?: {
|
||||
@@ -26,6 +26,7 @@ const DOCS_URL = "https://paseo.sh/docs";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -140,7 +141,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||
const [logsError, setLogsError] = useState<string | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
@@ -222,7 +222,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
|
||||
if (isSimpleSplash) {
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={styles.container}>
|
||||
<TitlebarDragRegion />
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.subtitle}>Starting up…</Text>
|
||||
</View>
|
||||
@@ -231,7 +232,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
|
||||
if (!isError) {
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={styles.container}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.centeredContent}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
@@ -253,7 +255,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
|
||||
<View style={[styles.container, styles.containerError]}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.errorContent}>
|
||||
<View style={styles.errorHeader}>
|
||||
<PaseoLogo size={64} />
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
useColorScheme,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -63,6 +62,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
import {
|
||||
normalizeWorkspaceTabTarget,
|
||||
@@ -578,8 +578,7 @@ function useCloseTabs(): UseCloseTabsResult {
|
||||
function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isDarkMode = useColorScheme() === "dark";
|
||||
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
|
||||
const mainBackgroundColor = theme.colors.surfaceWorkspace;
|
||||
const toast = useToast();
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
@@ -619,7 +618,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
enabled:
|
||||
Boolean(client && isConnected) &&
|
||||
normalizedWorkspaceId.length > 0 &&
|
||||
normalizedWorkspaceId.startsWith("/"),
|
||||
isAbsolutePath(normalizedWorkspaceId),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
@@ -688,7 +687,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
|
||||
if (!client || !isConnected || !isAbsolutePath(normalizedWorkspaceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -720,7 +719,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
enabled:
|
||||
Boolean(client && isConnected) &&
|
||||
normalizedWorkspaceId.length > 0 &&
|
||||
normalizedWorkspaceId.startsWith("/"),
|
||||
isAbsolutePath(normalizedWorkspaceId),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
@@ -762,7 +761,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) {
|
||||
if (!normalizedServerId || !isAbsolutePath(normalizedWorkspaceId)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -1140,7 +1139,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
if (createTerminalMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
||||
if (!isAbsolutePath(normalizedWorkspaceId)) {
|
||||
return;
|
||||
}
|
||||
createTerminalMutation.mutate(input);
|
||||
@@ -1367,7 +1366,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
);
|
||||
|
||||
const handleCopyWorkspacePath = useCallback(async () => {
|
||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
||||
if (!isAbsolutePath(normalizedWorkspaceId)) {
|
||||
toast.error("Workspace path not available");
|
||||
return;
|
||||
}
|
||||
@@ -2058,7 +2057,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
|
||||
disabled={!normalizedWorkspaceId.startsWith("/")}
|
||||
disabled={!isAbsolutePath(normalizedWorkspaceId)}
|
||||
onSelect={handleCopyWorkspacePath}
|
||||
>
|
||||
Copy workspace path
|
||||
@@ -2329,7 +2328,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
},
|
||||
headerTitleContainer: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -112,11 +112,15 @@ const lightSemanticColors = {
|
||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
||||
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#ffffff", // Workspace main background
|
||||
|
||||
// Text
|
||||
foreground: "#09090b",
|
||||
foregroundMuted: "#71717a",
|
||||
|
||||
// Controls
|
||||
scrollbarHandle: "#3f3f46", // zinc-700
|
||||
|
||||
// Borders - shifted one step lighter
|
||||
border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
|
||||
borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines
|
||||
@@ -182,11 +186,15 @@ const darkSemanticColors = {
|
||||
surface3: "#434645", // Highest elevation
|
||||
surface4: "#595B5B", // Extra emphasis
|
||||
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
||||
|
||||
// Text
|
||||
foreground: "#fafafa",
|
||||
foregroundMuted: "#A1A5A4",
|
||||
|
||||
// Controls
|
||||
scrollbarHandle: "#71717a", // zinc-500
|
||||
|
||||
// Borders
|
||||
border: "#252B2A",
|
||||
borderAccent: "#2F3534",
|
||||
@@ -310,18 +318,60 @@ const commonTheme = {
|
||||
} as const;
|
||||
|
||||
export const darkTheme = {
|
||||
colorScheme: "dark" as const,
|
||||
colors: {
|
||||
...darkSemanticColors,
|
||||
palette: baseColors,
|
||||
},
|
||||
shadow: {
|
||||
sm: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.25)",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
md: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.20)",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
lg: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.40)",
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
},
|
||||
},
|
||||
...commonTheme,
|
||||
} as const;
|
||||
|
||||
export const lightTheme = {
|
||||
colorScheme: "light" as const,
|
||||
colors: {
|
||||
...lightSemanticColors,
|
||||
palette: baseColors,
|
||||
},
|
||||
shadow: {
|
||||
sm: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.02)",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
md: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.04)",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 16,
|
||||
elevation: 4,
|
||||
},
|
||||
lg: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.08)",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
},
|
||||
},
|
||||
...commonTheme,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
||||
|
||||
describe("readFiniteScreenPoint", () => {
|
||||
it("returns finite screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
|
||||
screenX: 1280,
|
||||
screenY: 720,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects NaN screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects infinite screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing screen coordinates", () => {
|
||||
expect(readFiniteScreenPoint(undefined)).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull();
|
||||
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
export type DesktopWindowScreenPoint = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
type ScreenPointInput =
|
||||
| {
|
||||
screenX?: unknown;
|
||||
screenY?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function isFiniteCoordinate(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
|
||||
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
screenX: input.screenX,
|
||||
screenY: input.screenY,
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isInteractiveDesktopDragTarget } from "./desktop-window";
|
||||
|
||||
function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget {
|
||||
return {
|
||||
closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null),
|
||||
} as unknown as EventTarget;
|
||||
}
|
||||
|
||||
describe("isInteractiveDesktopDragTarget", () => {
|
||||
it("treats focusable pressables as interactive drag exemptions", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: (selector) => selector.includes("[tabindex]"),
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats semantic button targets as interactive drag exemptions", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: (selector) => selector.includes("[role='button']"),
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-interactive targets", () => {
|
||||
const target = createTarget({
|
||||
matchesSelector: () => false,
|
||||
});
|
||||
|
||||
expect(isInteractiveDesktopDragTarget(target)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the target does not support closest()", () => {
|
||||
expect(isInteractiveDesktopDragTarget(null)).toBe(false);
|
||||
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
getIsElectronRuntimeMac,
|
||||
getIsElectronRuntime,
|
||||
@@ -9,120 +9,7 @@ import {
|
||||
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
||||
|
||||
export async function toggleMaximize() {
|
||||
const win = getDesktopWindow();
|
||||
if (win && typeof win.toggleMaximize === "function") {
|
||||
try {
|
||||
await win.toggleMaximize();
|
||||
} catch (error) {
|
||||
console.warn("[DesktopWindow] toggleMaximize failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual window dragging via pointer events.
|
||||
// Mirrors the Tauri implementation: single pointerdown handler with
|
||||
// double-click-to-maximize via timing, closest() for interactive check,
|
||||
// and pointer capture for move tracking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INTERACTIVE_SELECTOR =
|
||||
"button, a, input, textarea, select, " +
|
||||
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
|
||||
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
|
||||
"[role='menuitem'], [tabindex], [contenteditable='true']";
|
||||
|
||||
const DOUBLE_CLICK_MS = 300;
|
||||
|
||||
type DesktopDragViewProps = Pick<
|
||||
ViewProps,
|
||||
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
|
||||
>;
|
||||
|
||||
export function isInteractiveDesktopDragTarget(target: unknown): boolean {
|
||||
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
|
||||
if (!candidate || typeof candidate.closest !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(candidate.closest(INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
export function useDesktopDragHandlers(): DesktopDragViewProps {
|
||||
const isDragging = useRef(false);
|
||||
const lastPointerDownAt = useRef(0);
|
||||
const isActive = Platform.OS === "web" && isElectronRuntime();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
function handleBlur() {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
getDesktopWindow()?.endMove?.();
|
||||
}
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => window.removeEventListener("blur", handleBlur);
|
||||
}, [isActive]);
|
||||
|
||||
return useMemo((): DesktopDragViewProps => {
|
||||
if (!isActive) return {};
|
||||
|
||||
function stopDrag(e: RNPointerEvent) {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
|
||||
const el = e.currentTarget as unknown as Element | null;
|
||||
if (el && "releasePointerCapture" in el) {
|
||||
el.releasePointerCapture(e.nativeEvent.pointerId);
|
||||
}
|
||||
getDesktopWindow()?.endMove?.();
|
||||
}
|
||||
|
||||
return {
|
||||
onPointerDown: (e: RNPointerEvent) => {
|
||||
if (e.nativeEvent.button !== 0) return;
|
||||
|
||||
if (isInteractiveDesktopDragTarget(e.target)) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
|
||||
lastPointerDownAt.current = 0;
|
||||
void toggleMaximize();
|
||||
return;
|
||||
}
|
||||
lastPointerDownAt.current = now;
|
||||
|
||||
const win = getDesktopWindow();
|
||||
if (!win?.startMove) return;
|
||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
|
||||
if (!screenPoint) return;
|
||||
|
||||
isDragging.current = true;
|
||||
const el = e.currentTarget as unknown as Element;
|
||||
el.setPointerCapture(e.nativeEvent.pointerId);
|
||||
win.startMove(screenPoint.screenX, screenPoint.screenY);
|
||||
},
|
||||
onPointerMove: (e: RNPointerEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
|
||||
if (!screenPoint) {
|
||||
stopDrag(e);
|
||||
return;
|
||||
}
|
||||
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY);
|
||||
},
|
||||
onPointerUp: stopDrag,
|
||||
onPointerCancel: stopDrag,
|
||||
};
|
||||
}, [isActive]);
|
||||
}
|
||||
|
||||
type RawWindowControlsPadding = {
|
||||
left: number;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { isAbsolutePath } from "./path";
|
||||
|
||||
interface BuildAbsoluteExplorerPathInput {
|
||||
workspaceRoot: string;
|
||||
entryPath: string;
|
||||
}
|
||||
|
||||
function isAbsolutePath(pathValue: string): boolean {
|
||||
return (
|
||||
pathValue.startsWith("/") || pathValue.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(pathValue)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAbsoluteExplorerPath({
|
||||
workspaceRoot,
|
||||
entryPath,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAbsolutePath } from "./path";
|
||||
|
||||
export interface InlinePathTarget {
|
||||
raw: string;
|
||||
path: string;
|
||||
@@ -183,7 +185,7 @@ export function parseAssistantFileLink(
|
||||
};
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
if (!isAbsolutePath(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -195,7 +197,7 @@ export function parseAssistantFileLink(
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
|
||||
if (!normalizedPath || !normalizedPath.startsWith("/")) {
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -334,7 +336,3 @@ function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
||||
function normalizePathForCompare(value: string): string {
|
||||
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function isAbsolutePath(value: string): boolean {
|
||||
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
||||
}
|
||||
|
||||
39
packages/app/src/utils/path.test.ts
Normal file
39
packages/app/src/utils/path.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAbsolutePath } from "./path";
|
||||
|
||||
describe("isAbsolutePath", () => {
|
||||
it("returns true for Unix absolute paths", () => {
|
||||
expect(isAbsolutePath("/")).toBe(true);
|
||||
expect(isAbsolutePath("/home/user")).toBe(true);
|
||||
expect(isAbsolutePath("/tmp/file.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Windows drive letter paths", () => {
|
||||
expect(isAbsolutePath("C:\\Users")).toBe(true);
|
||||
expect(isAbsolutePath("C:/Users")).toBe(true);
|
||||
expect(isAbsolutePath("d:\\projects")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for UNC paths", () => {
|
||||
expect(isAbsolutePath("\\\\server\\share")).toBe(true);
|
||||
expect(isAbsolutePath("\\\\\\\\host\\path")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for relative paths", () => {
|
||||
expect(isAbsolutePath("foo/bar")).toBe(false);
|
||||
expect(isAbsolutePath("./relative")).toBe(false);
|
||||
expect(isAbsolutePath("../parent")).toBe(false);
|
||||
expect(isAbsolutePath("")).toBe(false);
|
||||
expect(isAbsolutePath("file.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for edge cases that are not absolute paths", () => {
|
||||
expect(isAbsolutePath("")).toBe(false);
|
||||
expect(isAbsolutePath("C:")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles mixed separators in absolute paths", () => {
|
||||
expect(isAbsolutePath("C:/Users\\mixed/path")).toBe(true);
|
||||
expect(isAbsolutePath("/tmp\\mixed/path")).toBe(true);
|
||||
});
|
||||
});
|
||||
5
packages/app/src/utils/path.ts
Normal file
5
packages/app/src/utils/path.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function isAbsolutePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value)
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"@getpaseo/relay": "0.1.41",
|
||||
"@getpaseo/server": "0.1.41",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -175,6 +175,7 @@ function checkProviderBinary(binary: string): { path: string | null; version: st
|
||||
timeout: 5000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: process.platform === "win32",
|
||||
}).trim();
|
||||
return { path: binaryPath, version: output || null };
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"@getpaseo/cli": "0.1.41",
|
||||
"@getpaseo/server": "0.1.41",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -7,6 +7,24 @@ const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
|
||||
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
|
||||
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
|
||||
const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"];
|
||||
const INTERACTIVE_SELECTOR = [
|
||||
"button",
|
||||
"a",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"[role='button']",
|
||||
"[role='link']",
|
||||
"[role='textbox']",
|
||||
"[role='combobox']",
|
||||
"[role='tab']",
|
||||
"[role='switch']",
|
||||
"[role='checkbox']",
|
||||
"[role='slider']",
|
||||
"[role='menuitem']",
|
||||
"[tabindex]",
|
||||
"[contenteditable='true']",
|
||||
].join(", ");
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
@@ -28,6 +46,313 @@ async function captureScreenshot(page, fileName) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function inspectTitlebarRegions(page) {
|
||||
return page.evaluate((interactiveSelector) => {
|
||||
const nodes = Array.from(document.querySelectorAll("*"));
|
||||
const annotationId = "electron-verify-titlebar-style";
|
||||
const existingAnnotation = document.getElementById(annotationId);
|
||||
existingAnnotation?.remove();
|
||||
|
||||
const annotationStyle = document.createElement("style");
|
||||
annotationStyle.id = annotationId;
|
||||
annotationStyle.textContent = `
|
||||
[data-electron-verify-drag="true"] {
|
||||
outline: 3px solid #ff4d4f !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
[data-electron-verify-resizer="true"] {
|
||||
outline: 3px solid #52c41a !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
[data-electron-verify-interactive="true"] {
|
||||
outline: 3px solid #1677ff !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(annotationStyle);
|
||||
|
||||
function isVisible(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.opacity !== "0"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeText(element) {
|
||||
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
}
|
||||
|
||||
function readAppRegion(element) {
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
|
||||
}
|
||||
|
||||
function rectInfo(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeElement(element) {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
text: summarizeText(element),
|
||||
appRegion: readAppRegion(element),
|
||||
position: style.position,
|
||||
zIndex: style.zIndex,
|
||||
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
|
||||
paddingTop: Number.parseFloat(style.paddingTop || "0"),
|
||||
...rectInfo(element),
|
||||
};
|
||||
}
|
||||
|
||||
function isNearTop(summary) {
|
||||
return summary.top < 220;
|
||||
}
|
||||
|
||||
function isTopResizer(element, overlayRect) {
|
||||
if (!(element instanceof HTMLElement) || !isVisible(element)) {
|
||||
return false;
|
||||
}
|
||||
const summary = summarizeElement(element);
|
||||
return (
|
||||
summary.appRegion === "no-drag" &&
|
||||
summary.position === "absolute" &&
|
||||
Math.abs(summary.height - 4) <= 1 &&
|
||||
Math.abs(summary.top - overlayRect.top) <= 2 &&
|
||||
Math.abs(summary.left - overlayRect.left) <= 2 &&
|
||||
Math.abs(summary.width - overlayRect.width) <= 2
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeInteractive(element) {
|
||||
const summary = summarizeElement(element);
|
||||
return {
|
||||
...summary,
|
||||
testId: element.getAttribute("data-testid"),
|
||||
role: element.getAttribute("role"),
|
||||
};
|
||||
}
|
||||
|
||||
const dragSummaries = [];
|
||||
const suspiciousDragHosts = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
||||
continue;
|
||||
}
|
||||
const summary = summarizeElement(node);
|
||||
if (summary.appRegion !== "drag") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = node.parentElement instanceof HTMLElement ? node.parentElement : null;
|
||||
const parentSummary = parent ? summarizeElement(parent) : null;
|
||||
const interactiveDescendants = Array.from(node.querySelectorAll(interactiveSelector))
|
||||
.filter((child) => child instanceof HTMLElement)
|
||||
.filter((child) => isVisible(child))
|
||||
.map((child) => summarizeInteractive(child));
|
||||
|
||||
const siblingResizers = parent
|
||||
? Array.from(parent.children)
|
||||
.filter((child) => child !== node)
|
||||
.filter((child) => child instanceof HTMLElement)
|
||||
.filter((child) => isTopResizer(child, summary))
|
||||
.map((child) => summarizeElement(child))
|
||||
: [];
|
||||
|
||||
const parentInteractive = parent
|
||||
? Array.from(parent.querySelectorAll(interactiveSelector))
|
||||
.filter((child) => child instanceof HTMLElement)
|
||||
.filter((child) => isVisible(child))
|
||||
.map((child) => summarizeInteractive(child))
|
||||
: [];
|
||||
const explicitNoDragInteractive = parentInteractive.filter(
|
||||
(child) => child.appRegion === "no-drag",
|
||||
);
|
||||
|
||||
const record = {
|
||||
...summary,
|
||||
parent: parentSummary,
|
||||
interactiveDescendants: interactiveDescendants.slice(0, 5),
|
||||
siblingResizers: siblingResizers.slice(0, 3),
|
||||
explicitNoDragInteractive: explicitNoDragInteractive.slice(0, 5),
|
||||
parentInteractiveCount: parentInteractive.length,
|
||||
};
|
||||
dragSummaries.push(record);
|
||||
|
||||
const looksLikeHostShortcut =
|
||||
isNearTop(summary) &&
|
||||
(summary.position !== "absolute" ||
|
||||
summary.text.length > 0 ||
|
||||
interactiveDescendants.length > 0 ||
|
||||
parentSummary?.appRegion === "drag");
|
||||
if (looksLikeHostShortcut) {
|
||||
suspiciousDragHosts.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
const verifiedRegions = dragSummaries
|
||||
.filter((entry) => isNearTop(entry))
|
||||
.filter((entry) => entry.position === "absolute")
|
||||
.filter((entry) => entry.text.length === 0)
|
||||
.filter((entry) => entry.parent?.appRegion !== "drag")
|
||||
.filter((entry) => entry.siblingResizers.length > 0)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.explicitNoDragInteractive.length - left.explicitNoDragInteractive.length ||
|
||||
left.top - right.top ||
|
||||
right.width - left.width,
|
||||
);
|
||||
|
||||
const candidate = verifiedRegions[0] ?? null;
|
||||
if (candidate) {
|
||||
const matchingDragNode = nodes.find((node) => {
|
||||
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
||||
return false;
|
||||
}
|
||||
const summary = summarizeElement(node);
|
||||
return (
|
||||
summary.appRegion === "drag" &&
|
||||
Math.abs(summary.top - candidate.top) <= 1 &&
|
||||
Math.abs(summary.left - candidate.left) <= 1 &&
|
||||
Math.abs(summary.width - candidate.width) <= 1 &&
|
||||
Math.abs(summary.height - candidate.height) <= 1
|
||||
);
|
||||
});
|
||||
if (matchingDragNode instanceof HTMLElement) {
|
||||
matchingDragNode.setAttribute("data-electron-verify-drag", "true");
|
||||
const parent = matchingDragNode.parentElement;
|
||||
if (parent instanceof HTMLElement) {
|
||||
for (const child of parent.children) {
|
||||
if (child instanceof HTMLElement && isTopResizer(child, candidate)) {
|
||||
child.setAttribute("data-electron-verify-resizer", "true");
|
||||
}
|
||||
}
|
||||
const interactiveChildren = Array.from(parent.querySelectorAll(interactiveSelector))
|
||||
.filter((child) => child instanceof HTMLElement)
|
||||
.filter((child) => isVisible(child))
|
||||
.filter((child) => summarizeElement(child).appRegion === "no-drag")
|
||||
.slice(0, 3);
|
||||
for (const child of interactiveChildren) {
|
||||
child.setAttribute("data-electron-verify-interactive", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interactiveSelector,
|
||||
dragRegionCount: dragSummaries.length,
|
||||
verifiedRegionCount: verifiedRegions.length,
|
||||
candidate,
|
||||
suspiciousDragHosts: suspiciousDragHosts.slice(0, 10),
|
||||
dragRegions: dragSummaries.slice(0, 10),
|
||||
};
|
||||
}, INTERACTIVE_SELECTOR);
|
||||
}
|
||||
|
||||
async function inspectFullscreenResizer(page) {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
let windowId = null;
|
||||
let initialBounds = null;
|
||||
let fullscreenEntered = false;
|
||||
|
||||
try {
|
||||
const windowInfo = await session.send("Browser.getWindowForTarget");
|
||||
windowId = windowInfo.windowId;
|
||||
initialBounds = await session.send("Browser.getWindowBounds", { windowId });
|
||||
await session.send("Browser.setWindowBounds", {
|
||||
windowId,
|
||||
bounds: { windowState: "fullscreen" },
|
||||
});
|
||||
fullscreenEntered = true;
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const details = await page.evaluate(async () => {
|
||||
const bridge = window.paseoDesktop?.window;
|
||||
const bridgeFullscreen =
|
||||
typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null;
|
||||
const visibleNoDragResizers = Array.from(document.querySelectorAll("*"))
|
||||
.filter((node) => node instanceof HTMLElement)
|
||||
.filter((node) => {
|
||||
const element = node;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
const appRegion =
|
||||
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.opacity !== "0" &&
|
||||
appRegion === "no-drag" &&
|
||||
style.position === "absolute" &&
|
||||
Math.abs(rect.height - 4) <= 1 &&
|
||||
rect.top < 220
|
||||
);
|
||||
})
|
||||
.map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
tagName: node.tagName.toLowerCase(),
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
bridgeFullscreen,
|
||||
visibleNoDragResizers,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
enteredFullscreen: fullscreenEntered,
|
||||
initialBounds,
|
||||
...details,
|
||||
passed:
|
||||
details.bridgeFullscreen === true &&
|
||||
Array.isArray(details.visibleNoDragResizers) &&
|
||||
details.visibleNoDragResizers.length === 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
supported: false,
|
||||
error: String(error),
|
||||
initialBounds,
|
||||
};
|
||||
} finally {
|
||||
const previousWindowState = initialBounds?.bounds?.windowState ?? "normal";
|
||||
if (windowId !== null && fullscreenEntered) {
|
||||
try {
|
||||
await session.send("Browser.setWindowBounds", {
|
||||
windowId,
|
||||
bounds: { windowState: previousWindowState },
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
} catch {
|
||||
// Best-effort restore only.
|
||||
}
|
||||
}
|
||||
await session.detach().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function findAppPage(browser) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
for (const context of browser.contexts()) {
|
||||
@@ -132,115 +457,20 @@ async function main() {
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const dragRegionCheck = await page.evaluate(() => {
|
||||
const nodes = Array.from(document.querySelectorAll("*"));
|
||||
const annotationId = "electron-verify-drag-style";
|
||||
const existingAnnotation = document.getElementById(annotationId);
|
||||
existingAnnotation?.remove();
|
||||
|
||||
const annotationStyle = document.createElement("style");
|
||||
annotationStyle.id = annotationId;
|
||||
annotationStyle.textContent = `
|
||||
[data-electron-verify-drag="true"] {
|
||||
outline: 3px solid #ff4d4f !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(annotationStyle);
|
||||
|
||||
function isVisible(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.opacity !== "0"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeText(element) {
|
||||
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
}
|
||||
|
||||
const regions = [];
|
||||
let candidate = null;
|
||||
|
||||
for (const element of nodes) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
||||
if (appRegion !== "drag" || !isVisible(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const text = summarizeText(element);
|
||||
const info = {
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
text,
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
|
||||
paddingTop: Number.parseFloat(style.paddingTop || "0"),
|
||||
};
|
||||
regions.push(info);
|
||||
|
||||
const looksLikeHeader =
|
||||
rect.top < 180 &&
|
||||
rect.height >= 40 &&
|
||||
(text.includes("Settings") || text.includes("Sessions"));
|
||||
if (!candidate && looksLikeHeader) {
|
||||
candidate = { ...info };
|
||||
element.setAttribute("data-electron-verify-drag", "true");
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate && regions.length > 0) {
|
||||
candidate = regions.slice().sort((left, right) => left.top - right.top)[0];
|
||||
|
||||
for (const element of nodes) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
||||
if (appRegion !== "drag" || !isVisible(element)) {
|
||||
continue;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
Math.abs(rect.top - candidate.top) < 1 &&
|
||||
Math.abs(rect.left - candidate.left) < 1 &&
|
||||
Math.abs(rect.width - candidate.width) < 1 &&
|
||||
Math.abs(rect.height - candidate.height) < 1
|
||||
) {
|
||||
element.setAttribute("data-electron-verify-drag", "true");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: regions.length,
|
||||
candidate,
|
||||
regions: regions.slice(0, 10),
|
||||
};
|
||||
});
|
||||
const dragRegionCheck = await inspectTitlebarRegions(page);
|
||||
|
||||
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
|
||||
const dragRegionPassed =
|
||||
dragRegionCheck.count > 0 &&
|
||||
dragRegionCheck.dragRegionCount > 0 &&
|
||||
dragRegionCheck.verifiedRegionCount > 0 &&
|
||||
Boolean(dragRegionCheck.candidate) &&
|
||||
dragRegionCheck.candidate.top < 180;
|
||||
dragRegionCheck.candidate.top < 220 &&
|
||||
dragRegionCheck.candidate.parent?.appRegion !== "drag" &&
|
||||
dragRegionCheck.candidate.siblingResizers.length > 0 &&
|
||||
dragRegionCheck.suspiciousDragHosts.length === 0;
|
||||
|
||||
results.push({
|
||||
check: "drag-regions",
|
||||
check: "titlebar-drag-structure",
|
||||
pass: dragRegionPassed,
|
||||
details: dragRegionCheck,
|
||||
screenshot: dragScreenshot,
|
||||
@@ -248,7 +478,7 @@ async function main() {
|
||||
|
||||
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
|
||||
const isMac = process.platform === "darwin";
|
||||
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
|
||||
const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null;
|
||||
const trafficLightPaddingPassed = !isMac
|
||||
? true
|
||||
: typeof observedPaddingLeft === "number" &&
|
||||
@@ -261,12 +491,36 @@ async function main() {
|
||||
details: {
|
||||
platform: process.platform,
|
||||
observedPaddingLeft,
|
||||
expectedApproximatePaddingLeft: 78,
|
||||
note: "Traffic-light padding is only validated structurally on macOS in this verifier.",
|
||||
candidate: dragRegionCheck.candidate,
|
||||
},
|
||||
screenshot: trafficLightScreenshot,
|
||||
});
|
||||
|
||||
const noDragInteractiveCheck = {
|
||||
check: "interactive-no-drag-layering",
|
||||
pass:
|
||||
Boolean(dragRegionCheck.candidate) &&
|
||||
Array.isArray(dragRegionCheck.candidate.explicitNoDragInteractive) &&
|
||||
dragRegionCheck.candidate.explicitNoDragInteractive.length > 0,
|
||||
details: {
|
||||
candidate: dragRegionCheck.candidate,
|
||||
explicitNoDragInteractive:
|
||||
dragRegionCheck.candidate?.explicitNoDragInteractive ?? [],
|
||||
},
|
||||
screenshot: dragScreenshot,
|
||||
};
|
||||
results.push(noDragInteractiveCheck);
|
||||
|
||||
const fullscreenDetails = await inspectFullscreenResizer(page);
|
||||
const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png");
|
||||
results.push({
|
||||
check: "fullscreen-resizer",
|
||||
pass: fullscreenDetails.supported ? fullscreenDetails.passed : true,
|
||||
details: fullscreenDetails,
|
||||
screenshot: fullscreenScreenshot,
|
||||
});
|
||||
|
||||
const daemonManagementVisible = await Promise.all([
|
||||
page.getByText("Built-in daemon", { exact: true }).isVisible(),
|
||||
page.getByText("Daemon management", { exact: true }).isVisible(),
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||
import {
|
||||
registerWindowManager,
|
||||
getTitleBarOverlayOptions,
|
||||
getMainWindowChromeOptions,
|
||||
getWindowBackgroundColor,
|
||||
resolveSystemWindowTheme,
|
||||
setupWindowResizeEvents,
|
||||
setupDefaultContextMenu,
|
||||
setupDragDropPrevention,
|
||||
} from "./window/window-manager.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
@@ -86,21 +88,19 @@ function applyAppIcon(): void {
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
const isMac = process.platform === "darwin";
|
||||
const iconPath = getWindowIconPath();
|
||||
const systemTheme = resolveSystemWindowTheme();
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(systemTheme),
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
titleBarStyle: "hidden",
|
||||
...(isMac
|
||||
? { trafficLightPosition: { x: 16, y: 14 } }
|
||||
: {
|
||||
titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()),
|
||||
autoHideMenuBar: true,
|
||||
}),
|
||||
...getMainWindowChromeOptions({
|
||||
platform: process.platform,
|
||||
theme: systemTheme,
|
||||
}),
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
@@ -109,6 +109,7 @@ async function createMainWindow(): Promise<void> {
|
||||
});
|
||||
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
|
||||
@@ -19,15 +19,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
},
|
||||
window: {
|
||||
getCurrentWindow: () => ({
|
||||
startMove: (screenX: number, screenY: number) =>
|
||||
ipcRenderer.send("paseo:window:startMove", { screenX, screenY }),
|
||||
moving: (screenX: number, screenY: number) =>
|
||||
ipcRenderer.send("paseo:window:moving", { screenX, screenY }),
|
||||
endMove: () => ipcRenderer.send("paseo:window:endMove"),
|
||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||
setTitleBarTheme: (theme: "light" | "dark") =>
|
||||
ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme),
|
||||
updateWindowControls: (update: {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
}) => ipcRenderer.invoke("paseo:window:updateWindowControls", update),
|
||||
onResized: (handler: EventHandler): (() => void) => {
|
||||
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
||||
handler(payload);
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createWindowMoveState,
|
||||
readWindowMovePayload,
|
||||
resolveWindowMovePosition,
|
||||
} from "./window-drag-coordinates";
|
||||
|
||||
describe("window-drag-coordinates", () => {
|
||||
describe("readWindowMovePayload", () => {
|
||||
it("returns finite screen coordinates", () => {
|
||||
expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({
|
||||
screenX: 1500,
|
||||
screenY: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite screen coordinates", () => {
|
||||
expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull();
|
||||
expect(
|
||||
readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWindowMoveState", () => {
|
||||
it("derives finite offsets from the window position", () => {
|
||||
expect(
|
||||
createWindowMoveState({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
windowX: 1200,
|
||||
windowY: 700,
|
||||
}),
|
||||
).toEqual({
|
||||
offsetX: 300,
|
||||
offsetY: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite offsets", () => {
|
||||
expect(
|
||||
createWindowMoveState({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
windowX: Number.NaN,
|
||||
windowY: 700,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWindowMovePosition", () => {
|
||||
it("rounds the next window position", () => {
|
||||
expect(
|
||||
resolveWindowMovePosition({
|
||||
payload: { screenX: 1501.4, screenY: 902.6 },
|
||||
state: { offsetX: 300, offsetY: 200 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 1201,
|
||||
y: 703,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite next positions", () => {
|
||||
expect(
|
||||
resolveWindowMovePosition({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
state: { offsetX: Number.NaN, offsetY: 200 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
export type WindowMovePayload = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
export type WindowMoveState = {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
export type WindowPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type WindowMovePayloadInput =
|
||||
| {
|
||||
screenX?: unknown;
|
||||
screenY?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function isFiniteCoordinate(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null {
|
||||
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
screenX: input.screenX,
|
||||
screenY: input.screenY,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWindowMoveState(input: {
|
||||
payload: WindowMovePayload;
|
||||
windowX: number;
|
||||
windowY: number;
|
||||
}): WindowMoveState | null {
|
||||
const offsetX = input.payload.screenX - input.windowX;
|
||||
const offsetY = input.payload.screenY - input.windowY;
|
||||
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { offsetX, offsetY };
|
||||
}
|
||||
|
||||
export function resolveWindowMovePosition(input: {
|
||||
payload: WindowMovePayload;
|
||||
state: WindowMoveState;
|
||||
}): WindowPosition | null {
|
||||
const x = Math.round(input.payload.screenX - input.state.offsetX);
|
||||
const y = Math.round(input.payload.screenY - input.state.offsetY);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getTitleBarOverlayOptions, readBadgeCount, readWindowTheme } from "./window-manager";
|
||||
import {
|
||||
applyWindowControlsOverlayUpdate,
|
||||
createWindowControlsOverlayState,
|
||||
getMainWindowChromeOptions,
|
||||
getTitleBarOverlayOptions,
|
||||
readBadgeCount,
|
||||
readWindowControlsOverlayUpdate,
|
||||
readWindowTheme,
|
||||
resolveRuntimeTitleBarOverlayOptions,
|
||||
} from "./window-manager";
|
||||
|
||||
describe("window-manager", () => {
|
||||
describe("readBadgeCount", () => {
|
||||
@@ -39,7 +48,7 @@ describe("window-manager", () => {
|
||||
expect(getTitleBarOverlayOptions("light")).toEqual({
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 48,
|
||||
height: 29,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +56,133 @@ describe("window-manager", () => {
|
||||
expect(getTitleBarOverlayOptions("dark")).toEqual({
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 29,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readWindowControlsOverlayUpdate", () => {
|
||||
it("accepts partial runtime overlay updates", () => {
|
||||
expect(
|
||||
readWindowControlsOverlayUpdate({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
}),
|
||||
).toEqual({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty and invalid payloads", () => {
|
||||
expect(readWindowControlsOverlayUpdate(undefined)).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({})).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({ height: 0 })).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({ backgroundColor: 12 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRuntimeTitleBarOverlayOptions", () => {
|
||||
it("applies the VS Code height minus border adjustment", () => {
|
||||
expect(
|
||||
resolveRuntimeTitleBarOverlayOptions({
|
||||
height: 48,
|
||||
backgroundColor: "#ffffff",
|
||||
foregroundColor: "#09090b",
|
||||
}),
|
||||
).toEqual({
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 47,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyWindowControlsOverlayUpdate", () => {
|
||||
it("merges cached colors with later runtime height updates", () => {
|
||||
const setTitleBarOverlay = vi.fn();
|
||||
let state = createWindowControlsOverlayState("dark");
|
||||
|
||||
state = applyWindowControlsOverlayUpdate({
|
||||
win: { setTitleBarOverlay },
|
||||
current: state,
|
||||
update: {
|
||||
backgroundColor: "#18181c",
|
||||
foregroundColor: "#e4e4e7",
|
||||
},
|
||||
});
|
||||
|
||||
state = applyWindowControlsOverlayUpdate({
|
||||
win: { setTitleBarOverlay },
|
||||
current: state,
|
||||
update: { height: 48 },
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
foregroundColor: "#e4e4e7",
|
||||
});
|
||||
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(1, {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 28,
|
||||
});
|
||||
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(2, {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 47,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMainWindowChromeOptions", () => {
|
||||
it("uses frameless hidden title bars with overlay on windows", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "win32",
|
||||
theme: "dark",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarOverlay: {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 29,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses frameless hidden title bars with overlay on linux", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "linux",
|
||||
theme: "light",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarOverlay: {
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 29,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the mac traffic-light path separate", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "darwin",
|
||||
theme: "dark",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 14 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme } from "electron";
|
||||
import {
|
||||
createWindowMoveState,
|
||||
readWindowMovePayload,
|
||||
resolveWindowMovePosition,
|
||||
type WindowMoveState,
|
||||
} from "./window-drag-coordinates.js";
|
||||
import { app, BrowserWindow, Menu, ipcMain, nativeTheme } from "electron";
|
||||
|
||||
export function readBadgeCount(input: unknown): number {
|
||||
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
|
||||
@@ -15,6 +9,17 @@ export function readBadgeCount(input: unknown): number {
|
||||
}
|
||||
|
||||
export type WindowTheme = "light" | "dark";
|
||||
export type WindowControlsOverlayUpdate = {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
};
|
||||
|
||||
export type WindowControlsOverlayState = {
|
||||
height: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
};
|
||||
|
||||
export function readWindowTheme(input: unknown): WindowTheme | null {
|
||||
if (input === "light" || input === "dark") {
|
||||
@@ -28,71 +33,115 @@ export function resolveSystemWindowTheme(): WindowTheme {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function getWindowBackgroundColor(theme: WindowTheme): string {
|
||||
return theme === "dark" ? "#181B1A" : "#ffffff";
|
||||
}
|
||||
|
||||
export function createWindowControlsOverlayState(theme: WindowTheme): WindowControlsOverlayState {
|
||||
const overlay = getTitleBarOverlayOptions(theme);
|
||||
return {
|
||||
height: overlay.height ?? 29,
|
||||
backgroundColor: overlay.color,
|
||||
foregroundColor: overlay.symbolColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
|
||||
if (theme === "dark") {
|
||||
return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 };
|
||||
return { color: "#181B1A", symbolColor: "#e4e4e7", height: 29 };
|
||||
}
|
||||
|
||||
return { color: "#ffffff", symbolColor: "#09090b", height: 48 };
|
||||
return { color: "#ffffff", symbolColor: "#09090b", height: 29 };
|
||||
}
|
||||
|
||||
export function getMainWindowChromeOptions(input: {
|
||||
platform: NodeJS.Platform;
|
||||
theme: WindowTheme;
|
||||
}): Pick<
|
||||
Electron.BrowserWindowConstructorOptions,
|
||||
"titleBarStyle" | "trafficLightPosition" | "frame" | "titleBarOverlay" | "autoHideMenuBar"
|
||||
> {
|
||||
if (input.platform === "darwin") {
|
||||
return {
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 14 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
titleBarOverlay: getTitleBarOverlayOptions(input.theme),
|
||||
autoHideMenuBar: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readFiniteOverlayHeight(input: unknown): number | null {
|
||||
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rounded = Math.round(input);
|
||||
return rounded >= 1 ? rounded : null;
|
||||
}
|
||||
|
||||
function readOverlayColor(input: unknown): string | null {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export function readWindowControlsOverlayUpdate(input: unknown): WindowControlsOverlayUpdate | null {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = input as Record<string, unknown>;
|
||||
const height = readFiniteOverlayHeight(candidate.height);
|
||||
const backgroundColor = readOverlayColor(candidate.backgroundColor);
|
||||
const foregroundColor = readOverlayColor(candidate.foregroundColor);
|
||||
|
||||
if (height === null && backgroundColor === null && foregroundColor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(height !== null ? { height } : {}),
|
||||
...(backgroundColor !== null ? { backgroundColor } : {}),
|
||||
...(foregroundColor !== null ? { foregroundColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeTitleBarOverlayOptions(
|
||||
state: WindowControlsOverlayState,
|
||||
): Electron.TitleBarOverlayOptions {
|
||||
return {
|
||||
color: state.backgroundColor?.trim() === "" ? undefined : state.backgroundColor,
|
||||
symbolColor: state.foregroundColor?.trim() === "" ? undefined : state.foregroundColor,
|
||||
height: Math.max(0, state.height - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyWindowControlsOverlayUpdate(input: {
|
||||
win: Pick<BrowserWindow, "setTitleBarOverlay">;
|
||||
current: WindowControlsOverlayState;
|
||||
update: WindowControlsOverlayUpdate;
|
||||
}): WindowControlsOverlayState {
|
||||
const next: WindowControlsOverlayState = {
|
||||
height: input.update.height ?? input.current.height,
|
||||
backgroundColor: input.update.backgroundColor ?? input.current.backgroundColor,
|
||||
foregroundColor: input.update.foregroundColor ?? input.current.foregroundColor,
|
||||
};
|
||||
|
||||
input.win.setTitleBarOverlay(resolveRuntimeTitleBarOverlayOptions(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWindowManager(): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
|
||||
// State is keyed per window id to support multiple windows.
|
||||
// ---------------------------------------------------------------------------
|
||||
const moveStates = new Map<number, WindowMoveState>();
|
||||
|
||||
ipcMain.on("paseo:window:startMove", (event, payload: { screenX: number; screenY: number }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const movePayload = readWindowMovePayload(payload);
|
||||
if (!movePayload) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
const [winX, winY] = win.getPosition();
|
||||
const moveState = createWindowMoveState({
|
||||
payload: movePayload,
|
||||
windowX: winX,
|
||||
windowY: winY,
|
||||
});
|
||||
if (!moveState) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
moveStates.set(win.id, moveState);
|
||||
});
|
||||
|
||||
ipcMain.on("paseo:window:moving", (event, payload: { screenX: number; screenY: number }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const state = moveStates.get(win.id);
|
||||
if (!state) return;
|
||||
const movePayload = readWindowMovePayload(payload);
|
||||
if (!movePayload) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
const nextPosition = resolveWindowMovePosition({
|
||||
payload: movePayload,
|
||||
state,
|
||||
});
|
||||
if (!nextPosition) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
win.setPosition(nextPosition.x, nextPosition.y);
|
||||
});
|
||||
|
||||
ipcMain.on("paseo:window:endMove", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win) moveStates.delete(win.id);
|
||||
});
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
win.on("closed", () => moveStates.delete(win.id));
|
||||
});
|
||||
const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>();
|
||||
|
||||
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
@@ -124,18 +173,33 @@ export function registerWindowManager(): void {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:window:setTitleBarTheme", (event, theme?: unknown) => {
|
||||
ipcMain.handle("paseo:window:updateWindowControls", (event, update?: unknown) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win || process.platform === "darwin") {
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTheme = readWindowTheme(theme);
|
||||
if (!nextTheme) {
|
||||
const nextUpdate = readWindowControlsOverlayUpdate(update);
|
||||
if (!nextUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.setTitleBarOverlay(getTitleBarOverlayOptions(nextTheme));
|
||||
if (nextUpdate.backgroundColor) {
|
||||
win.setBackgroundColor(nextUpdate.backgroundColor);
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const current =
|
||||
overlayStateByWindow.get(win) ?? createWindowControlsOverlayState(resolveSystemWindowTheme());
|
||||
const nextState = applyWindowControlsOverlayUpdate({
|
||||
win,
|
||||
current,
|
||||
update: nextUpdate,
|
||||
});
|
||||
overlayStateByWindow.set(win, nextState);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +217,18 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function setupDefaultContextMenu(win: BrowserWindow): void {
|
||||
win.webContents.on("context-menu", (_event, params) => {
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ role: "copy", enabled: params.selectionText.length > 0 },
|
||||
{ role: "paste" },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll" },
|
||||
]);
|
||||
menu.popup({ window: win });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent Electron from navigating to files dragged onto the window.
|
||||
* The renderer handles drag-drop via standard HTML5 APIs instead.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -63,8 +63,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.40",
|
||||
"@getpaseo/relay": "0.1.40",
|
||||
"@getpaseo/highlight": "0.1.41",
|
||||
"@getpaseo/relay": "0.1.41",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import { isLikelyExternalToolName } from "./tool-name-normalization.js";
|
||||
import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
|
||||
|
||||
const DEFAULT_MAX_ITEMS = 40;
|
||||
const DEFAULT_MAX_ITEMS = 0;
|
||||
const MAX_TOOL_INPUT_CHARS = 400;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 200;
|
||||
|
||||
|
||||
@@ -168,6 +168,24 @@ describe("findExecutable", () => {
|
||||
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
|
||||
});
|
||||
|
||||
test("on Windows, preserves the first where.exe match", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
process.env.Path = "C:\\Windows\\System32";
|
||||
findExecutableDependencies.execFileSync.mockImplementation(
|
||||
((command: string) => {
|
||||
if (command === "powershell") {
|
||||
return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n";
|
||||
}
|
||||
if (command === "where.exe") {
|
||||
return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n";
|
||||
}
|
||||
throw new Error(`unexpected command ${command}`);
|
||||
}) as any,
|
||||
);
|
||||
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("uses the last line from login-shell which output", () => {
|
||||
findExecutableDependencies.shell = "/bin/zsh";
|
||||
findExecutableDependencies.execSync.mockReturnValue(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import path from "node:path";
|
||||
import { shellEnvSync } from "shell-env";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -106,7 +107,7 @@ function resolveExecutableFromWhichOutput(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!candidate.startsWith("/")) {
|
||||
if (!path.isAbsolute(candidate)) {
|
||||
console.warn(
|
||||
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
|
||||
);
|
||||
@@ -187,7 +188,10 @@ export function applyProviderEnv(
|
||||
* user opened a terminal and typed the command. If that fails (e.g. the login
|
||||
* shell itself errors) we fall back to a plain `which`.
|
||||
*
|
||||
* On Windows the system PATH is always available, so `where.exe` is sufficient.
|
||||
* On Windows we augment the daemon PATH with machine/user registry PATH values
|
||||
* and return the first `where.exe` match. Launch-time execution decides whether
|
||||
* the resolved path needs `cmd.exe` semantics (for example npm shims under
|
||||
* nvm4w such as `C:\nvm4w\nodejs\codex`).
|
||||
*/
|
||||
export function findExecutable(
|
||||
name: string,
|
||||
@@ -228,8 +232,12 @@ export function findExecutable(
|
||||
Path: resolvedPath,
|
||||
};
|
||||
const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim();
|
||||
const firstLine = out.split(/\r?\n/)[0]?.trim();
|
||||
return firstLine || null;
|
||||
return (
|
||||
out
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
...applyProviderEnv(spawnOptions.env, runtimeSettings),
|
||||
...(launchEnv ?? {}),
|
||||
},
|
||||
shell: process.platform === "win32",
|
||||
signal: spawnOptions.signal,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -3421,6 +3421,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
);
|
||||
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
||||
detached: process.platform !== "win32",
|
||||
shell: process.platform === "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
|
||||
});
|
||||
|
||||
@@ -336,6 +336,7 @@ export class OpenCodeServerManager {
|
||||
launchPrefix.command,
|
||||
[...launchPrefix.args, "serve", "--port", String(this.port)],
|
||||
{
|
||||
shell: process.platform === "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: applyProviderEnv(process.env, this.runtimeSettings),
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
createTerminal,
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
|
||||
resolveDefaultTerminalShell,
|
||||
type TerminalSession,
|
||||
} from "./terminal.js";
|
||||
import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
@@ -111,6 +112,18 @@ describe("Terminal", () => {
|
||||
expect(statSync(helperPath).mode & 0o111).toBe(0o111);
|
||||
});
|
||||
|
||||
it("uses cmd.exe-compatible default shell on Windows", () => {
|
||||
expect(resolveDefaultTerminalShell({ platform: "win32", env: {} })).toBe(
|
||||
"C:\\Windows\\System32\\cmd.exe",
|
||||
);
|
||||
expect(
|
||||
resolveDefaultTerminalShell({
|
||||
platform: "win32",
|
||||
env: { ComSpec: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" },
|
||||
}),
|
||||
).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
|
||||
});
|
||||
|
||||
it("creates a terminal session with an id, name, and cwd", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
|
||||
@@ -119,6 +119,22 @@ export function ensureNodePtySpawnHelperExecutableForCurrentPlatform(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDefaultTerminalShell(
|
||||
options: {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {},
|
||||
): string {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const env = options.env ?? process.env;
|
||||
|
||||
if (platform === "win32") {
|
||||
return env.ComSpec || env.COMSPEC || "C:\\Windows\\System32\\cmd.exe";
|
||||
}
|
||||
|
||||
return env.SHELL || "/bin/sh";
|
||||
}
|
||||
|
||||
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
|
||||
const buffer = terminal.buffer.active;
|
||||
const line = buffer.getLine(row);
|
||||
@@ -299,12 +315,13 @@ export function captureTerminalLines(
|
||||
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
const {
|
||||
cwd,
|
||||
shell = process.env.SHELL || "/bin/sh",
|
||||
shell,
|
||||
env = {},
|
||||
rows = 24,
|
||||
cols = 80,
|
||||
name = "Terminal",
|
||||
} = options;
|
||||
const resolvedShell = shell ?? resolveDefaultTerminalShell();
|
||||
|
||||
const id = randomUUID();
|
||||
const listeners = new Set<(msg: ServerMessage) => void>();
|
||||
@@ -324,7 +341,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
|
||||
|
||||
// Create PTY
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
const ptyProcess = pty.spawn(resolvedShell, [], {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
49
scripts/emit-release-env.mjs
Normal file
49
scripts/emit-release-env.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getReleaseInfoFromSourceTag } from "./release-version-utils.mjs";
|
||||
|
||||
function usageAndExit(code = 1) {
|
||||
process.stderr.write(`Usage: node scripts/emit-release-env.mjs --source-tag <tag>\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let sourceTag = "";
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--source-tag") {
|
||||
sourceTag = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
usageAndExit(0);
|
||||
}
|
||||
usageAndExit();
|
||||
}
|
||||
|
||||
if (!sourceTag) {
|
||||
usageAndExit();
|
||||
}
|
||||
|
||||
return sourceTag;
|
||||
}
|
||||
|
||||
const sourceTag = parseArgs(process.argv.slice(2));
|
||||
const info = getReleaseInfoFromSourceTag(sourceTag);
|
||||
|
||||
const entries = [
|
||||
["SOURCE_TAG", info.sourceTag],
|
||||
["RELEASE_TAG", info.releaseTag],
|
||||
["RELEASE_VERSION", info.version],
|
||||
["RELEASE_BASE_VERSION", info.baseVersion],
|
||||
["RELEASE_PRERELEASE", info.prerelease ?? ""],
|
||||
["IS_PRERELEASE", info.isPrerelease ? "true" : "false"],
|
||||
["IS_RELEASE_CANDIDATE", info.isReleaseCandidate ? "true" : "false"],
|
||||
["RELEASE_TYPE", info.releaseType],
|
||||
["DESKTOP_VERSION", info.version],
|
||||
["IS_SMOKE_TAG", info.isSmokeTag ? "true" : "false"],
|
||||
];
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
process.stdout.write(`${key}=${value}\n`);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const rootPackagePath = path.join(rootDir, "package.json");
|
||||
|
||||
function usageAndExit(code = 0) {
|
||||
process.stderr.write(`Usage: node scripts/finalize-current-release.mjs\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function run(cmd, args) {
|
||||
execFileSync(cmd, args, { cwd: rootDir, stdio: "inherit" });
|
||||
}
|
||||
|
||||
function runQuiet(cmd, args) {
|
||||
return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function parseRepoSlug(remoteUrl) {
|
||||
const trimmed = remoteUrl.trim().replace(/\.git$/, "");
|
||||
const httpsMatch = trimmed.match(/github\.com[/:]([^/]+\/[^/]+)$/);
|
||||
if (httpsMatch) {
|
||||
return httpsMatch[1];
|
||||
}
|
||||
throw new Error(`Could not determine GitHub repo from origin URL: ${remoteUrl}`);
|
||||
}
|
||||
|
||||
if (process.argv.length > 2) {
|
||||
const arg = process.argv[2];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
usageAndExit(0);
|
||||
}
|
||||
usageAndExit(1);
|
||||
}
|
||||
|
||||
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8"));
|
||||
const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : "";
|
||||
if (!version) {
|
||||
throw new Error('Root package.json must contain a valid "version"');
|
||||
}
|
||||
|
||||
const tag = `v${version}`;
|
||||
const repo = parseRepoSlug(runQuiet("git", ["remote", "get-url", "origin"]));
|
||||
const releaseJson = runQuiet("gh", [
|
||||
"release",
|
||||
"view",
|
||||
tag,
|
||||
"--repo",
|
||||
repo,
|
||||
"--json",
|
||||
"isDraft,tagName,url",
|
||||
]);
|
||||
const release = JSON.parse(releaseJson);
|
||||
|
||||
if (!release.isDraft) {
|
||||
throw new Error(`Release ${tag} is already published. Refusing to finalize a non-draft release.`);
|
||||
}
|
||||
|
||||
run("npm", ["run", "release:publish"]);
|
||||
run("gh", ["release", "edit", tag, "--repo", repo, "--draft=false"]);
|
||||
|
||||
console.log(`Finalized release ${tag}: npm packages published and GitHub draft release promoted.`);
|
||||
@@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, "..");
|
||||
const rootPackagePath = path.join(rootDir, "package.json");
|
||||
|
||||
function usageAndExit(code = 0) {
|
||||
process.stderr.write(`Usage: node scripts/push-current-release-tag.mjs [--draft-release]\n`);
|
||||
process.stderr.write(`Usage: node scripts/push-current-release-tag.mjs [--branch <name>]\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,32 @@ function runQuiet(cmd, args) {
|
||||
return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { draftRelease: false };
|
||||
function getRemoteTagCommit(tag) {
|
||||
try {
|
||||
return runQuiet("git", ["ls-remote", "--exit-code", "--refs", "origin", `refs/tags/${tag}^{}`])
|
||||
.split(/\s+/)[0]
|
||||
.trim();
|
||||
} catch {
|
||||
try {
|
||||
return runQuiet("git", ["ls-remote", "--exit-code", "--refs", "origin", `refs/tags/${tag}`])
|
||||
.split(/\s+/)[0]
|
||||
.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === "--draft-release") {
|
||||
args.draftRelease = true;
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
branch: "",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--branch") {
|
||||
args.branch = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
@@ -37,22 +57,13 @@ function parseArgs(argv) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function parseRepoSlug(remoteUrl) {
|
||||
const trimmed = remoteUrl.trim().replace(/\.git$/, "");
|
||||
const httpsMatch = trimmed.match(/github\.com[/:]([^/]+\/[^/]+)$/);
|
||||
if (httpsMatch) {
|
||||
return httpsMatch[1];
|
||||
}
|
||||
throw new Error(`Could not determine GitHub repo from origin URL: ${remoteUrl}`);
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8"));
|
||||
const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : "";
|
||||
if (!version) {
|
||||
throw new Error('Root package.json must contain a valid "version"');
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const tag = `v${version}`;
|
||||
const headCommit = runQuiet("git", ["rev-parse", "HEAD"]);
|
||||
|
||||
@@ -71,33 +82,25 @@ if (localTagCommit !== headCommit) {
|
||||
);
|
||||
}
|
||||
|
||||
run("git", ["push", "origin", "HEAD"]);
|
||||
|
||||
let remoteTagExists = false;
|
||||
try {
|
||||
runQuiet("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`]);
|
||||
remoteTagExists = true;
|
||||
} catch {
|
||||
remoteTagExists = false;
|
||||
const currentBranch = runQuiet("git", ["branch", "--show-current"]);
|
||||
const branchRef = args.branch || currentBranch;
|
||||
if (!branchRef) {
|
||||
throw new Error("Cannot determine branch to push. Pass --branch <name>.");
|
||||
}
|
||||
run("git", ["push", "origin", `HEAD:${branchRef}`]);
|
||||
|
||||
if (remoteTagExists) {
|
||||
const remoteTagCommit = getRemoteTagCommit(tag);
|
||||
|
||||
if (remoteTagCommit) {
|
||||
if (remoteTagCommit !== localTagCommit) {
|
||||
throw new Error(
|
||||
`Remote tag ${tag} points to ${remoteTagCommit}, but local tag points to ${localTagCommit}. ` +
|
||||
"Refusing to reuse an existing release tag for a different commit.",
|
||||
);
|
||||
}
|
||||
console.log(`Tag ${tag} already exists on origin`);
|
||||
} else {
|
||||
run("git", ["push", "origin", tag]);
|
||||
}
|
||||
|
||||
if (args.draftRelease) {
|
||||
const repo = parseRepoSlug(runQuiet("git", ["remote", "get-url", "origin"]));
|
||||
run("node", [
|
||||
path.join("scripts", "sync-release-notes-from-changelog.mjs"),
|
||||
"--repo",
|
||||
repo,
|
||||
"--tag",
|
||||
tag,
|
||||
"--create-if-missing",
|
||||
"--draft",
|
||||
]);
|
||||
}
|
||||
|
||||
console.log(`Release push complete: branch HEAD and tag ${tag}`);
|
||||
|
||||
166
scripts/release-version-utils.mjs
Normal file
166
scripts/release-version-utils.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
const versionPattern = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z.-]+))?$/;
|
||||
const sourceTagPattern =
|
||||
/^(?:(?:desktop(?:-(?:windows|linux|macos))?|android)-)?v(?<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
|
||||
|
||||
function assertInteger(value, label) {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(version) {
|
||||
const trimmed = version.trim();
|
||||
const match = trimmed.match(versionPattern);
|
||||
if (!match?.groups) {
|
||||
throw new Error(
|
||||
`Unsupported release version "${version}". Expected semver like 0.1.41 or 0.1.41-rc.1.`,
|
||||
);
|
||||
}
|
||||
|
||||
const major = Number.parseInt(match.groups.major, 10);
|
||||
const minor = Number.parseInt(match.groups.minor, 10);
|
||||
const patch = Number.parseInt(match.groups.patch, 10);
|
||||
const prerelease = match.groups.prerelease ?? null;
|
||||
const rcMatch = prerelease?.match(/^rc\.(?<rc>\d+)$/) ?? null;
|
||||
const rcNumber = rcMatch?.groups?.rc ? Number.parseInt(rcMatch.groups.rc, 10) : null;
|
||||
|
||||
assertInteger(major, "major version");
|
||||
assertInteger(minor, "minor version");
|
||||
assertInteger(patch, "patch version");
|
||||
if (rcNumber !== null) {
|
||||
assertInteger(rcNumber, "release candidate number");
|
||||
}
|
||||
|
||||
return {
|
||||
version: trimmed,
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
prerelease,
|
||||
baseVersion: `${major}.${minor}.${patch}`,
|
||||
isPrerelease: prerelease !== null,
|
||||
isReleaseCandidate: rcNumber !== null,
|
||||
rcNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatReleaseVersion({ major, minor, patch, prerelease = null }) {
|
||||
assertInteger(major, "major version");
|
||||
assertInteger(minor, "minor version");
|
||||
assertInteger(patch, "patch version");
|
||||
return prerelease ? `${major}.${minor}.${patch}-${prerelease}` : `${major}.${minor}.${patch}`;
|
||||
}
|
||||
|
||||
export function normalizeReleaseTag(rawTag) {
|
||||
const trimmed = rawTag.trim().replace(/^refs\/tags\//, "");
|
||||
const match = trimmed.match(sourceTagPattern);
|
||||
if (!match?.groups?.version) {
|
||||
throw new Error(
|
||||
`Unsupported release tag "${rawTag}". Expected vX.Y.Z, vX.Y.Z-rc.N, desktop-v..., or android-v...`,
|
||||
);
|
||||
}
|
||||
return `v${match.groups.version}`;
|
||||
}
|
||||
|
||||
export function getReleaseInfoFromSourceTag(sourceTag) {
|
||||
const releaseTag = normalizeReleaseTag(sourceTag);
|
||||
const parsed = parseReleaseVersion(releaseTag.slice(1));
|
||||
return {
|
||||
sourceTag,
|
||||
releaseTag,
|
||||
version: parsed.version,
|
||||
baseVersion: parsed.baseVersion,
|
||||
prerelease: parsed.prerelease,
|
||||
isPrerelease: parsed.isPrerelease,
|
||||
isReleaseCandidate: parsed.isReleaseCandidate,
|
||||
rcNumber: parsed.rcNumber,
|
||||
releaseType: parsed.isPrerelease ? "prerelease" : "release",
|
||||
isSmokeTag: sourceTag.includes("gha-smoke"),
|
||||
};
|
||||
}
|
||||
|
||||
export function computeNextReleaseVersion(currentVersion, mode) {
|
||||
const parsed = parseReleaseVersion(currentVersion);
|
||||
|
||||
if (mode === "patch" || mode === "minor" || mode === "major") {
|
||||
if (parsed.isPrerelease) {
|
||||
throw new Error(
|
||||
`Cannot cut a stable ${mode} release from prerelease version ${currentVersion}. Promote it first.`,
|
||||
);
|
||||
}
|
||||
if (mode === "patch") {
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major,
|
||||
minor: parsed.minor,
|
||||
patch: parsed.patch + 1,
|
||||
});
|
||||
}
|
||||
if (mode === "minor") {
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major,
|
||||
minor: parsed.minor + 1,
|
||||
patch: 0,
|
||||
});
|
||||
}
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major + 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "rc-patch" || mode === "rc-minor" || mode === "rc-major") {
|
||||
if (parsed.isPrerelease) {
|
||||
throw new Error(
|
||||
`Cannot start a new RC line from prerelease version ${currentVersion}. Use rc-next or promote.`,
|
||||
);
|
||||
}
|
||||
if (mode === "rc-patch") {
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major,
|
||||
minor: parsed.minor,
|
||||
patch: parsed.patch + 1,
|
||||
prerelease: "rc.1",
|
||||
});
|
||||
}
|
||||
if (mode === "rc-minor") {
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major,
|
||||
minor: parsed.minor + 1,
|
||||
patch: 0,
|
||||
prerelease: "rc.1",
|
||||
});
|
||||
}
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major + 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: "rc.1",
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "rc-next") {
|
||||
if (!parsed.isReleaseCandidate || parsed.rcNumber === null) {
|
||||
throw new Error(
|
||||
`Cannot advance RC number from ${currentVersion}. Expected a version like 0.1.41-rc.1.`,
|
||||
);
|
||||
}
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major,
|
||||
minor: parsed.minor,
|
||||
patch: parsed.patch,
|
||||
prerelease: `rc.${parsed.rcNumber + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "promote") {
|
||||
if (!parsed.isReleaseCandidate) {
|
||||
throw new Error(
|
||||
`Cannot promote ${currentVersion}. Expected a release candidate version like 0.1.41-rc.1.`,
|
||||
);
|
||||
}
|
||||
return parsed.baseVersion;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported release mode "${mode}".`);
|
||||
}
|
||||
74
scripts/set-release-version.mjs
Normal file
74
scripts/set-release-version.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeNextReleaseVersion } from "./release-version-utils.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const rootPackagePath = path.join(rootDir, "package.json");
|
||||
|
||||
function usageAndExit(code = 1) {
|
||||
process.stderr.write(`Usage: node scripts/set-release-version.mjs --mode <mode> [--print]\n`);
|
||||
process.stderr.write(
|
||||
"Modes: patch, minor, major, rc-patch, rc-minor, rc-major, rc-next, promote\n",
|
||||
);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
mode: "",
|
||||
print: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--mode") {
|
||||
args.mode = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--print") {
|
||||
args.print = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
usageAndExit(0);
|
||||
}
|
||||
usageAndExit();
|
||||
}
|
||||
|
||||
if (!args.mode) {
|
||||
usageAndExit();
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8"));
|
||||
const currentVersion = typeof rootPackage.version === "string" ? rootPackage.version.trim() : "";
|
||||
|
||||
if (!currentVersion) {
|
||||
throw new Error('Root package.json must contain a valid "version".');
|
||||
}
|
||||
|
||||
const nextVersion = computeNextReleaseVersion(currentVersion, args.mode);
|
||||
|
||||
if (args.print) {
|
||||
process.stdout.write(`${nextVersion}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
execFileSync(
|
||||
"npm",
|
||||
[
|
||||
"version",
|
||||
nextVersion,
|
||||
"--include-workspace-root",
|
||||
"--message",
|
||||
"chore(release): cut %s",
|
||||
],
|
||||
{ cwd: rootDir, stdio: "inherit" },
|
||||
);
|
||||
@@ -2,6 +2,11 @@ import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
getReleaseInfoFromSourceTag,
|
||||
normalizeReleaseTag,
|
||||
parseReleaseVersion,
|
||||
} from "./release-version-utils.mjs";
|
||||
|
||||
const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/;
|
||||
|
||||
@@ -13,7 +18,6 @@ Options:
|
||||
--repo <owner/repo> Repository slug. Defaults to $GITHUB_REPOSITORY.
|
||||
--tag <tag> Release tag (e.g. v0.1.14). Defaults to latest changelog entry.
|
||||
--create-if-missing Create release if it does not already exist.
|
||||
--draft Create missing release as draft.
|
||||
`;
|
||||
process.stderr.write(usage.trimStart());
|
||||
process.stderr.write("\n");
|
||||
@@ -25,7 +29,6 @@ function parseArgs(argv) {
|
||||
repo: process.env.GITHUB_REPOSITORY || "",
|
||||
tag: "",
|
||||
createIfMissing: false,
|
||||
draft: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -54,12 +57,6 @@ function parseArgs(argv) {
|
||||
args.createIfMissing = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--draft") {
|
||||
args.draft = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
usageAndExit();
|
||||
}
|
||||
|
||||
@@ -70,11 +67,6 @@ function parseArgs(argv) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function normalizeTag(rawTag) {
|
||||
const trimmed = rawTag.trim().replace(/^refs\/tags\//, "");
|
||||
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||
}
|
||||
|
||||
function parseChangelog(changelogText) {
|
||||
const lines = changelogText.split(/\r?\n/);
|
||||
const headings = [];
|
||||
@@ -140,23 +132,51 @@ function runGh(args) {
|
||||
execFileSync("gh", args, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function buildPrereleaseNotes({ releaseTag, version }) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const lines = [
|
||||
`## ${version} - ${today}`,
|
||||
"",
|
||||
"Release candidate build for testing from `main`.",
|
||||
"",
|
||||
`- Tag: \`${releaseTag}\``,
|
||||
];
|
||||
|
||||
if (process.env.GITHUB_SHA) {
|
||||
lines.push(`- Commit: \`${process.env.GITHUB_SHA}\``);
|
||||
}
|
||||
|
||||
lines.push("- This prerelease does not replace the website's latest stable download.");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const changelogPath = path.resolve("CHANGELOG.md");
|
||||
const changelogText = readFileSync(changelogPath, "utf8");
|
||||
const entries = parseChangelog(changelogText);
|
||||
|
||||
const targetTag = args.tag ? normalizeTag(args.tag) : entries[0].tag;
|
||||
const targetTag = args.tag ? normalizeReleaseTag(args.tag) : entries[0].tag;
|
||||
const releaseInfo = getReleaseInfoFromSourceTag(targetTag);
|
||||
const targetEntry = entries.find((entry) => entry.tag === targetTag);
|
||||
|
||||
if (!targetEntry) {
|
||||
let notes = targetEntry?.notes ?? null;
|
||||
|
||||
if (!notes && releaseInfo.isPrerelease) {
|
||||
notes = buildPrereleaseNotes({
|
||||
releaseTag: releaseInfo.releaseTag,
|
||||
version: releaseInfo.version,
|
||||
});
|
||||
}
|
||||
|
||||
if (!notes) {
|
||||
console.log(`No matching changelog section found for ${targetTag}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-"));
|
||||
const notesPath = path.join(tempDir, `${targetTag}-notes.md`);
|
||||
writeFileSync(notesPath, targetEntry.notes);
|
||||
writeFileSync(notesPath, notes);
|
||||
|
||||
try {
|
||||
if (hasRelease(targetTag, args.repo)) {
|
||||
@@ -184,7 +204,7 @@ function main() {
|
||||
"--notes-file",
|
||||
notesPath,
|
||||
"--verify-tag",
|
||||
...(args.draft ? ["--draft"] : []),
|
||||
...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []),
|
||||
]);
|
||||
console.log(`Created release ${targetTag} with changelog notes.`);
|
||||
} catch (createError) {
|
||||
|
||||
Reference in New Issue
Block a user