diff --git a/.github/templates/preview-comment.md b/.github/templates/preview-comment.md index a1bd37c3d40..f555706ecd5 100644 --- a/.github/templates/preview-comment.md +++ b/.github/templates/preview-comment.md @@ -14,11 +14,6 @@ $DATABASE_LINK -Fly.io Electric (Fly.io) -$ELECTRIC_STATUS -$ELECTRIC_LINK - - Vercel API (Vercel) $API_STATUS $API_LINK diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index ff17b9e2529..8f2acaca814 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -20,15 +20,15 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Setup Node.js (for native addon compilation) - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 @@ -43,7 +43,7 @@ jobs: run: bun run build:dist --target=${{ matrix.target }} - name: Upload tarball - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: superset-${{ matrix.target }} path: packages/cli/dist/superset-${{ matrix.target }}.tar.gz @@ -59,10 +59,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Download all artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: release-artifacts pattern: superset-* diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 8f132df951a..6d0f5733b41 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -41,16 +41,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.bun/install/cache @@ -136,7 +136,7 @@ jobs: } - name: Upload DMG artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-mac-${{ matrix.arch }}-dmg path: apps/desktop/release/*.dmg @@ -144,7 +144,7 @@ jobs: if-no-files-found: error - name: Upload ZIP artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-mac-${{ matrix.arch }}-zip path: apps/desktop/release/*.zip @@ -152,7 +152,7 @@ jobs: if-no-files-found: error - name: Upload auto-update manifest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-mac-${{ matrix.arch }}-update-manifest path: apps/desktop/release/*-mac.yml @@ -166,16 +166,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.bun/install/cache @@ -249,7 +249,7 @@ jobs: } - name: Upload AppImage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-linux-appimage path: apps/desktop/release/*.AppImage @@ -257,7 +257,7 @@ jobs: if-no-files-found: error - name: Upload Linux auto-update manifest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-linux-update-manifest path: apps/desktop/release/*-linux.yml diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index d8312607e27..42ce5f8fea4 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -54,7 +54,7 @@ jobs: done - name: Checkout homebrew-tap - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: superset-sh/homebrew-tap token: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c9a076cf0..ffbb41a421a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,16 +12,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -37,16 +37,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -62,16 +62,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -108,16 +108,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -133,16 +133,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/cleanup-preview.yml b/.github/workflows/cleanup-preview.yml index 79774cdc5f9..2af3e565286 100644 --- a/.github/workflows/cleanup-preview.yml +++ b/.github/workflows/cleanup-preview.yml @@ -7,7 +7,6 @@ on: jobs: cleanup: name: Cleanup Preview Resources - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest permissions: contents: read @@ -16,7 +15,7 @@ jobs: steps: - name: Delete Neon branch id: neon-cleanup - uses: neondatabase/delete-branch-action@v3 + uses: neondatabase/delete-branch-action@4468d825d5a88ef4012f1705a82f02ec3072f776 # v3.2.1 continue-on-error: true with: project_id: ${{ vars.NEON_PROJECT_ID }} @@ -24,7 +23,7 @@ jobs: api_key: ${{ secrets.NEON_API_KEY }} - name: Setup Fly CLI - uses: superfly/flyctl-actions/setup-flyctl@master + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6 - name: Delete Electric Fly.io app id: electric-cleanup @@ -36,7 +35,7 @@ jobs: - name: Update comment if: always() - uses: thollander/actions-comment-pull-request@v3 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 with: message: | ## ๐Ÿงน Preview Cleanup Complete diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 84045155038..772641b28b5 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -18,27 +18,25 @@ env: MARKETING_ALIAS: marketing-pr-${{ github.event.pull_request.number }}-superset.vercel.app ADMIN_ALIAS: admin-pr-${{ github.event.pull_request.number }}-superset.vercel.app DOCS_ALIAS: docs-pr-${{ github.event.pull_request.number }}-superset.vercel.app - ELECTRIC_URL: https://superset-electric-pr-${{ github.event.pull_request.number }}.fly.dev/v1/shape jobs: deploy-database: name: Deploy Database (Neon) - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: preview steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -48,7 +46,7 @@ jobs: - name: Create Neon branch id: create-branch - uses: neondatabase/create-branch-action@v6 + uses: neondatabase/create-branch-action@fb620d43d4c565abaf088b848a4e28e5c4ea4d9c # 6.3.1 with: project_id: ${{ vars.NEON_PROJECT_ID }} branch_name: ${{ github.head_ref }} @@ -74,76 +72,29 @@ jobs: EOF - name: Upload database status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: database-status path: database-status.env - deploy-electric: - name: Deploy Electric (Fly.io) - if: github.repository == 'superset-sh/superset' - runs-on: ubuntu-latest - needs: deploy-database - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download database info - uses: actions/download-artifact@v4 - with: - name: database-status - - - name: Load database URL - run: | - source database-status.env - echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV - - - name: Deploy Electric to Fly.io - uses: superfly/fly-pr-review-apps@1.3.0 - with: - name: superset-electric-pr-${{ github.event.pull_request.number }} - region: iad - org: ${{ vars.FLY_ORG }} - config: fly.toml - secrets: | - DATABASE_URL=${{ env.DATABASE_URL_UNPOOLED }} - ELECTRIC_SECRET=${{ secrets.ELECTRIC_SECRET_PREVIEW }} - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - - name: Save Electric status - run: | - cat > electric-status.env << EOF - ELECTRIC_STATUS="โœ…" - ELECTRIC_LINK="View App" - EOF - - - name: Upload Electric status - uses: actions/upload-artifact@v4 - with: - name: electric-status - path: electric-status.env - deploy-api: name: Deploy API - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: preview needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Download database info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: database-status @@ -154,7 +105,7 @@ jobs: echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -206,10 +157,9 @@ jobs: GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} + QSTASH_URL: ${{ secrets.QSTASH_URL }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} - ELECTRIC_URL: ${{ env.ELECTRIC_URL }} - ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET_PREVIEW }} DURABLE_STREAMS_URL: ${{ secrets.DURABLE_STREAMS_URL }} DURABLE_STREAMS_SECRET: ${{ secrets.DURABLE_STREAMS_SECRET }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -220,6 +170,7 @@ jobs: SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} + RELAY_URL: ${{ secrets.RELAY_URL }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -258,10 +209,9 @@ jobs: --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ --env QSTASH_TOKEN=$QSTASH_TOKEN \ + --env QSTASH_URL=$QSTASH_URL \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ - --env ELECTRIC_URL=$ELECTRIC_URL \ - --env ELECTRIC_SECRET=$ELECTRIC_SECRET \ --env DURABLE_STREAMS_URL=$DURABLE_STREAMS_URL \ --env DURABLE_STREAMS_SECRET=$DURABLE_STREAMS_SECRET \ --env STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY \ @@ -271,7 +221,8 @@ jobs: --env STRIPE_ENTERPRISE_YEARLY_PRICE_ID=$STRIPE_ENTERPRISE_YEARLY_PRICE_ID \ --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY \ - --env TAVILY_API_KEY=$TAVILY_API_KEY) + --env TAVILY_API_KEY=$TAVILY_API_KEY \ + --env RELAY_URL=$RELAY_URL) vercel alias $VERCEL_URL ${{ env.API_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT @@ -284,14 +235,13 @@ jobs: EOF - name: Upload API status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: api-status path: api-status.env deploy-web: name: Deploy Web - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: name: preview @@ -300,22 +250,22 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} - name: Download database info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: database-status @@ -403,30 +353,40 @@ jobs: EOF - name: Upload web status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: web-status path: web-status.env deploy-marketing: name: Deploy Marketing - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: preview needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version + - name: Download database info + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: database-status + + - name: Load database URL + run: | + source database-status.env + echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV + echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV + - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -443,6 +403,8 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} + DATABASE_URL: ${{ env.DATABASE_URL }} + DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} @@ -470,6 +432,8 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --archive=tgz --token=$VERCEL_TOKEN \ + --env DATABASE_URL=$DATABASE_URL \ + --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ @@ -503,30 +467,29 @@ jobs: EOF - name: Upload marketing status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: marketing-status path: marketing-status.env deploy-admin: name: Deploy Admin - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: preview needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Download database info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: database-status @@ -537,7 +500,7 @@ jobs: echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -622,30 +585,40 @@ jobs: EOF - name: Upload admin status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: admin-status path: admin-status.env deploy-docs: name: Deploy Docs - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: preview needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version + - name: Download database info + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: database-status + + - name: Load database URL + run: | + source database-status.env + echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV + echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV + - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -662,6 +635,8 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_DOCS_PROJECT_ID }} + DATABASE_URL: ${{ env.DATABASE_URL }} + DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} @@ -675,6 +650,8 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --archive=tgz --token=$VERCEL_TOKEN \ + --env DATABASE_URL=$DATABASE_URL \ + --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ --env NEXT_PUBLIC_SENTRY_DSN_DOCS=$NEXT_PUBLIC_SENTRY_DSN_DOCS \ @@ -691,26 +668,26 @@ jobs: EOF - name: Upload docs status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: docs-status path: docs-status.env post-final-comment: name: Post Deployment Comment - if: github.repository == 'superset-sh/superset' && always() runs-on: ubuntu-latest - needs: [deploy-database, deploy-electric, deploy-api, deploy-web, deploy-marketing, deploy-admin, deploy-docs] + if: always() + needs: [deploy-database, deploy-api, deploy-web, deploy-marketing, deploy-admin, deploy-docs] permissions: contents: read pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Download all status artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: "*-status" merge-multiple: true @@ -719,8 +696,6 @@ jobs: run: | DATABASE_STATUS="โŒ" DATABASE_LINK="Failed to create" - ELECTRIC_STATUS="โŒ" - ELECTRIC_LINK="Failed to deploy" API_STATUS="โŒ" API_LINK="Failed to deploy" WEB_STATUS="โŒ" @@ -736,10 +711,6 @@ jobs: source database-status.env fi - if [[ "${{ needs.deploy-electric.result }}" == "success" ]]; then - source electric-status.env - fi - if [[ "${{ needs.deploy-api.result }}" == "success" ]]; then source api-status.env fi @@ -760,11 +731,11 @@ jobs: source docs-status.env fi - export DATABASE_STATUS DATABASE_LINK ELECTRIC_STATUS ELECTRIC_LINK API_STATUS API_LINK WEB_STATUS WEB_LINK MARKETING_STATUS MARKETING_LINK ADMIN_STATUS ADMIN_LINK DOCS_STATUS DOCS_LINK + export DATABASE_STATUS DATABASE_LINK API_STATUS API_LINK WEB_STATUS WEB_LINK MARKETING_STATUS MARKETING_LINK ADMIN_STATUS ADMIN_LINK DOCS_STATUS DOCS_LINK envsubst < .github/templates/preview-comment.md > final-comment.md - name: Post final deployment comment - uses: thollander/actions-comment-pull-request@v3 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 with: file-path: final-comment.md comment-tag: "๐Ÿš€-preview-deployment" diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 05fc0908307..ced6ac2d246 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -5,31 +5,27 @@ on: branches: [main] workflow_dispatch: -# Disabled in fork โ€” only runs on the upstream repository -# To re-enable, remove the `if` condition from each job - env: VERCEL_CLI_VERSION: 50.22.1 jobs: deploy-database: name: Deploy Database Migrations - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: production steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -46,23 +42,22 @@ jobs: deploy-api: name: Deploy API to Vercel - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: production needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -113,10 +108,9 @@ jobs: GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} + QSTASH_URL: ${{ secrets.QSTASH_URL }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} - ELECTRIC_URL: ${{ secrets.ELECTRIC_URL }} - ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET }} DURABLE_STREAMS_URL: ${{ secrets.DURABLE_STREAMS_URL }} DURABLE_STREAMS_SECRET: ${{ secrets.DURABLE_STREAMS_SECRET }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -127,6 +121,7 @@ jobs: SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} + RELAY_URL: ${{ secrets.RELAY_URL }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -165,10 +160,9 @@ jobs: --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ --env QSTASH_TOKEN=$QSTASH_TOKEN \ + --env QSTASH_URL=$QSTASH_URL \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ - --env ELECTRIC_URL=$ELECTRIC_URL \ - --env ELECTRIC_SECRET=$ELECTRIC_SECRET \ --env DURABLE_STREAMS_URL=$DURABLE_STREAMS_URL \ --env DURABLE_STREAMS_SECRET=$DURABLE_STREAMS_SECRET \ --env STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY \ @@ -178,27 +172,27 @@ jobs: --env STRIPE_ENTERPRISE_YEARLY_PRICE_ID=$STRIPE_ENTERPRISE_YEARLY_PRICE_ID \ --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY \ - --env TAVILY_API_KEY=$TAVILY_API_KEY + --env TAVILY_API_KEY=$TAVILY_API_KEY \ + --env RELAY_URL=$RELAY_URL deploy-web: name: Deploy Web to Vercel - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: production needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -272,23 +266,22 @@ jobs: deploy-marketing: name: Deploy Marketing to Vercel - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: production needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -304,6 +297,8 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }} @@ -331,6 +326,8 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --archive=tgz --token=$VERCEL_TOKEN \ + --env DATABASE_URL=$DATABASE_URL \ + --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ @@ -356,23 +353,22 @@ jobs: deploy-admin: name: Deploy Admin to Vercel - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: production needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -448,16 +444,15 @@ jobs: deploy-electric: name: Deploy Electric to Fly.io - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: production steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Fly CLI - uses: superfly/flyctl-actions/setup-flyctl@master + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6 - name: Stage secrets env: @@ -476,22 +471,21 @@ jobs: deploy-electric-proxy: name: Deploy Electric Proxy to Cloudflare - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: production steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -508,23 +502,22 @@ jobs: deploy-docs: name: Deploy Docs to Vercel - if: github.repository == 'superset-sh/superset' runs-on: ubuntu-latest environment: production needs: deploy-database steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -540,6 +533,8 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_DOCS_PROJECT_ID }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }} @@ -553,6 +548,8 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --archive=tgz --token=$VERCEL_TOKEN \ + --env DATABASE_URL=$DATABASE_URL \ + --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ --env NEXT_PUBLIC_SENTRY_DSN_DOCS=$NEXT_PUBLIC_SENTRY_DSN_DOCS \ diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index bcbf1918dbd..17aeda11096 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -17,18 +17,18 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/release-desktop-canary.yml b/.github/workflows/release-desktop-canary.yml index 51c3a92f37f..061eb3a0ab1 100644 --- a/.github/workflows/release-desktop-canary.yml +++ b/.github/workflows/release-desktop-canary.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -88,10 +88,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: release-artifacts pattern: desktop-canary-* @@ -138,7 +138,7 @@ jobs: git push origin :refs/tags/desktop-canary || true - name: Create Canary Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: desktop-canary name: "Superset Desktop Canary" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 717b1a3806d..91be7e1cadb 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -52,7 +52,7 @@ jobs: echo "Previous tag: ${PREVIOUS_TAG:-none}" - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: release-artifacts pattern: desktop-* diff --git a/.github/workflows/setup-automations-schedule.yml b/.github/workflows/setup-automations-schedule.yml new file mode 100644 index 00000000000..e5c3f6ee27c --- /dev/null +++ b/.github/workflows/setup-automations-schedule.yml @@ -0,0 +1,36 @@ +name: Setup Automations Schedule + +on: + workflow_dispatch: + +jobs: + setup: + name: Ensure QStash schedule exists + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + id: setup-bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + run: bun install --frozen --ignore-scripts + + - name: Create schedule + env: + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} + QSTASH_URL: ${{ secrets.QSTASH_URL }} + run: bun run apps/api/scripts/setup-automations-schedule.ts diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml index 9e405193c8f..1d44af19c4f 100644 --- a/.github/workflows/triage-issue.yml +++ b/.github/workflows/triage-issue.yml @@ -27,16 +27,16 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 5f3dfc9f5f2..056b0a6b5fe 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -17,18 +17,18 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} diff --git a/README.md b/README.md index 40bc76bab43..648235d9497 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,10 @@ echo 'SKIP_ENV_VALIDATION=1' >> .env ```bash # Install caddy: brew install caddy (macOS) or see https://caddyserver.com/docs/install cp Caddyfile.example Caddyfile + +# Without this, Chromium rejects https://localhost:* with ERR_CERT_AUTHORITY_INVALID. +# Prompts for sudo once. +caddy trust ``` **4. Install dependencies and run** diff --git a/apps/admin/package.json b/apps/admin/package.json index b6a7a7bce52..8c6bec93071 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -23,9 +23,9 @@ "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", - "better-auth": "1.5.6", + "better-auth": "1.6.5", "date-fns": "^4.1.0", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "import-in-the-middle": "2.0.1", "next": "^16.0.10", "next-themes": "^0.4.6", diff --git a/apps/api/package.json b/apps/api/package.json index 24595a756ba..f7e4aab1ae8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,9 +12,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.78.0", - "@better-auth/oauth-provider": "1.5.6", + "@better-auth/oauth-provider": "1.6.5", "@durable-streams/client": "^0.2.3", - "@electric-sql/client": "1.5.15", "@linear/sdk": "^68.1.0", "@modelcontextprotocol/sdk": "^1.26.0", "@octokit/app": "^16.1.2", @@ -35,9 +34,9 @@ "@upstash/ratelimit": "^2.0.4", "@upstash/redis": "^1.34.3", "@vercel/blob": "^2.0.0", - "better-auth": "1.5.6", + "better-auth": "1.6.5", "date-fns": "^4.1.0", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "import-in-the-middle": "2.0.1", "jose": "^6.1.3", "lodash.chunk": "^4.2.0", diff --git a/apps/api/src/app/api/desktop/version/route.ts b/apps/api/src/app/api/desktop/version/route.ts index 01751c8d186..2e6dff66c7e 100644 --- a/apps/api/src/app/api/desktop/version/route.ts +++ b/apps/api/src/app/api/desktop/version/route.ts @@ -1,4 +1,4 @@ -const MINIMUM_DESKTOP_VERSION = "0.0.48"; +const MINIMUM_DESKTOP_VERSION = "1.5.0"; /** * Used to force the desktop app to update, in cases where we can't support diff --git a/apps/api/src/app/api/electric/[...path]/route.ts b/apps/api/src/app/api/electric/[...path]/route.ts deleted file mode 100644 index 21e552bfcab..00000000000 --- a/apps/api/src/app/api/electric/[...path]/route.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"; -import { auth } from "@superset/auth/server"; -import { env } from "@/env"; -import { buildWhereClause } from "./utils"; - -interface AuthInfo { - userId: string; - organizationIds: string[]; -} - -async function authenticate(request: Request): Promise { - const bearer = request.headers.get("Authorization"); - if (bearer?.startsWith("Bearer ")) { - const token = bearer.slice(7); - try { - const { payload } = await auth.api.verifyJWT({ body: { token } }); - if (payload?.sub && Array.isArray(payload.organizationIds)) { - return { - userId: payload.sub, - organizationIds: payload.organizationIds as string[], - }; - } - } catch {} - } - - const sessionData = await auth.api.getSession({ headers: request.headers }); - if (!sessionData?.user) return null; - return { - userId: sessionData.user.id, - organizationIds: sessionData.session.organizationIds ?? [], - }; -} - -export async function GET(request: Request): Promise { - const authInfo = await authenticate(request); - if (!authInfo) { - return new Response("Unauthorized", { status: 401 }); - } - - const url = new URL(request.url); - - const organizationId = url.searchParams.get("organizationId"); - - if (organizationId && !authInfo.organizationIds.includes(organizationId)) { - return new Response("Not a member of this organization", { status: 403 }); - } - - const originUrl = new URL(env.ELECTRIC_URL); - originUrl.searchParams.set("secret", env.ELECTRIC_SECRET); - - url.searchParams.forEach((value, key) => { - if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { - originUrl.searchParams.set(key, value); - } - }); - - const tableName = url.searchParams.get("table"); - if (!tableName) { - return new Response("Missing table parameter", { status: 400 }); - } - - const whereClause = await buildWhereClause( - tableName, - organizationId ?? "", - authInfo.userId, - ); - if (!whereClause) { - return new Response(`Unknown table: ${tableName}`, { status: 400 }); - } - - originUrl.searchParams.set("table", tableName); - originUrl.searchParams.set("where", whereClause.fragment); - whereClause.params.forEach((value, index) => { - originUrl.searchParams.set(`params[${index + 1}]`, String(value)); - }); - - if (tableName === "auth.apikeys") { - originUrl.searchParams.set( - "columns", - "id,name,start,created_at,last_request", - ); - } - - if (tableName === "integration_connections") { - originUrl.searchParams.set( - "columns", - "id,organization_id,connected_by_user_id,provider,token_expires_at,external_org_id,external_org_name,config,created_at,updated_at", - ); - } - - const response = await fetch(originUrl.toString()); - - const headers = new Headers(response.headers); - if (headers.get("content-encoding")) { - headers.delete("content-encoding"); - headers.delete("content-length"); - } - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts deleted file mode 100644 index 7206700204a..00000000000 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { db } from "@superset/db/client"; -import { - agentCommands, - chatSessions, - devicePresence, - githubPullRequests, - githubRepositories, - integrationConnections, - invitations, - members, - organizations, - projects, - sessionHosts, - subscriptions, - taskStatuses, - tasks, - v2Clients, - v2Hosts, - v2Projects, - v2UsersHosts, - v2Workspaces, - workspaces, -} from "@superset/db/schema"; -import { eq, inArray, sql } from "drizzle-orm"; -import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; -import { QueryBuilder } from "drizzle-orm/pg-core"; - -export type AllowedTable = - | "tasks" - | "task_statuses" - | "projects" - | "v2_hosts" - | "v2_clients" - | "v2_projects" - | "v2_users_hosts" - | "v2_workspaces" - | "auth.members" - | "auth.organizations" - | "auth.users" - | "auth.invitations" - | "auth.apikeys" - | "device_presence" - | "agent_commands" - | "integration_connections" - | "subscriptions" - | "workspaces" - | "chat_sessions" - | "session_hosts" - | "github_repositories" - | "github_pull_requests"; - -interface WhereClause { - fragment: string; - params: unknown[]; -} - -function build(table: PgTable, column: PgColumn, id: string): WhereClause { - const whereExpr = eq(sql`${sql.identifier(column.name)}`, id); - const qb = new QueryBuilder(); - const { sql: query, params } = qb - .select() - .from(table) - .where(whereExpr) - .toSQL(); - const fragment = query.replace(/^select .* from .* where\s+/i, ""); - return { fragment, params }; -} - -export async function buildWhereClause( - tableName: string, - organizationId: string, - userId: string, -): Promise { - switch (tableName) { - case "tasks": - return build(tasks, tasks.organizationId, organizationId); - - case "task_statuses": - return build(taskStatuses, taskStatuses.organizationId, organizationId); - - case "projects": - return build(projects, projects.organizationId, organizationId); - - case "v2_projects": - return build(v2Projects, v2Projects.organizationId, organizationId); - - case "v2_hosts": - return build(v2Hosts, v2Hosts.organizationId, organizationId); - - case "v2_clients": - return build(v2Clients, v2Clients.organizationId, organizationId); - - case "v2_users_hosts": - return build(v2UsersHosts, v2UsersHosts.organizationId, organizationId); - - case "v2_workspaces": - return build(v2Workspaces, v2Workspaces.organizationId, organizationId); - - case "auth.members": - return build(members, members.organizationId, organizationId); - - case "auth.invitations": - return build(invitations, invitations.organizationId, organizationId); - - case "auth.organizations": { - // Use the authenticated user's ID to find their organizations - const userMemberships = await db.query.members.findMany({ - where: eq(members.userId, userId), - columns: { organizationId: true }, - }); - - if (userMemberships.length === 0) { - return { fragment: "1 = 0", params: [] }; - } - - const orgIds = [...new Set(userMemberships.map((m) => m.organizationId))]; - const whereExpr = inArray( - sql`${sql.identifier(organizations.id.name)}`, - orgIds, - ); - const qb = new QueryBuilder(); - const { sql: query, params } = qb - .select() - .from(organizations) - .where(whereExpr) - .toSQL(); - const fragment = query.replace(/^select .* from .* where\s+/i, ""); - return { fragment, params }; - } - - case "auth.users": { - const fragment = `$1 = ANY("organization_ids")`; - return { fragment, params: [organizationId] }; - } - - case "device_presence": - return build( - devicePresence, - devicePresence.organizationId, - organizationId, - ); - - case "agent_commands": - return build(agentCommands, agentCommands.organizationId, organizationId); - - case "auth.apikeys": { - const fragment = `"metadata" LIKE '%"organizationId":"' || $1 || '"%'`; - return { fragment, params: [organizationId] }; - } - - case "integration_connections": - return build( - integrationConnections, - integrationConnections.organizationId, - organizationId, - ); - - case "subscriptions": - return build(subscriptions, subscriptions.referenceId, organizationId); - - case "workspaces": - return build(workspaces, workspaces.organizationId, organizationId); - - case "chat_sessions": - return build(chatSessions, chatSessions.organizationId, organizationId); - - case "session_hosts": - return build(sessionHosts, sessionHosts.organizationId, organizationId); - - case "github_repositories": - return build( - githubRepositories, - githubRepositories.organizationId, - organizationId, - ); - - case "github_pull_requests": - return build( - githubPullRequests, - githubPullRequests.organizationId, - organizationId, - ); - - default: - return null; - } -} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 9c778d7d818..ce4a0b1564e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -10,10 +10,6 @@ export const env = createEnv({ server: { DATABASE_URL: z.string(), DATABASE_URL_UNPOOLED: z.string(), - ELECTRIC_URL: z.string().url(), - ELECTRIC_SECRET: z.string().min(16), - ELECTRIC_SOURCE_ID: z.string().optional(), - ELECTRIC_SOURCE_SECRET: z.string().optional(), BLOB_READ_WRITE_TOKEN: z.string(), GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), diff --git a/apps/api/src/proxy.ts b/apps/api/src/proxy.ts index 8e28b5829e2..6451123950c 100644 --- a/apps/api/src/proxy.ts +++ b/apps/api/src/proxy.ts @@ -29,15 +29,8 @@ function getCorsHeaders(origin: string | null, deploymentOrigin: string) { "Access-Control-Allow-Origin": isAllowed ? origin : "", "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": - "Content-Type, Authorization, x-trpc-source, trpc-accept, X-Electric-Backend, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Closed", + "Content-Type, Authorization, x-trpc-source, trpc-accept, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Closed", "Access-Control-Expose-Headers": [ - // Electric sync headers - "electric-offset", - "electric-handle", - "electric-schema", - "electric-cursor", - "electric-chunk-last-offset", - "electric-up-to-date", // Durable stream headers "Stream-Next-Offset", "Stream-Cursor", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a519a46713f..f5578d2a19a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -40,8 +40,8 @@ "@ai-sdk/openai": "3.0.36", "@ai-sdk/react": "^3.0.0", "@ast-grep/napi": "^0.41.0", - "@better-auth/api-key": "1.5.6", - "@better-auth/stripe": "1.5.6", + "@better-auth/api-key": "1.6.5", + "@better-auth/stripe": "1.6.5", "@codemirror/commands": "^6.10.2", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -144,21 +144,21 @@ "@types/pidusage": "^2.0.5", "@vercel/blob": "^2.0.0", "@vscode/ripgrep": "^1.15.9", - "@xterm/addon-clipboard": "0.3.0-beta.195", - "@xterm/addon-fit": "0.12.0-beta.195", - "@xterm/addon-image": "0.10.0-beta.195", - "@xterm/addon-ligatures": "0.11.0-beta.195", - "@xterm/addon-progress": "0.3.0-beta.195", - "@xterm/addon-search": "0.17.0-beta.195", - "@xterm/addon-serialize": "0.15.0-beta.195", - "@xterm/addon-unicode11": "0.10.0-beta.195", - "@xterm/addon-webgl": "0.20.0-beta.194", - "@xterm/headless": "6.1.0-beta.195", - "@xterm/xterm": "6.1.0-beta.195", + "@xterm/addon-clipboard": "0.3.0-beta.197", + "@xterm/addon-fit": "0.12.0-beta.197", + "@xterm/addon-image": "0.10.0-beta.197", + "@xterm/addon-ligatures": "0.11.0-beta.197", + "@xterm/addon-progress": "0.3.0-beta.197", + "@xterm/addon-search": "0.17.0-beta.197", + "@xterm/addon-serialize": "0.15.0-beta.197", + "@xterm/addon-unicode11": "0.10.0-beta.197", + "@xterm/addon-webgl": "0.20.0-beta.196", + "@xterm/headless": "6.1.0-beta.197", + "@xterm/xterm": "6.1.0-beta.197", "@xyflow/react": "^12.10.0", "ai": "^6.0.0", "ansi_up": "^6.0.6", - "better-auth": "1.5.6", + "better-auth": "1.6.5", "better-sqlite3": "12.6.2", "bindings": "^1.5.0", "bufferutil": "^4.1.0", @@ -177,7 +177,7 @@ "dockerfile-language-service": "0.16.1", "dockerfile-utils": "0.16.3", "dotenv": "^17.3.1", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "electron-updater": "^6.8.3", "elkjs": "^0.11.1", "exceljs": "^4.4.0", diff --git a/apps/desktop/plans/20260417-1830-lsof-excessive-spawning-3372.md b/apps/desktop/plans/20260417-1830-lsof-excessive-spawning-3372.md new file mode 100644 index 00000000000..63b59d57c90 --- /dev/null +++ b/apps/desktop/plans/20260417-1830-lsof-excessive-spawning-3372.md @@ -0,0 +1,74 @@ +# Stop Excessive `lsof` Process Spawning (Issue #3372) + +## Problem + +[#3372](https://github.com/superset-sh/superset/issues/3372): Superset spawns a growing pile of `lsof` processes. Symptoms: CPU pinned at 100%, count grows with open workspaces, closing workspaces doesn't help, quitting Superset leaves `lsof` behind. + +Related: [#3235](https://github.com/superset-sh/superset/issues/3235) โ€” EDR agents amplify every spawn, so fixing this also reduces their CPU. + +## Root Causes (three, compounding) + +All in `apps/desktop/src/main/lib/terminal/`. + +1. **Interval never stops.** `PortManager` constructor called `startPeriodicScan()` at module load. The 2.5s `setInterval` kept firing forever โ€” zero sessions, closed workspaces, whatever. +2. **Hint scans run concurrently with the bulk scan.** `scanPane` had no `isScanning` guard. Hint regexes `/port\s+(\d+)/i` and `/:(\d{4,5})\s*$/` were so loose they matched routine `git`/`ssh` output, firing spurious scans on top of the periodic ones. +3. **`lsof` children outlive us.** Code ran `exec("sh -c 'lsof โ€ฆ || true'")`. On timeout, Node SIGTERMs the shell; the shell doesn't forward to `lsof`; the child gets reparented to `launchd`/`init` and survives even app quit. + +## Fix (minimum-churn, one PR) + +1. **Lifecycle:** interval starts on first `registerSession`/`upsertDaemonSession`, stops on the last unregister. +2. **Coalesce:** one debounced hint timer + a `scanRequested` flag. If a hint or tick fires mid-scan, queue exactly one follow-up. `maxInFlight == 1` guaranteed. +3. **No orphans:** `execFile` instead of `exec` (no shell wrapper). `AbortController` on `PortManager`; `stopPeriodicScan` aborts the in-flight child. +4. **Regex noise:** delete the two over-broad patterns; keep the three that imply a real listener (`listening on`, `server started`, `ready on`). + +## Alternatives Considered (and why rejected) + +- **A3, event-driven only (no interval):** misses silent port openers. Deferred. +- **B1, shared `isScanning` flag only:** still drops detection for up to 2.5s with no follow-up guarantee. B2 is strictly better for the same worst-case latency. +- **B3, per-pane `isScanning` + semaphore:** more state, same behavior as B2. +- **C3, `killSignal: "SIGKILL"`:** kills the shell, child still orphans. Doesn't address the root issue. +- **D3, delete all hints:** loses fast-detection for dev servers (UX regression). +- **Option 1: delete the whole dynamic-port subsystem and rely on `.superset/ports.json`:** attractive (-1500 lines) but regresses feature for users without a static config. +- **Option 2: delete periodic scan, hint-only:** halves the code but misses silent port openers. +- **Option 4: delete `lsof` entirely, parse ports from terminal output:** most elegant (-700 lines) but loses PID info (Kill Port), still has edge cases. + +Chose minimum-churn because the real cost isn't `lsof`'s per-call expense (~100 ms on the fast path) โ€” it's the three lifecycle bugs multiplying it. Fix those and the feature works fine. + +## Prior Attempt โ€” Superseded + +Auto-generated PR [#3373](https://github.com/superset-sh/superset/pull/3373) by `github-actions[bot]` addresses lifecycle + a weaker `isScanning` guard on `scanPane`. Leaves the orphan-on-timeout and noisy-regex causes untouched. This PR addresses all three. + +## Progress + +- [x] (2026-04-17) Lifecycle: lazy start/stop via `ensurePeriodicScanRunning` / `stopPeriodicScanIfIdle` +- [x] (2026-04-17) Concurrency: `scanRequested` follow-up flag; deleted `scanPane`, `scanPidTreeAndUpdate`, `pendingHintScans` +- [x] (2026-04-17) `execFile` + `AbortSignal`; `runTolerant` helper for lsof exit-1 +- [x] (2026-04-17) `AbortController` aborted on `stopPeriodicScan` +- [x] (2026-04-17) Deleted the two over-broad hint regexes +- [x] (2026-04-17) Deleted dead `getProcessName` export and unused `paneId` parameter +- [x] (2026-04-17) 13 regression tests in `port-manager.test.ts`; A/B verified (8 fail on `main`) +- [x] (2026-04-17) `bun run typecheck` + `bun run lint:fix` clean; 127/127 terminal tests pass +- [x] (2026-04-17) PR [#3547](https://github.com/superset-sh/superset/pull/3547) opened +- [ ] Manual validation on macOS with 10 workspaces +- [ ] #3547 merged, #3372 and #3373 closed + +## Surprises + +- `execFile` via `promisify` rejects on non-zero exit codes. `lsof` exits 1 when its `-p` filter matches no PIDs โ€” legitimate empty result. Added `runTolerant` helper that reads `err.stdout` off the rejection. +- The production `getListeningPortsLsof` swallows all errors and returns `[]`. The initial test mock rejected on abort, which broke `forceScan`'s contract; fixed by mirroring production (resolve on abort). +- `getProcessName` was exported but had zero in-repo call sites. Likely dead since a prior hint-scan refactor. + +## Decisions + +- **Supersede #3373.** It fixes ~60% of the bug. Three causes โ†’ one PR is easier to review and revert. +- **Coalesce (B2) over shared-flag (B1).** B1 silently drops hint scans; B2 guarantees a follow-up at the same worst-case latency. +- **`execFile` + `AbortController`, not shell `exec`.** Removes the `sh -c` wrapper that strands children. Signal delivery becomes deterministic. +- **Delete the two over-broad regexes.** Routine non-port text shouldn't trigger scans. + +## Outcome + +- `port-manager.ts`: +36 / โˆ’110 +- `port-scanner.ts`: +52 / โˆ’40 +- `port-manager.test.ts`: +255 (new, 13 tests) + +A/B (mocked `lsof`): 100 hint-matching chunks during a 30 ms scan โ†’ โ‰ค2 `lsof` calls, `maxInFlight == 1`. On `main`: unbounded concurrency. diff --git a/apps/desktop/src/lib/ai/call-small-model.ts b/apps/desktop/src/lib/ai/call-small-model.ts index 369c548ee28..3cb67348f10 100644 --- a/apps/desktop/src/lib/ai/call-small-model.ts +++ b/apps/desktop/src/lib/ai/call-small-model.ts @@ -1,21 +1,10 @@ -// FORK NOTE: upstream #3517 removed fork's SmallModelProviders array -// and the provider-diagnostics store. Fork code (enhance-text.ts, -// git-operations.ts) still calls callSmallModel({ invoke }) expecting -// { result, attempts } with per-provider fallback. This shim restores -// that behavior on top of getSmallModelCandidates() (a fork-maintained -// replacement that returns the full priority list with OAuth / API key -// / proxy AUTH_TOKEN correctly wired via getAnthropicProviderOptions). -// -// Trade-offs vs. the pre-#3517 fork: -// - ProviderIssue reporting collapsed to generic `failed` โ€” upstream -// removed the diagnostic classifiers when it dropped -// provider-diagnostics, and fork no longer surfaces them anywhere -// except describeEnhanceFailure's reason string. -// - Credential resolution happens synchronously (mastracode token -// refresh is not awaited in the candidate list). If an OAuth access -// token is actually expired, the next candidate in the priority -// chain is tried. -import { getSmallModelCandidates } from "@superset/chat/server/shared"; +// FORK NOTE: upstream #3580 (#3580) replaced getSmallModelCandidates() with +// an async getSmallModel() that resolves a single model. This shim keeps the +// callSmallModel({ invoke }) interface that fork code (enhance-text.ts, +// git-operations.ts) expects, but now delegates to getSmallModel() instead of +// iterating a candidate list. Provider fallback and attempt tracking are +// simplified โ€” getSmallModel() already handles the priority chain internally. +import { getSmallModel } from "@superset/chat/server/shared"; import type { ProviderId, ProviderIssue } from "shared/ai/provider-status"; export type SmallModelCredentialKind = "api_key" | "oauth" | "env"; @@ -47,15 +36,8 @@ export interface SmallModelInvocationContext { credentials: SmallModelCredential; } -function toShimCredentialKind( - kind: "apiKey" | "oauth", -): SmallModelCredentialKind { - return kind === "oauth" ? "oauth" : "api_key"; -} - export async function callSmallModel({ invoke, - providerOrder, }: { invoke: ( context: SmallModelInvocationContext, @@ -65,26 +47,9 @@ export async function callSmallModel({ result: TResult | null; attempts: SmallModelAttempt[]; }> { - const allCandidates = getSmallModelCandidates(); - - const ordered = providerOrder - ? [...allCandidates].sort((a, b) => { - const ai = providerOrder.indexOf(a.providerId); - const bi = providerOrder.indexOf(b.providerId); - return ( - (ai === -1 ? Number.MAX_SAFE_INTEGER : ai) - - (bi === -1 ? Number.MAX_SAFE_INTEGER : bi) - ); - }) - : allCandidates; - - const attempts: SmallModelAttempt[] = []; + const model = await getSmallModel(); - if (ordered.length === 0) { - // No credentials at all for either provider. Fabricate two - // missing-credentials attempts so describeEnhanceFailure's - // "every attempt is missing-credentials" branch triggers the - // correct "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใŒๆŽฅ็ถšใ•ใ‚Œใฆใ„ใพใ›ใ‚“" message. + if (!model) { return { result: null, attempts: [ @@ -102,62 +67,46 @@ export async function callSmallModel({ }; } - for (const candidate of ordered) { - const credentials: SmallModelCredential = { - kind: toShimCredentialKind(candidate.credentialKind), - source: candidate.credentialSource, - }; - let model: unknown; - try { - model = candidate.createModel(); - } catch (error) { - attempts.push({ - providerId: candidate.providerId, - providerName: candidate.providerName, - credentialKind: credentials.kind, - credentialSource: candidate.credentialSource, - outcome: "failed", - reason: error instanceof Error ? error.message : String(error), - }); - continue; - } - - try { - const result = await invoke({ - providerId: candidate.providerId, - providerName: candidate.providerName, - model, - credentials, - }); - if (result === null || result === undefined) { - attempts.push({ - providerId: candidate.providerId, - providerName: candidate.providerName, - credentialKind: credentials.kind, - credentialSource: candidate.credentialSource, - outcome: "empty-result", - }); - continue; - } - attempts.push({ - providerId: candidate.providerId, - providerName: candidate.providerName, - credentialKind: credentials.kind, - credentialSource: candidate.credentialSource, - outcome: "succeeded", - }); - return { result, attempts }; - } catch (error) { - attempts.push({ - providerId: candidate.providerId, - providerName: candidate.providerName, - credentialKind: credentials.kind, - credentialSource: candidate.credentialSource, - outcome: "failed", - reason: error instanceof Error ? error.message : String(error), - }); + try { + const result = await invoke({ + providerId: "anthropic", + providerName: "Anthropic", + model, + credentials: { kind: "api_key" }, + }); + if (result === null || result === undefined) { + return { + result: null, + attempts: [ + { + providerId: "anthropic", + providerName: "Anthropic", + outcome: "empty-result", + }, + ], + }; } + return { + result, + attempts: [ + { + providerId: "anthropic", + providerName: "Anthropic", + outcome: "succeeded", + }, + ], + }; + } catch (error) { + return { + result: null, + attempts: [ + { + providerId: "anthropic", + providerName: "Anthropic", + outcome: "failed", + reason: error instanceof Error ? error.message : String(error), + }, + ], + }; } - - return { result: null, attempts }; } diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 431ae4a3b4c..bc779bf1c05 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -16,6 +16,7 @@ import { shell, } from "electron"; import { localDb } from "main/lib/local-db"; +import { externalUrlLogLabel, isSafeExternalUrl } from "main/lib/safe-url"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getWorkspace } from "../workspaces/utils/db-helpers"; @@ -153,12 +154,26 @@ async function openPathInApp( export const createExternalRouter = () => { return router({ openUrl: publicProcedure.input(z.string()).mutation(async ({ input }) => { + if (!isSafeExternalUrl(input)) { + console.warn( + "[external/openUrl] Blocked unsafe URL scheme:", + externalUrlLogLabel(input), + ); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "URL scheme not allowed", + }); + } try { await shell.openExternal(input); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error("[external/openUrl] Failed to open URL:", input, error); + console.error( + "[external/openUrl] Failed to open URL:", + externalUrlLogLabel(input), + error, + ); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: errorMessage, diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index a04b765e43f..04b8cbd0285 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -19,6 +19,10 @@ import { AGENT_PRESET_DESCRIPTIONS, DEFAULT_TERMINAL_PRESET_AGENT_TYPES, } from "@superset/shared/agent-command"; +import { + applyLegacyPermissionsOverrides, + terminalPresetsMatchPre3546Seed, +} from "@superset/shared/agent-permissions-migration"; import { TRPCError } from "@trpc/server"; import { app } from "electron"; import { env } from "main/env.main"; @@ -140,7 +144,42 @@ function saveTerminalPresets( .run(); } +let agentPresetPermissionsMigrationChecked = false; + +function runAgentPresetPermissionsMigration() { + if (agentPresetPermissionsMigrationChecked) return; + const row = getSettings(); + if (row.agentPresetPermissionsMigratedAt) { + agentPresetPermissionsMigrationChecked = true; + return; + } + + const isExistingUser = + row.terminalPresetsInitialized === true && + terminalPresetsMatchPre3546Seed(row.terminalPresets); + + const nextOverrides = isExistingUser + ? applyLegacyPermissionsOverrides( + readAgentPresetOverrides(row.agentPresetOverrides), + ) + : undefined; + + const now = Date.now(); + const setFields = { + agentPresetPermissionsMigratedAt: now, + ...(nextOverrides ? { agentPresetOverrides: nextOverrides } : {}), + }; + localDb + .insert(settings) + .values({ id: 1, ...setFields }) + .onConflictDoUpdate({ target: settings.id, set: setFields }) + .run(); + + agentPresetPermissionsMigrationChecked = true; +} + function readRawAgentPresetOverrides(): AgentPresetOverrideEnvelope { + runAgentPresetPermissionsMigration(); const row = getSettings(); return readAgentPresetOverrides(row.agentPresetOverrides); } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts index 7ad12334bab..65c1c7e5ca6 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts @@ -3,7 +3,7 @@ import { getSmallModel } from "@superset/chat/server/shared"; import { sanitizeBranchNameWithMaxLength } from "shared/utils/branch"; const BRANCH_NAME_INSTRUCTIONS = - "Generate a concise git branch name (2-4 words, kebab-case, descriptive). Return ONLY the branch name, nothing else."; + "Generate a concise git branch name (2-4 words, kebab-case, descriptive, 20 characters or less). Return ONLY the branch name, nothing else."; const MAX_CONFLICT_RESOLUTION_ATTEMPTS = 1000; const INITIAL_CONFLICT_SUFFIX = 2; @@ -58,7 +58,7 @@ export async function generateBranchNameFromPrompt( existingBranches: string[], branchPrefix?: string, ): Promise { - const model = getSmallModel(); + const model = await getSmallModel(); if (!model) return null; let generated: string | null; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts index 5fa3df6dff0..7926fa98075 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; const getSmallModelMock = mock( - (() => null) as (...args: unknown[]) => unknown | null, + (async () => null) as (...args: unknown[]) => Promise, ); const generateTitleFromMessageMock = mock( (async () => null) as (...args: unknown[]) => Promise, @@ -79,7 +79,7 @@ const { describe("generateWorkspaceNameFromPrompt", () => { beforeEach(() => { getSmallModelMock.mockClear(); - getSmallModelMock.mockReturnValue(null); + getSmallModelMock.mockResolvedValue(null); generateTitleFromMessageMock.mockClear(); generateTitleFromMessageMock.mockResolvedValue(null); selectGetMock.mockReset(); @@ -102,7 +102,7 @@ describe("generateWorkspaceNameFromPrompt", () => { }); it("returns the model-generated title when a model is available", async () => { - getSmallModelMock.mockReturnValueOnce({ id: "test-model" }); + getSmallModelMock.mockResolvedValueOnce({ id: "test-model" }); generateTitleFromMessageMock.mockResolvedValueOnce("Checking In"); await expect( @@ -116,13 +116,14 @@ describe("generateWorkspaceNameFromPrompt", () => { agentModel: { id: "test-model" }, agentId: "workspace-namer", agentName: "Workspace Namer", - instructions: "You generate concise workspace titles.", + instructions: + "You generate concise workspace titles. 20 characters or less. Return ONLY the title, nothing else.", tracingContext: { surface: "workspace-auto-name" }, }); }); it("preserves empty-string model results instead of forcing fallback", async () => { - getSmallModelMock.mockReturnValueOnce({ id: "test-model" }); + getSmallModelMock.mockResolvedValueOnce({ id: "test-model" }); generateTitleFromMessageMock.mockResolvedValueOnce(""); await expect( @@ -134,7 +135,7 @@ describe("generateWorkspaceNameFromPrompt", () => { }); it("falls back when generation throws", async () => { - getSmallModelMock.mockReturnValueOnce({ id: "test-model" }); + getSmallModelMock.mockResolvedValueOnce({ id: "test-model" }); generateTitleFromMessageMock.mockRejectedValueOnce(new Error("boom")); await expect( @@ -155,7 +156,7 @@ afterAll(() => { describe("attemptWorkspaceAutoRenameFromPrompt", () => { beforeEach(() => { getSmallModelMock.mockClear(); - getSmallModelMock.mockReturnValue(null); + getSmallModelMock.mockResolvedValue(null); generateTitleFromMessageMock.mockClear(); generateTitleFromMessageMock.mockResolvedValue(null); selectGetMock.mockReset(); @@ -196,7 +197,7 @@ describe("attemptWorkspaceAutoRenameFromPrompt", () => { isUnnamed: true, deletingAt: null, }); - getSmallModelMock.mockReturnValueOnce({ id: "test-model" }); + getSmallModelMock.mockResolvedValueOnce({ id: "test-model" }); generateTitleFromMessageMock.mockResolvedValueOnce(""); await expect( diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts index 1bb06606be5..0df5e43de28 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts @@ -34,7 +34,7 @@ export async function generateWorkspaceNameFromPrompt(prompt: string): Promise<{ usedPromptFallback: boolean; warning?: string; }> { - const model = getSmallModel(); + const model = await getSmallModel(); if (model) { try { const generated = await generateTitleFromMessage({ @@ -42,7 +42,8 @@ export async function generateWorkspaceNameFromPrompt(prompt: string): Promise<{ agentModel: model, agentId: "workspace-namer", agentName: "Workspace Namer", - instructions: "You generate concise workspace titles.", + instructions: + "You generate concise workspace titles. 20 characters or less. Return ONLY the title, nothing else.", tracingContext: { surface: "workspace-auto-name" }, }); if (generated !== null && generated !== undefined) { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 7f3058e6705..16a7499fd29 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -1807,18 +1807,46 @@ export async function createWorktreeFromPr({ }); } - await execWithShellEnv( - "gh", - [ - "pr", - "checkout", - String(prInfo.number), - "--branch", - localBranchName, - "--force", - ], - { cwd: worktreePath, timeout: 120_000 }, - ); + try { + await execWithShellEnv( + "gh", + [ + "pr", + "checkout", + String(prInfo.number), + "--branch", + localBranchName, + "--force", + ], + { cwd: worktreePath, timeout: 120_000 }, + ); + } catch (ghError) { + const ghMsg = + ghError instanceof Error ? ghError.message : String(ghError); + // `gh pr checkout` can fail with "is not a branch" when the branch name + // contains '/' (e.g. "user/feature-branch"). Git has trouble resolving + // "origin/user/feature-branch" as a tracking ref inside a worktree. + // gh already fetched the remote successfully, so FETCH_HEAD points to + // the right commit โ€” fall back to creating the branch without tracking. + if (!ghMsg.includes("is not a branch")) { + throw ghError; + } + console.log( + `[git] gh pr checkout failed with tracking error for PR #${prInfo.number}, falling back to FETCH_HEAD checkout`, + ); + await execGitWithShellPath( + [ + "-C", + worktreePath, + "checkout", + "-B", + localBranchName, + "--no-track", + "FETCH_HEAD", + ], + { timeout: 30_000 }, + ); + } // Enable autoSetupRemote so `git push` just works without -u flag. await execGitWithShellPath( diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts index ac2809dd786..60808ff763f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts @@ -1,17 +1,12 @@ +// v1-only. Dies with the v1 UI sunset. Don't evolve this module โ€” v2 already +// resolves PRs via host-service (`packages/host-service/src/runtime/pull-requests` +// backing `git.getPullRequest` + `pullRequests.getByWorkspaces`). Everything +// under `renderer/screens/main/` + `routes/_authenticated/_dashboard/workspace/` +// gets deleted together; no port needed. import type { CheckItem, GitHubStatus } from "@superset/local-db"; import { execGitWithShellPath } from "../git-client"; import { execWithShellEnv } from "../shell-env"; -import { - clearCachedNoPullRequestMatch, - hasCachedNoPullRequestMatch, - makeGitHubNoPullRequestCacheKey, - setCachedNoPullRequestMatch, -} from "./cache"; -import { - trackGitHubOperation, - trackGitHubOperationEvent, -} from "./github-metrics"; -import { getPullRequestRepoNamesForWorktree } from "./repo-context"; +import { getPullRequestRepoArgs } from "./repo-context"; import { type GHPRResponse, GHPRResponseSchema, @@ -19,20 +14,7 @@ import { } from "./types"; const PR_JSON_FIELDS = - "number,title,url,state,isDraft,mergedAt,additions,deletions,headRefOid,headRefName,headRepository,headRepositoryOwner,isCrossRepository,reviewDecision,statusCheckRollup,reviewRequests,assignees"; - -function getPullRequestRepoArgSets(repoNames: string[]): string[][] { - if (repoNames.length === 0) { - return [[]]; - } - - return repoNames.map((repoName) => ["--repo", repoName]); -} - -interface PullRequestLookupResult { - pr: GitHubStatus["pr"]; - hadLookupFailure: boolean; -} + "number,title,url,state,isDraft,mergedAt,additions,deletions,headRefOid,headRefName,headRepository,headRepositoryOwner,isCrossRepository,reviewDecision,statusCheckRollup,reviewRequests"; export async function getPRForBranch( worktreePath: string, @@ -40,55 +22,26 @@ export async function getPRForBranch( repoContext?: RepoContext, headSha?: string, ): Promise { - const noPullRequestCacheKey = makeGitHubNoPullRequestCacheKey({ - worktreePath, - localBranch, - headSha, - }); - if (hasCachedNoPullRequestMatch(noPullRequestCacheKey)) { - return null; - } - const byTracking = await getPRByBranchTracking( worktreePath, localBranch, headSha, ); if (byTracking) { - clearCachedNoPullRequestMatch(noPullRequestCacheKey); return byTracking; } - const repoNames = await getPullRequestRepoNamesForWorktree({ - worktreePath, - repoContext, - }); - const byHeadBranch = await findPRByHeadBranch( worktreePath, localBranch, - repoNames, - headSha, - ); - if (byHeadBranch.pr) { - clearCachedNoPullRequestMatch(noPullRequestCacheKey); - return byHeadBranch.pr; - } - - const byHeadCommit = await findPRByHeadCommit( - worktreePath, - repoNames, + repoContext, headSha, ); - if (byHeadCommit.pr) { - clearCachedNoPullRequestMatch(noPullRequestCacheKey); - return byHeadCommit.pr; + if (byHeadBranch) { + return byHeadBranch; } - if (!byHeadBranch.hadLookupFailure && !byHeadCommit.hadLookupFailure) { - setCachedNoPullRequestMatch(noPullRequestCacheKey); - } - return null; + return findPRByHeadCommit(worktreePath, repoContext, headSha); } /** @@ -132,7 +85,10 @@ function getForkOwnerPrefix( export function prMatchesLocalBranch( localBranch: string, - pr: Pick, + pr: Pick< + GHPRResponse, + "headRefName" | "headRepositoryOwner" | "isCrossRepository" + >, ): boolean { if (!branchMatchesPR(localBranch, pr.headRefName)) { return false; @@ -140,6 +96,9 @@ export function prMatchesLocalBranch( const ownerPrefix = getForkOwnerPrefix(localBranch, pr.headRefName); if (!ownerPrefix) { + // Without a fork-owner prefix in the local branch, a cross-fork PR whose + // headRefName collides (e.g. fork:main โ†’ base:main) would misattribute. + if (pr.isCrossRepository) return false; return localBranch === pr.headRefName; } @@ -221,20 +180,12 @@ async function getPRByBranchTracking( localBranch: string, headSha?: string, ): Promise { - const startedAt = Date.now(); try { const { stdout } = await execWithShellEnv( "gh", ["pr", "view", "--json", PR_JSON_FIELDS], { cwd: worktreePath }, ); - trackGitHubOperationEvent({ - name: "gh_pr_view", - category: "gh", - worktreePath, - success: true, - durationMs: Date.now() - startedAt, - }); const data = parsePRResponse(stdout); if (!data) { @@ -255,23 +206,8 @@ async function getPRByBranchTracking( error instanceof Error && error.message.toLowerCase().includes("no pull requests found") ) { - trackGitHubOperationEvent({ - name: "gh_pr_view_no_match", - category: "gh", - worktreePath, - success: true, - durationMs: Date.now() - startedAt, - }); return null; } - trackGitHubOperationEvent({ - name: "gh_pr_view", - category: "gh", - worktreePath, - success: false, - durationMs: Date.now() - startedAt, - error, - }); throw error; } } @@ -283,73 +219,42 @@ async function getPRByBranchTracking( async function findPRByHeadBranch( worktreePath: string, localBranch: string, - repoNames: string[], + repoContext?: RepoContext, headSha?: string, -): Promise { +): Promise { try { const matches = new Map(); - const repoArgSets = getPullRequestRepoArgSets(repoNames); - let hadLookupFailure = false; - - for (const repoArgs of repoArgSets) { - for (const branchCandidate of getPRHeadBranchCandidates(localBranch)) { - let stdout: string; - try { - ({ stdout } = await trackGitHubOperation({ - name: "gh_pr_list_by_head_branch", - category: "gh", - worktreePath, - fn: () => - execWithShellEnv( - "gh", - [ - "pr", - "list", - ...repoArgs, - "--state", - "all", - "--head", - branchCandidate, - "--limit", - "20", - "--json", - PR_JSON_FIELDS, - ], - { cwd: worktreePath }, - ), - })); - } catch (error) { - hadLookupFailure = true; - console.warn( - "[GitHub/findPRByHeadBranch] Failed repo-scoped PR lookup:", - { - worktreePath, - repoArgs, - branchCandidate, - message: error instanceof Error ? error.message : String(error), - }, - ); - continue; - } - for (const candidate of parsePRListResponse(stdout)) { - if (shouldAcceptPRMatch({ localBranch, pr: candidate, headSha })) { - matches.set(candidate.number, candidate); - } + for (const branchCandidate of getPRHeadBranchCandidates(localBranch)) { + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + ...getPullRequestRepoArgs(repoContext), + "--state", + "all", + "--head", + branchCandidate, + "--limit", + "20", + "--json", + PR_JSON_FIELDS, + ], + { cwd: worktreePath }, + ); + + for (const candidate of parsePRListResponse(stdout)) { + if (shouldAcceptPRMatch({ localBranch, pr: candidate, headSha })) { + matches.set(candidate.number, candidate); } } } const bestMatch = sortPRCandidates([...matches.values()], headSha)[0]; - return { - pr: bestMatch ? formatPRData(bestMatch) : null, - hadLookupFailure, - }; + return bestMatch ? formatPRData(bestMatch) : null; } catch { - return { - pr: null, - hadLookupFailure: true, - }; + return null; } } @@ -359,9 +264,9 @@ async function findPRByHeadBranch( */ async function findPRByHeadCommit( worktreePath: string, - repoNames: string[], + repoContext?: RepoContext, providedSha?: string, -): Promise { +): Promise { try { let headSha = providedSha; if (!headSha) { @@ -372,70 +277,39 @@ async function findPRByHeadCommit( headSha = headOutput.trim(); } if (!headSha) { - return { - pr: null, - hadLookupFailure: false, - }; + return null; } - const exactHeadMatches: GHPRResponse[] = []; - let hadLookupFailure = false; - for (const repoArgs of getPullRequestRepoArgSets(repoNames)) { - let stdout: string; - try { - ({ stdout } = await trackGitHubOperation({ - name: "gh_pr_list_by_head_commit", - category: "gh", - worktreePath, - fn: () => - execWithShellEnv( - "gh", - [ - "pr", - "list", - ...repoArgs, - "--state", - "all", - "--search", - `${headSha} is:pr`, - "--limit", - "20", - "--json", - PR_JSON_FIELDS, - ], - { cwd: worktreePath }, - ), - })); - } catch (error) { - hadLookupFailure = true; - console.warn( - "[GitHub/findPRByHeadCommit] Failed repo-scoped PR lookup:", - { - worktreePath, - repoArgs, - headSha, - message: error instanceof Error ? error.message : String(error), - }, - ); - continue; - } + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + ...getPullRequestRepoArgs(repoContext), + "--state", + "all", + "--search", + `${headSha} is:pr`, + "--limit", + "20", + "--json", + PR_JSON_FIELDS, + ], + { cwd: worktreePath }, + ); - const candidates = parsePRListResponse(stdout); - exactHeadMatches.push( - ...candidates.filter((candidate) => candidate.headRefOid === headSha), - ); + const candidates = parsePRListResponse(stdout); + const exactHeadMatches = candidates.filter( + (candidate) => candidate.headRefOid === headSha, + ); + const bestMatch = sortPRCandidates(exactHeadMatches, headSha)[0]; + if (bestMatch) { + return formatPRData(bestMatch); } - const bestMatch = sortPRCandidates(exactHeadMatches, headSha)[0]; - return { - pr: bestMatch ? formatPRData(bestMatch) : null, - hadLookupFailure, - }; + return null; } catch { - return { - pr: null, - hadLookupFailure: true, - }; + return null; } } @@ -512,7 +386,6 @@ function formatPRData(data: GHPRResponse): NonNullable { checksStatus: computeChecksStatus(data.statusCheckRollup), checks: parseChecks(data.statusCheckRollup), requestedReviewers: parseReviewRequests(data.reviewRequests), - assignees: parseAssignees(data.assignees), }; } @@ -523,11 +396,6 @@ function parseReviewRequests( return requests.map((r) => r.login || r.slug || r.name || "").filter(Boolean); } -function parseAssignees(assignees: GHPRResponse["assignees"]): string[] { - if (!assignees || assignees.length === 0) return []; - return assignees.map((assignee) => assignee.login || "").filter(Boolean); -} - function mapPRState( state: GHPRResponse["state"], isDraft: boolean, diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 4012ce15d87..7242e253db7 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -7,9 +7,9 @@ import { dialog, Menu, nativeTheme, - shell, webContents, } from "electron"; +import { safeOpenExternal } from "main/lib/safe-url"; interface ConsoleEntry { level: "log" | "warn" | "error" | "info" | "debug"; @@ -975,7 +975,9 @@ class BrowserManager extends EventEmitter { menuItems.push( { label: "Open Link in Default Browser", - click: () => shell.openExternal(linkURL), + click: () => { + void safeOpenExternal(linkURL); + }, }, { label: "Open Link as New Split", @@ -1049,7 +1051,7 @@ class BrowserManager extends EventEmitter { label: "Open Page in Default Browser", click: () => { if (pageURL && pageURL !== "about:blank") { - shell.openExternal(pageURL); + void safeOpenExternal(pageURL); } }, enabled: !!pageURL && pageURL !== "about:blank", diff --git a/apps/desktop/src/main/lib/safe-url/index.ts b/apps/desktop/src/main/lib/safe-url/index.ts new file mode 100644 index 00000000000..cec73bbf341 --- /dev/null +++ b/apps/desktop/src/main/lib/safe-url/index.ts @@ -0,0 +1,2 @@ +export { safeOpenExternal } from "./safe-url"; +export { externalUrlLogLabel, isSafeExternalUrl } from "./scheme"; diff --git a/apps/desktop/src/main/lib/safe-url/safe-url.test.ts b/apps/desktop/src/main/lib/safe-url/safe-url.test.ts new file mode 100644 index 00000000000..33340bd175f --- /dev/null +++ b/apps/desktop/src/main/lib/safe-url/safe-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import { externalUrlLogLabel, isSafeExternalUrl } from "./scheme"; + +describe("isSafeExternalUrl", () => { + it("allows http, https, and mailto URLs", () => { + expect(isSafeExternalUrl("http://example.com")).toBe(true); + expect(isSafeExternalUrl("https://example.com/path?q=1")).toBe(true); + expect(isSafeExternalUrl("mailto:user@example.com")).toBe(true); + expect(isSafeExternalUrl("HTTPS://EXAMPLE.COM")).toBe(true); + }); + + it("blocks file, javascript, data, and custom-scheme URLs", () => { + expect( + isSafeExternalUrl("file:///System/Applications/Calculator.app"), + ).toBe(false); + expect(isSafeExternalUrl("file:///etc/passwd")).toBe(false); + expect(isSafeExternalUrl("javascript:alert(1)")).toBe(false); + expect(isSafeExternalUrl("data:text/html,")).toBe( + false, + ); + expect(isSafeExternalUrl("vscode://open?url=evil")).toBe(false); + expect(isSafeExternalUrl("ssh://user@host")).toBe(false); + expect(isSafeExternalUrl("ftp://example.com")).toBe(false); + }); + + it("blocks malformed input", () => { + expect(isSafeExternalUrl("")).toBe(false); + expect(isSafeExternalUrl("not a url")).toBe(false); + expect(isSafeExternalUrl("/etc/passwd")).toBe(false); + }); +}); + +describe("externalUrlLogLabel", () => { + it("returns only the scheme, never the full URL", () => { + expect(externalUrlLogLabel("https://example.com/path?token=secret")).toBe( + "https:", + ); + expect(externalUrlLogLabel("file:///etc/passwd")).toBe("file:"); + expect(externalUrlLogLabel("mailto:user@example.com")).toBe("mailto:"); + }); + + it("returns sentinels for empty and malformed input", () => { + expect(externalUrlLogLabel("")).toBe("empty"); + expect(externalUrlLogLabel("not a url")).toBe("malformed"); + }); +}); diff --git a/apps/desktop/src/main/lib/safe-url/safe-url.ts b/apps/desktop/src/main/lib/safe-url/safe-url.ts new file mode 100644 index 00000000000..3ac8fb349d5 --- /dev/null +++ b/apps/desktop/src/main/lib/safe-url/safe-url.ts @@ -0,0 +1,29 @@ +import { shell } from "electron"; +import { externalUrlLogLabel, isSafeExternalUrl } from "./scheme"; + +/** + * Wraps `shell.openExternal` with a scheme allowlist. Returns false and + * refuses to dispatch when the URL is not http(s)/mailto. Catches + * `shell.openExternal` rejections so callers can fire-and-forget without + * risking an unhandled rejection in the Electron main process. + */ +export async function safeOpenExternal(url: string): Promise { + if (!isSafeExternalUrl(url)) { + console.warn( + "[safeOpenExternal] blocked unsafe URL scheme:", + externalUrlLogLabel(url), + ); + return false; + } + try { + await shell.openExternal(url); + return true; + } catch (error) { + console.error( + "[safeOpenExternal] openExternal failed:", + externalUrlLogLabel(url), + error, + ); + return false; + } +} diff --git a/apps/desktop/src/main/lib/safe-url/scheme.ts b/apps/desktop/src/main/lib/safe-url/scheme.ts new file mode 100644 index 00000000000..0ca05a82aac --- /dev/null +++ b/apps/desktop/src/main/lib/safe-url/scheme.ts @@ -0,0 +1,24 @@ +/** + * Schemes safe to hand to Electron's `shell.openExternal`. + * Anything else (file:, javascript:, custom handlers, etc.) can execute + * binaries or scripts via the OS URL handler registry. + */ +const ALLOWED_SCHEMES = new Set(["http:", "https:", "mailto:"]); + +export function isSafeExternalUrl(url: string): boolean { + if (typeof url !== "string" || url.length === 0) return false; + try { + return ALLOWED_SCHEMES.has(new URL(url).protocol); + } catch { + return false; + } +} + +export function externalUrlLogLabel(url: string): string { + if (typeof url !== "string" || url.length === 0) return "empty"; + try { + return new URL(url).protocol || "unknown:"; + } catch { + return "malformed"; + } +} diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index f04d7d75a50..b4fb5cffe70 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -11,8 +11,7 @@ import { } from "../../terminal-host/client"; import type { ListSessionsResponse } from "../../terminal-host/types"; import { raceWithAbort, throwIfAborted } from "../abort"; -import { getDefaultShell } from "../env"; -import { buildTerminalEnv } from "../env-terminal"; +import { buildTerminalEnv, getDefaultShell } from "../env"; import { TerminalKilledError } from "../errors"; import { portManager } from "../port-manager"; import type { CreateSessionParams, SessionResult } from "../types"; @@ -30,8 +29,6 @@ import type { ColdRestoreInfo, SessionInfo } from "./types"; interface PendingCreateOrAttach { requestId: string; joinPending: boolean; - cols?: number; - rows?: number; abortController: AbortController; promise: Promise; } @@ -194,7 +191,7 @@ export class DaemonTerminalManager extends EventEmitter { session.lastActive = Date.now(); } - portManager.checkOutputForHint(data, paneId); + portManager.checkOutputForHint(data); this.historyManager.writeToHistory(paneId, data, () => this.sessions.get(paneId), ); @@ -302,52 +299,16 @@ export class DaemonTerminalManager extends EventEmitter { const requestId = params.requestId ?? `${paneId}:${Date.now()}`; const joinPending = params.joinPending ?? false; const pending = this.pendingSessions.get(paneId); - const requestHasExplicitSize = - Number.isInteger(params.cols) && - Number.isInteger(params.rows) && - (params.cols ?? 0) > 0 && - (params.rows ?? 0) > 0; if (pending) { - const pendingHasExplicitSize = - Number.isInteger(pending.cols) && - Number.isInteger(pending.rows) && - (pending.cols ?? 0) > 0 && - (pending.rows ?? 0) > 0; - const pendingSizeMatchesRequest = - pending.cols === params.cols && pending.rows === params.rows; - const shouldSupersedePending = - requestHasExplicitSize && - (!pendingHasExplicitSize || !pendingSizeMatchesRequest); - - if (shouldSupersedePending) { - if (DEBUG_TERMINAL) { - console.log( - "[DaemonTerminalManager] Superseding pending createOrAttach with explicit size", - { - paneId, - requestId, - pendingRequestId: pending.requestId, - pendingCols: pending.cols ?? null, - pendingRows: pending.rows ?? null, - nextCols: params.cols ?? null, - nextRows: params.rows ?? null, - pendingJoinPending: pending.joinPending, - joinPending, - }, - ); - } - pending.abortController.abort(); - this.pendingSessions.delete(paneId); - } else if ( + if ( pending.requestId === requestId || joinPending || pending.joinPending ) { return pending.promise; - } else { - pending.abortController.abort(); - this.pendingSessions.delete(paneId); } + pending.abortController.abort(); + this.pendingSessions.delete(paneId); } const abortController = new AbortController(); @@ -358,8 +319,6 @@ export class DaemonTerminalManager extends EventEmitter { const entry: PendingCreateOrAttach = { requestId, joinPending, - cols: params.cols, - rows: params.rows, abortController, promise, }; @@ -682,28 +641,15 @@ export class DaemonTerminalManager extends EventEmitter { } } - write(params: { - paneId: string; - data: string; - requireAck?: boolean; - interactive?: boolean; - }): Promise | void { - const { paneId, data, requireAck = false, interactive } = params; + write(params: { paneId: string; data: string }): void { + const { paneId, data } = params; const session = this.sessions.get(paneId); if (!session || !session.isAlive) { throw new Error(`Terminal session ${paneId} not found or not alive`); } - // Critical one-shot commands like workspace setup should fail loudly if - // the daemon didn't actually accept the write. - if (requireAck) { - return this.client - .write({ sessionId: paneId, data, interactive }) - .then(() => {}); - } - - this.client.writeNoAck({ sessionId: paneId, data, interactive }); + this.client.writeNoAck({ sessionId: paneId, data }); } ackColdRestore(paneId: string): void { diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 36d5686166b..883582ba935 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -1,9 +1,21 @@ import { exec } from "node:child_process"; +import fs from "node:fs"; import os from "node:os"; import defaultShell from "default-shell"; +import { env } from "shared/env.shared"; +import { getShellEnv } from "../agent-setup/shell-wrappers"; +const MACOS_SYSTEM_CERT_FILE = "/etc/ssl/cert.pem"; let cachedUtf8Locale: string | null = null; let localeProbeInFlight = false; +const PROCESS_ENV_SNAPSHOT_CACHE_TTL_MS = 1_000; + +let cachedProcessEnvSnapshot: { + raw: Record; + safe: Record; + expiresAt: number; +} | null = null; +let cachedMacosSystemCertAvailable: boolean | null = null; function startLocaleProbe(): void { if (cachedUtf8Locale || localeProbeInFlight) return; @@ -97,20 +109,6 @@ export function getLocale(baseEnv: Record): string { return cachedUtf8Locale; } -export function sanitizeEnv( - env: NodeJS.ProcessEnv, -): Record | undefined { - const sanitized: Record = {}; - - for (const [key, value] of Object.entries(env)) { - if (typeof value === "string") { - sanitized[key] = value; - } - } - - return Object.keys(sanitized).length > 0 ? sanitized : undefined; -} - /** * Precompute expensive locale fallback resolution early in app startup so * the first terminal create/attach path does not pay a synchronous probe. @@ -129,7 +127,51 @@ export function prewarmTerminalEnv(): void { startLocaleProbe(); } +export function sanitizeEnv( + env: NodeJS.ProcessEnv, +): Record | undefined { + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") { + sanitized[key] = value; + } + } + + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function getProcessEnvSnapshot(): { + raw: Record; + safe: Record; +} { + const now = Date.now(); + if (cachedProcessEnvSnapshot && cachedProcessEnvSnapshot.expiresAt > now) { + return cachedProcessEnvSnapshot; + } + + const raw = sanitizeEnv(process.env) || {}; + const safe = buildSafeEnv(raw); + cachedProcessEnvSnapshot = { + raw, + safe, + expiresAt: now + PROCESS_ENV_SNAPSHOT_CACHE_TTL_MS, + }; + return cachedProcessEnvSnapshot; +} + +function hasMacosSystemCertBundle(): boolean { + if (cachedMacosSystemCertAvailable !== null) { + return cachedMacosSystemCertAvailable; + } + + cachedMacosSystemCertAvailable = fs.existsSync(MACOS_SYSTEM_CERT_FILE); + return cachedMacosSystemCertAvailable; +} + export function resetTerminalEnvCachesForTests(): void { + cachedProcessEnvSnapshot = null; + cachedMacosSystemCertAvailable = null; cachedUtf8Locale = null; localeProbeInFlight = false; } @@ -376,3 +418,81 @@ export function buildSafeEnv( return safe; } + +/** + * @deprecated Use buildSafeEnv instead. Kept for backward compatibility. + */ +export function removeAppEnvVars( + env: Record, +): Record { + return buildSafeEnv(env); +} + +export function buildTerminalEnv(params: { + shell: string; + paneId: string; + tabId: string; + workspaceId: string; + workspaceName?: string; + workspacePath?: string; + rootPath?: string; + themeType?: "dark" | "light"; +}): Record { + const { + shell, + paneId, + tabId, + workspaceId, + workspaceName, + workspacePath, + rootPath, + themeType, + } = params; + + // Get Electron's process.env and filter to only allowlisted safe vars + // This prevents secrets and app config from leaking to user terminals + const { raw: rawBaseEnv, safe: baseEnv } = getProcessEnvSnapshot(); + + // shellEnv provides shell wrapper control variables (ZDOTDIR, BASH_ENV, etc.) + // These configure how the shell initializes, not the user's actual environment + const shellEnv = getShellEnv(shell); + const locale = getLocale(rawBaseEnv); + + // COLORFGBG: "foreground;background" ANSI color indices โ€” TUI apps use this to detect light/dark + const colorFgBg = themeType === "light" ? "0;15" : "15;0"; + + const terminalEnv: Record = { + ...baseEnv, + ...shellEnv, + TERM_PROGRAM: "Superset", + TERM_PROGRAM_VERSION: process.env.npm_package_version || "1.0.0", + COLORTERM: "truecolor", + COLORFGBG: colorFgBg, + LANG: locale, + SUPERSET_PANE_ID: paneId, + SUPERSET_TAB_ID: tabId, + SUPERSET_WORKSPACE_ID: workspaceId, + SUPERSET_WORKSPACE_NAME: workspaceName || "", + SUPERSET_WORKSPACE_PATH: workspacePath || "", + SUPERSET_ROOT_PATH: rootPath || "", + SUPERSET_PORT: String(env.DESKTOP_NOTIFICATIONS_PORT), + // Environment identifier for dev/prod separation + SUPERSET_ENV: env.NODE_ENV === "development" ? "development" : "production", + // Hook protocol version for forward compatibility + SUPERSET_HOOK_VERSION: HOOK_PROTOCOL_VERSION, + }; + + delete terminalEnv.GOOGLE_API_KEY; + + // Electron child processes can't access macOS Keychain for TLS cert verification, + // causing "x509: OSStatus -26276" in Go binaries like `gh`. File-based fallback. + if ( + os.platform() === "darwin" && + !terminalEnv.SSL_CERT_FILE && + hasMacosSystemCertBundle() + ) { + terminalEnv.SSL_CERT_FILE = MACOS_SYSTEM_CERT_FILE; + } + + return terminalEnv; +} diff --git a/apps/desktop/src/main/lib/terminal/port-manager.test.ts b/apps/desktop/src/main/lib/terminal/port-manager.test.ts new file mode 100644 index 00000000000..c70a09e6a88 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/port-manager.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import type { TerminalSession } from "./types"; + +/** + * Regression tests for #3372 ("excessive lsof spawning"). + * + * Three behaviors the fix guarantees: + * 1. No scans run when there are no registered sessions (lifecycle). + * 2. At most one scan is in flight at any moment, even under a flood of + * hint-matching output (concurrency / coalescing). + * 3. stopPeriodicScan aborts any in-flight child so it cannot outlive us + * (no orphan lsof). + * + * The hint regexes that previously matched routine log noise ("port 22", + * trailing ":12345") must no longer trigger scans; the three "listening on โ€ฆ" + * patterns still must. + */ + +interface ScannerSpy { + getProcessTree: number; + getListeningPortsForPids: number; + inFlight: number; + maxInFlight: number; + lastSignal: AbortSignal | undefined; + aborted: number; +} + +const spy: ScannerSpy = { + getProcessTree: 0, + getListeningPortsForPids: 0, + inFlight: 0, + maxInFlight: 0, + lastSignal: undefined, + aborted: 0, +}; + +let lsofDelayMs = 0; + +mock.module("./port-scanner", () => ({ + getProcessTree: async (pid: number) => { + spy.getProcessTree++; + return [pid, pid + 1]; + }, + getListeningPortsForPids: async (_pids: number[], signal?: AbortSignal) => { + spy.getListeningPortsForPids++; + spy.inFlight++; + spy.maxInFlight = Math.max(spy.maxInFlight, spy.inFlight); + spy.lastSignal = signal; + try { + if (lsofDelayMs > 0) { + // Match production: getListeningPortsLsof catches all errors and + // returns []. If we get aborted we just resolve with [] early. + await new Promise((resolve) => { + const timer = setTimeout(resolve, lsofDelayMs); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + spy.aborted++; + resolve(); + }); + }); + } + return []; + } finally { + spy.inFlight--; + } + }, +})); + +mock.module("../tree-kill", () => ({ + treeKillWithEscalation: async () => ({ success: true }), +})); + +const { portManager } = await import("./port-manager"); + +const HINT_DEBOUNCE_MS = 500; +const PAST_DEBOUNCE_MS = HINT_DEBOUNCE_MS + 50; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const makeSession = (paneId: string, pid: number): TerminalSession => + ({ paneId, isAlive: true, pty: { pid } }) as unknown as TerminalSession; + +// biome-ignore lint/suspicious/noExplicitAny: reach into private singleton state +const pmInternals = () => portManager as any; + +function resetSpy(): void { + spy.getProcessTree = 0; + spy.getListeningPortsForPids = 0; + spy.inFlight = 0; + spy.maxInFlight = 0; + spy.lastSignal = undefined; + spy.aborted = 0; + lsofDelayMs = 0; +} + +function resetManager(): void { + const internals = pmInternals(); + for (const paneId of Array.from(internals.sessions.keys())) { + portManager.unregisterSession(paneId); + } + for (const paneId of Array.from(internals.daemonSessions.keys())) { + portManager.unregisterDaemonSession(paneId); + } + portManager.stopPeriodicScan(); +} + +beforeEach(() => { + resetSpy(); + resetManager(); +}); + +afterEach(() => { + resetManager(); +}); + +describe("PortManager โ€” #3372 lifecycle (interval runs only with sessions)", () => { + it("forceScan is a no-op when no sessions are registered", async () => { + await portManager.forceScan(); + expect(spy.getProcessTree).toBe(0); + expect(spy.getListeningPortsForPids).toBe(0); + }); + + it("first registered session starts the interval; last unregister stops it", () => { + expect(pmInternals().scanInterval).toBeNull(); + + portManager.registerSession(makeSession("p1", 1000), "ws1"); + expect(pmInternals().scanInterval).not.toBeNull(); + + portManager.unregisterSession("p1"); + expect(pmInternals().scanInterval).toBeNull(); + }); + + it("daemon sessions control the interval the same way", () => { + portManager.upsertDaemonSession("pd1", "ws1", 2000); + expect(pmInternals().scanInterval).not.toBeNull(); + + portManager.unregisterDaemonSession("pd1"); + expect(pmInternals().scanInterval).toBeNull(); + }); + + it("mixed session types: interval stops only when all are gone", () => { + portManager.registerSession(makeSession("p1", 1000), "ws1"); + portManager.upsertDaemonSession("pd1", "ws2", 2000); + + portManager.unregisterSession("p1"); + expect(pmInternals().scanInterval).not.toBeNull(); + + portManager.unregisterDaemonSession("pd1"); + expect(pmInternals().scanInterval).toBeNull(); + }); + + it("re-registering after idle restarts the interval", () => { + portManager.registerSession(makeSession("p1", 1000), "ws1"); + portManager.unregisterSession("p1"); + expect(pmInternals().scanInterval).toBeNull(); + + portManager.registerSession(makeSession("p2", 1001), "ws1"); + expect(pmInternals().scanInterval).not.toBeNull(); + }); +}); + +describe("PortManager โ€” #3372 concurrency (at most one lsof in flight)", () => { + it("bulk scan batches every session into a single lsof call", async () => { + for (let i = 0; i < 10; i++) { + portManager.registerSession(makeSession(`p${i}`, 1000 + i), `ws${i}`); + } + await portManager.forceScan(); + + expect(spy.getListeningPortsForPids).toBe(1); + expect(spy.maxInFlight).toBe(1); + }); + + it("a flood of hints coalesces into one follow-up, never concurrent", async () => { + lsofDelayMs = 30; + portManager.registerSession(makeSession("p1", 1000), "ws1"); + + const firstScan = portManager.forceScan(); + + // 100 hints while the first scan is running โ€” all on the hot path. + for (let i = 0; i < 100; i++) { + portManager.checkOutputForHint("listening on port 3000\n"); + } + + await firstScan; + await sleep(PAST_DEBOUNCE_MS); // let the single debounced follow-up run + + expect(spy.maxInFlight).toBe(1); + // Exact โ€” one initial scan + one coalesced follow-up, never more, never fewer. + expect(spy.getListeningPortsForPids).toBe(2); + }); +}); + +describe("PortManager โ€” #3372 hint regex narrowing", () => { + beforeEach(() => { + portManager.registerSession(makeSession("p1", 1000), "ws1"); + resetSpy(); + }); + + it("does NOT scan on a bare 'port 22' (old loose pattern)", async () => { + portManager.checkOutputForHint("connection reached port 22\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(0); + }); + + it("does NOT scan on a trailing ':12345' (old loose pattern)", async () => { + portManager.checkOutputForHint("commit abc123def:12345\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(0); + }); + + it("DOES scan on 'listening on port 3000'", async () => { + portManager.checkOutputForHint("listening on port 3000\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(1); + }); + + it("DOES scan on 'server running at http://localhost:3000'", async () => { + portManager.checkOutputForHint("server running at http://localhost:3000\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(1); + }); + + it("DOES scan on 'ready on http://localhost:5173' (Vite-style)", async () => { + portManager.checkOutputForHint("ready on http://localhost:5173\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(1); + }); +}); + +describe("PortManager โ€” #3372 teardown (no orphan children)", () => { + it("stopPeriodicScan aborts any in-flight lsof", async () => { + lsofDelayMs = 200; + portManager.registerSession(makeSession("p1", 1000), "ws1"); + + const scanPromise = portManager.forceScan(); + // Wait for the lsof stub to start. + await sleep(10); + expect(spy.inFlight).toBe(1); + + portManager.stopPeriodicScan(); + + // The promise resolves (port-scanner swallows its own errors). + await scanPromise; + + expect(spy.aborted).toBeGreaterThanOrEqual(1); + expect(spy.inFlight).toBe(0); + }); + + it("in-flight lsof receives the AbortSignal from the manager", async () => { + lsofDelayMs = 50; + portManager.registerSession(makeSession("p1", 1000), "ws1"); + + const scanPromise = portManager.forceScan(); + await sleep(10); + + expect(spy.lastSignal).toBeDefined(); + expect(spy.lastSignal?.aborted).toBe(false); + + await scanPromise; + }); +}); diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index 4b7fdaf8e7b..eca34d43ac9 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -19,16 +19,16 @@ const IGNORED_PORTS = new Set([22, 80, 443, 5432, 3306, 6379, 27017]); /** * Check if terminal output contains hints that a port may have been opened. - * Common patterns from dev servers, test frameworks, etc. + * Restricted to phrases that strongly imply a server just started listening; + * looser patterns like a bare "port 22" or trailing ":12345" are omitted + * because they match routine log output (ssh banners, timestamps, etc.) and + * triggered excessive lsof scans โ€” see issue #3372. */ function containsPortHint(data: string): boolean { - // Common patterns: "listening on port X", "server started on :X", etc. const portPatterns = [ /listening\s+on\s+(?:port\s+)?(\d+)/i, /server\s+(?:started|running)\s+(?:on|at)\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, /ready\s+on\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, - /port\s+(\d+)/i, - /:(\d{4,5})\s*$/, ]; return portPatterns.some((pattern) => pattern.test(data)); } @@ -62,19 +62,19 @@ class PortManager extends EventEmitter { /** Daemon-mode sessions: paneId โ†’ { workspaceId, pid } */ private daemonSessions = new Map(); private scanInterval: ReturnType | null = null; - private pendingHintScans = new Map>(); + private hintScanTimeout: ReturnType | null = null; private isScanning = false; - - constructor() { - super(); - this.startPeriodicScan(); - } + /** Set when a hint arrives during a scan; triggers one follow-up scan. */ + private scanRequested = false; + /** Aborts any in-flight scan children (lsof/netstat) on teardown. */ + private scanAbort: AbortController | null = null; /** * Register a terminal session for port scanning */ registerSession(session: TerminalSession, workspaceId: string): void { this.sessions.set(session.paneId, { session, workspaceId }); + this.ensurePeriodicScanRunning(); } /** @@ -83,7 +83,7 @@ class PortManager extends EventEmitter { unregisterSession(paneId: string): void { this.sessions.delete(paneId); this.removePortsForPane(paneId); - this.clearPendingHintScan(paneId); + this.stopPeriodicScanIfIdle(); } /** @@ -97,6 +97,7 @@ class PortManager extends EventEmitter { pid: number | null, ): void { this.daemonSessions.set(paneId, { workspaceId, pid }); + this.ensurePeriodicScanRunning(); } /** @@ -105,17 +106,22 @@ class PortManager extends EventEmitter { unregisterDaemonSession(paneId: string): void { this.daemonSessions.delete(paneId); this.removePortsForPane(paneId); - this.clearPendingHintScan(paneId); + this.stopPeriodicScanIfIdle(); } - checkOutputForHint(data: string, paneId: string): void { + checkOutputForHint(data: string): void { if (!containsPortHint(data)) return; - this.scheduleHintScan(paneId); + this.scheduleHintScan(); + } + + private hasAnySessions(): boolean { + return this.sessions.size > 0 || this.daemonSessions.size > 0; } - private startPeriodicScan(): void { + private ensurePeriodicScanRunning(): void { if (this.scanInterval) return; + this.scanAbort = new AbortController(); this.scanInterval = setInterval(() => { this.scanAllSessions().catch((error) => { console.error("[PortManager] Scan error:", error); @@ -126,89 +132,42 @@ class PortManager extends EventEmitter { this.scanInterval.unref(); } + private stopPeriodicScanIfIdle(): void { + if (!this.hasAnySessions()) this.stopPeriodicScan(); + } + stopPeriodicScan(): void { if (this.scanInterval) { clearInterval(this.scanInterval); this.scanInterval = null; } - for (const timeout of this.pendingHintScans.values()) { - clearTimeout(timeout); + if (this.hintScanTimeout) { + clearTimeout(this.hintScanTimeout); + this.hintScanTimeout = null; } - this.pendingHintScans.clear(); - } - private clearPendingHintScan(paneId: string): void { - const pendingTimeout = this.pendingHintScans.get(paneId); - if (pendingTimeout) { - clearTimeout(pendingTimeout); - this.pendingHintScans.delete(paneId); + // Kill any in-flight lsof/netstat so it can't outlive us. + if (this.scanAbort) { + this.scanAbort.abort(); + this.scanAbort = null; } - } - - private scheduleHintScan(paneId: string): void { - this.clearPendingHintScan(paneId); - - const timeout = setTimeout(() => { - this.pendingHintScans.delete(paneId); - this.scanPane(paneId).catch(() => {}); - }, HINT_SCAN_DELAY_MS); - // Don't keep Electron alive just for port scanning - timeout.unref(); - this.pendingHintScans.set(paneId, timeout); + this.scanRequested = false; } - private async scanPidTreeAndUpdate({ - paneId, - workspaceId, - pid, - errorContext, - }: { - paneId: string; - workspaceId: string; - pid: number; - errorContext: string; - }): Promise { - try { - const pids = await getProcessTree(pid); - if (pids.length === 0) { - this.removePortsForPane(paneId); - return; - } - - const portInfos = await getListeningPortsForPids(pids); - this.updatePortsForPane({ paneId, workspaceId, portInfos }); - } catch (error) { - console.error(`[PortManager] Error scanning ${errorContext}:`, error); - } - } - - private async scanPane(paneId: string): Promise { - const registered = this.sessions.get(paneId); - if (registered) { - const { session, workspaceId } = registered; - if (!session.isAlive) return; - await this.scanPidTreeAndUpdate({ - paneId, - workspaceId, - pid: session.pty.pid, - errorContext: `pane ${paneId}`, - }); - return; - } + /** + * Debounce hint-triggered scans into a single follow-up bulk scan. + * Hints arrive on every PTY data chunk; we only need one scan per burst. + */ + private scheduleHintScan(): void { + if (this.hintScanTimeout) return; - const daemonSession = this.daemonSessions.get(paneId); - if (daemonSession) { - const { workspaceId, pid } = daemonSession; - if (pid === null) return; - await this.scanPidTreeAndUpdate({ - paneId, - workspaceId, - pid, - errorContext: `daemon pane ${paneId}`, - }); - } + this.hintScanTimeout = setTimeout(() => { + this.hintScanTimeout = null; + this.scanAllSessions().catch(() => {}); + }, HINT_SCAN_DELAY_MS); + this.hintScanTimeout.unref(); } private createScanState(): ScanState { @@ -307,7 +266,10 @@ class PortManager extends EventEmitter { const allPidList = Array.from(allPids); if (allPidList.length === 0) return portsByPane; - const portInfos = await getListeningPortsForPids(allPidList); + const portInfos = await getListeningPortsForPids( + allPidList, + this.scanAbort?.signal, + ); for (const info of portInfos) { const owner = pidOwnerMap.get(info.pid); if (!owner) continue; @@ -353,7 +315,12 @@ class PortManager extends EventEmitter { } private async scanAllSessions(): Promise { - if (this.isScanning) return; + if (this.isScanning) { + // A hint or tick fired mid-scan; queue exactly one follow-up. + this.scanRequested = true; + return; + } + if (!this.hasAnySessions()) return; this.isScanning = true; try { @@ -375,6 +342,11 @@ class PortManager extends EventEmitter { } finally { this.isScanning = false; } + + if (this.scanRequested && this.hasAnySessions()) { + this.scanRequested = false; + await this.scanAllSessions(); + } } private updatePortsForPane({ diff --git a/apps/desktop/src/main/lib/terminal/port-scanner.ts b/apps/desktop/src/main/lib/terminal/port-scanner.ts index 49247491d46..1b6089de6ad 100644 --- a/apps/desktop/src/main/lib/terminal/port-scanner.ts +++ b/apps/desktop/src/main/lib/terminal/port-scanner.ts @@ -1,9 +1,49 @@ -import { exec } from "node:child_process"; +import { execFile } from "node:child_process"; import os from "node:os"; import { promisify } from "node:util"; import pidtree from "pidtree"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +/** + * Run execFile and tolerate a plain non-zero exit by returning its stdout. + * lsof exits 1 when no PIDs match the filter โ€” a legitimate "empty" result. + * Aborts, timeouts, and signal-kills are NOT tolerated: partial stdout from a + * killed child is not a trustworthy snapshot, so rethrow and let the caller's + * outer catch turn it into `[]`. + */ +async function runTolerant( + file: string, + args: string[], + options: { maxBuffer: number; timeout: number; signal?: AbortSignal }, +): Promise { + try { + const { stdout } = await execFileAsync(file, args, options); + return stdout; + } catch (err) { + if (err && typeof err === "object") { + const execErr = err as { + stdout?: string | Buffer; + code?: unknown; + killed?: boolean; + signal?: unknown; + name?: string; + }; + if ( + execErr.name === "AbortError" || + execErr.code === "ABORT_ERR" || + execErr.killed || + execErr.signal + ) { + throw err; + } + if ("stdout" in execErr) { + return String(execErr.stdout ?? ""); + } + } + throw err; + } +} /** Timeout for shell commands to prevent hanging (ms) */ const EXEC_TIMEOUT_MS = 5000; @@ -33,16 +73,17 @@ export async function getProcessTree(pid: number): Promise { */ export async function getListeningPortsForPids( pids: number[], + signal?: AbortSignal, ): Promise { if (pids.length === 0) return []; const platform = os.platform(); if (platform === "darwin" || platform === "linux") { - return getListeningPortsLsof(pids); + return getListeningPortsLsof(pids, signal); } if (platform === "win32") { - return getListeningPortsWindows(pids); + return getListeningPortsWindows(pids, signal); } return []; @@ -51,7 +92,10 @@ export async function getListeningPortsForPids( /** * macOS/Linux implementation using lsof */ -async function getListeningPortsLsof(pids: number[]): Promise { +async function getListeningPortsLsof( + pids: number[], + signal?: AbortSignal, +): Promise { try { const pidArg = pids.join(","); const pidSet = new Set(pids); @@ -62,9 +106,10 @@ async function getListeningPortsLsof(pids: number[]): Promise { // -n: don't resolve hostnames // Note: lsof may ignore -p filter if PIDs don't exist or have no matches, // so we must validate PIDs in the output against our requested set - const { stdout: output } = await execAsync( - `lsof -p ${pidArg} -iTCP -sTCP:LISTEN -P -n 2>/dev/null || true`, - { maxBuffer: 10 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS }, + const output = await runTolerant( + "lsof", + ["-p", pidArg, "-iTCP", "-sTCP:LISTEN", "-P", "-n"], + { maxBuffer: 10 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS, signal }, ); if (!output.trim()) return []; @@ -116,11 +161,15 @@ async function getListeningPortsLsof(pids: number[]): Promise { /** * Windows implementation using netstat */ -async function getListeningPortsWindows(pids: number[]): Promise { +async function getListeningPortsWindows( + pids: number[], + signal?: AbortSignal, +): Promise { try { - const { stdout: output } = await execAsync("netstat -ano", { + const { stdout: output } = await execFileAsync("netstat", ["-ano"], { maxBuffer: 10 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS, + signal, }); const pidSet = new Set(pids); @@ -149,7 +198,7 @@ async function getListeningPortsWindows(pids: number[]): Promise { const nameResults = await Promise.all( pidsToLookup.map(async (pid) => ({ pid, - name: await getProcessNameWindows(pid), + name: await getProcessNameWindows(pid, signal), })), ); for (const { pid, name } of nameResults) { @@ -195,11 +244,15 @@ async function getListeningPortsWindows(pids: number[]): Promise { /** * Get process name for a PID on Windows */ -async function getProcessNameWindows(pid: number): Promise { +async function getProcessNameWindows( + pid: number, + signal?: AbortSignal, +): Promise { try { - const { stdout: output } = await execAsync( - `wmic process where processid=${pid} get name 2>nul`, - { timeout: EXEC_TIMEOUT_MS }, + const { stdout: output } = await execFileAsync( + "wmic", + ["process", "where", `processid=${pid}`, "get", "name"], + { timeout: EXEC_TIMEOUT_MS, signal }, ); const lines = output.trim().split("\n"); if (lines.length >= 2) { @@ -209,9 +262,10 @@ async function getProcessNameWindows(pid: number): Promise { } catch { // wmic is deprecated, try PowerShell as fallback try { - const { stdout: output } = await execAsync( - `powershell -Command "(Get-Process -Id ${pid}).ProcessName"`, - { timeout: EXEC_TIMEOUT_MS }, + const { stdout: output } = await execFileAsync( + "powershell", + ["-Command", `(Get-Process -Id ${pid}).ProcessName`], + { timeout: EXEC_TIMEOUT_MS, signal }, ); return output.trim() || "unknown"; } catch {} @@ -219,54 +273,76 @@ async function getProcessNameWindows(pid: number): Promise { return "unknown"; } -/** - * Get process name for a PID (cross-platform) - */ export async function getProcessName(pid: number): Promise { - const platform = os.platform(); - - if (platform === "win32") { + // FORK NOTE: Windows ใฏ `ps` ใŒ็„กใ„ใฎใง ENOENT ใงๅธธใซ fallback ใ—ใฆใ—ใพใ†ใ€‚ + // ใƒ—ใƒฉใƒƒใƒˆใƒ•ใ‚ฉใƒผใƒ ๅˆ†ๅฒใงๆ—ขๅญ˜ใฎ Windows ใƒ˜ใƒซใƒ‘ใƒผใธๅง”่ญฒใ™ใ‚‹ใ€‚ + if (os.platform() === "win32") { return getProcessNameWindows(pid); } - - // macOS/Linux try { - const { stdout: output } = await execAsync( - `ps -p ${pid} -o comm= 2>/dev/null || true`, - { timeout: EXEC_TIMEOUT_MS }, + const { stdout: output } = await execFileAsync( + "ps", + ["-p", String(pid), "-o", "comm="], + { + timeout: EXEC_TIMEOUT_MS, + }, ); - const name = output.trim(); - // On macOS, comm may be truncated. The full path can be gotten with -o command= - // but comm is usually sufficient for display purposes - return name || "unknown"; + return output.trim() || "unknown"; } catch { return "unknown"; } } /** - * Full argv of a process (space-joined). Useful when `comm` alone is - * ambiguous โ€” e.g. the `claude` / `codex` CLIs often appear under - * `comm=node` because they are spawned as `node /path/to/bin/claude โ€ฆ`. + * Get the full command line for a PID on Windows. + * Mirrors getProcessNameWindows but returns the full command line + * (wmic `commandline` column, PowerShell `CommandLine` property) so + * pane-resolver / browser-automation can match terminal processes + * cross-platform. */ -export async function getProcessCommand(pid: number): Promise { - const platform = os.platform(); - if (platform === "win32") { +async function getProcessCommandWindows( + pid: number, + signal?: AbortSignal, +): Promise { + try { + const { stdout } = await execFileAsync( + "wmic", + ["process", "where", `processid=${pid}`, "get", "commandline"], + { timeout: EXEC_TIMEOUT_MS, signal }, + ); + const lines = stdout.trim().split("\n"); + if (lines.length >= 2) { + return lines.slice(1).join("\n").trim(); + } + } catch { + // wmic is deprecated, try PowerShell as fallback try { - const { stdout } = await execAsync( - `wmic process where processid=${pid} get commandline 2>nul`, - { timeout: EXEC_TIMEOUT_MS }, + const { stdout } = await execFileAsync( + "powershell", + [ + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`, + ], + { timeout: EXEC_TIMEOUT_MS, signal }, ); - const lines = stdout.trim().split("\n"); - return lines.length >= 2 ? lines.slice(1).join(" ").trim() : ""; - } catch { - return ""; - } + return stdout.trim(); + } catch {} + } + return ""; +} + +export async function getProcessCommand(pid: number): Promise { + // FORK NOTE: Windows ๅˆ†ๅฒ โ€” ไธŠ่จ˜ getProcessName ใจๅŒใ˜็†็”ฑใ€‚ + if (os.platform() === "win32") { + return getProcessCommandWindows(pid); } try { - const { stdout } = await execAsync( - `ps -p ${pid} -o args= 2>/dev/null || true`, - { timeout: EXEC_TIMEOUT_MS }, + const { stdout } = await execFileAsync( + "ps", + ["-p", String(pid), "-o", "args="], + { + timeout: EXEC_TIMEOUT_MS, + }, ); return stdout.trim(); } catch { diff --git a/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts b/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts index 6c79e31d161..6d5876eb8fe 100644 --- a/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts +++ b/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts @@ -121,39 +121,22 @@ function spawnAndReady( // Tests // ============================================================================= -describe("Session shell-ready: write buffering", () => { - it("buffers non-interactive writes while shell is pending and flushes after marker", () => { +describe("Session shell-ready: write pass-through", () => { + it("passes writes through immediately while shell is pending (#3478)", () => { const { session, proc } = createTestSession("/bin/zsh"); spawnAndReady(session, proc); - // Preset/programmatic writes before shell is ready โ€” should be buffered - session.write("echo hello\n"); - session.write("echo world\n"); + // User keystrokes answering a shell-init prompt (e.g. fnm's + // "install missing Node version?") must reach the PTY without + // waiting for OSC 133;A. + session.write("y\n"); + session.write("echo ready\n"); - // No write frames should have been sent yet - expect(getWrittenData(proc)).toEqual([]); + expect(getWrittenData(proc)).toEqual(["y\n", "echo ready\n"]); - // Shell emits the ready marker + // The ready marker arriving later must not re-emit anything. sendData(proc, `direnv output...${SHELL_READY_MARKER}prompt$ `); - - // Now the buffered writes should be flushed in order - const writes = getWrittenData(proc); - expect(writes).toEqual(["echo hello\n", "echo world\n"]); - }); - - it("forwards interactive writes directly to PTY while shell is pending", () => { - const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); - - // Interactive write (user keyboard input) โ€” forwarded immediately - session.write("y", { interactive: true }); - - expect(getWrittenData(proc)).toEqual(["y"]); - - // Shell emits the ready marker โ€” interactive writes continue to work - sendData(proc, `direnv output...${SHELL_READY_MARKER}prompt$ `); - session.write("n", { interactive: true }); - expect(getWrittenData(proc)).toEqual(["y", "n"]); + expect(getWrittenData(proc)).toEqual(["y\n", "echo ready\n"]); }); it("passes writes through immediately for unsupported shells (sh)", () => { @@ -183,45 +166,28 @@ describe("Session shell-ready: write buffering", () => { session.write("\x1b[?62;4;9;22c"); // Simulate cursor position report session.write("\x1b[1;1R"); - // Queue a real preset command + // Regular command also arriving during pending session.write("claude\n"); - // Only the preset command should be in the queue - expect(getWrittenData(proc)).toEqual([]); + // Escape sequences are dropped; the command passes through. + expect(getWrittenData(proc)).toEqual(["claude\n"]); sendData(proc, SHELL_READY_MARKER); - // Only the command should flush โ€” escape sequences dropped + // Nothing new to emit after the marker. expect(getWrittenData(proc)).toEqual(["claude\n"]); }); - it("drops escape sequences even for interactive writes during pending state", () => { - const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); - - // Escape sequences are always dropped during pending, even if interactive - session.write("\x1b[?62;4;9;22c", { interactive: true }); - expect(getWrittenData(proc)).toEqual([]); - - // Regular interactive write goes through - session.write("y", { interactive: true }); - expect(getWrittenData(proc)).toEqual(["y"]); - }); - - it("flushes buffered writes on subprocess exit", async () => { + it("forwards escape sequences once shell is ready", () => { const { session, proc } = createTestSession("/bin/zsh"); spawnAndReady(session, proc); - session.write("echo delayed\n"); - expect(getWrittenData(proc)).toEqual([]); - - // Simulate exit which resolves shell readiness as timed_out - sendExit(proc, 0); - proc.emit("exit", 0); + sendData(proc, SHELL_READY_MARKER); - // Buffered write should now be flushed - const writes = getWrittenData(proc); - expect(writes).toEqual(["echo delayed\n"]); + // After the marker, escape sequences are no longer stale init noise, + // so they pass through (e.g. user pressing arrow keys). + session.write("\x1b[A"); + expect(getWrittenData(proc)).toEqual(["\x1b[A"]); }); }); @@ -250,16 +216,16 @@ describe("Session shell-ready: marker detection", () => { // Send first half โ€” shell should still be pending sendData(proc, `output${firstHalf}`); - // Interactive write forwarded; non-interactive buffered (still pending) - session.write("buffered\n"); - session.write("y", { interactive: true }); - expect(getWrittenData(proc)).toEqual(["y"]); + // Writes pass through even while pending + session.write("first\n"); + expect(getWrittenData(proc)).toEqual(["first\n"]); // Send second half โ€” should complete the marker sendData(proc, `${secondHalf}prompt`); - // Now the buffered write flushes - expect(getWrittenData(proc)).toEqual(["y", "buffered\n"]); + // Post-marker writes still pass through + session.write("second\n"); + expect(getWrittenData(proc)).toEqual(["first\n", "second\n"]); }); it("handles marker at start of data frame", () => { @@ -290,13 +256,13 @@ describe("Session shell-ready: marker detection", () => { const partialMarker = SHELL_READY_MARKER.slice(0, 5); sendData(proc, `${partialMarker}not-a-marker`); - // Shell should still be pending โ€” non-interactive write buffered - session.write("buffered\n"); - expect(getWrittenData(proc)).toEqual([]); + // Writes pass through regardless of marker state. + session.write("first\n"); + expect(getWrittenData(proc)).toEqual(["first\n"]); - // Now send the real marker + // Now send the real marker โ€” no backlog to flush. sendData(proc, SHELL_READY_MARKER); - expect(getWrittenData(proc)).toEqual(["buffered\n"]); + expect(getWrittenData(proc)).toEqual(["first\n"]); }); // Wrappers now emit both the legacy OSC 777 and the current OSC 133;A in @@ -310,45 +276,46 @@ describe("Session shell-ready: marker detection", () => { const { session, proc } = createTestSession("/bin/zsh"); spawnAndReady(session, proc); - session.write("buffered\n"); - expect(getWrittenData(proc)).toEqual([]); - const COMBINED_MARKER = "\x1b]777;superset-shell-ready\x07\x1b]133;A\x07"; sendData(proc, `direnv output...${COMBINED_MARKER}prompt$ `); - expect(getWrittenData(proc)).toEqual(["buffered\n"]); + // Writes after the combined marker pass through (marker detection + // guards future behaviors that may depend on the ready state). + session.write("test\n"); + expect(getWrittenData(proc)).toEqual(["test\n"]); }); }); describe("Session shell-ready: kill/exit before readiness", () => { - it("flushes queue when subprocess exits before marker", () => { + it("accepts writes when subprocess exits before marker", () => { const { session, proc } = createTestSession("/bin/bash"); spawnAndReady(session, proc); + // Writes pass through even during pending. session.write("echo pending\n"); - expect(getWrittenData(proc)).toEqual([]); + expect(getWrittenData(proc)).toEqual(["echo pending\n"]); - // Subprocess exits without ever sending the marker + // Subprocess exits without ever sending the marker โ€” no replay, + // no duplicate writes. sendExit(proc, 1); proc.emit("exit", 1); - // Queue should be flushed on exit expect(getWrittenData(proc)).toEqual(["echo pending\n"]); }); - it("resolves readiness when session is killed", () => { + it("accepts writes when session is killed before marker", () => { const { session, proc } = createTestSession("/bin/zsh"); spawnAndReady(session, proc); session.write("echo pending\n"); - expect(getWrittenData(proc)).toEqual([]); + expect(getWrittenData(proc)).toEqual(["echo pending\n"]); - // Kill triggers termination โ†’ subprocess exit โ†’ readiness resolved + // Kill triggers termination โ†’ subprocess exit โ†’ readiness resolved. + // No buffered replay on exit. session.kill(); sendExit(proc, 0); proc.emit("exit", 0); - // Writes should be flushed expect(getWrittenData(proc)).toEqual(["echo pending\n"]); }); }); @@ -360,24 +327,16 @@ describe("Session shell-ready: supported shells", () => { "/bin/bash", "/usr/local/bin/fish", ]) { - it(`buffers non-interactive writes for supported shell: ${shell}`, () => { + it(`passes writes through while pending for supported shell: ${shell}`, () => { const { session, proc } = createTestSession(shell); spawnAndReady(session, proc); session.write("test\n"); - expect(getWrittenData(proc)).toEqual([]); + expect(getWrittenData(proc)).toEqual(["test\n"]); sendData(proc, SHELL_READY_MARKER); expect(getWrittenData(proc)).toEqual(["test\n"]); }); - - it(`forwards interactive writes for supported shell: ${shell}`, () => { - const { session, proc } = createTestSession(shell); - spawnAndReady(session, proc); - - session.write("y", { interactive: true }); - expect(getWrittenData(proc)).toEqual(["y"]); - }); } for (const shell of ["/bin/sh", "/bin/ksh", "/usr/bin/dash"]) { diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 4359a8248ec..412a631a95f 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -115,9 +115,9 @@ const EMULATOR_WRITE_COALESCE_ENABLED = /** * Shell readiness lifecycle: - * - `pending` โ€” shell is initializing; user writes are buffered, escape sequences dropped - * - `ready` โ€” marker detected; buffered writes have been flushed - * - `timed_out` โ€” marker never arrived within timeout; writes unblocked + * - `pending` โ€” shell is initializing; escape sequences dropped, other writes pass through + * - `ready` โ€” marker detected; writes pass through + * - `timed_out` โ€” marker never arrived within timeout; writes pass through * - `unsupported` โ€” shell has no marker (sh, ksh); writes pass through from the start */ type ShellReadyState = "pending" | "ready" | "timed_out" | "unsupported"; @@ -191,11 +191,12 @@ export class Session { private ptyReadyPromise: Promise; private ptyReadyResolve: (() => void) | null = null; - // Shell readiness โ€” gates write() until the shell's first prompt. + // Shell readiness โ€” tracks the shell's init lifecycle. User input and + // preset commands pass through regardless; only stale xterm terminal-query + // responses (DA/DSR) are filtered while `pending`. // See ShellReadyState for lifecycle docs. private shellReadyState: ShellReadyState; private shellReadyTimeoutId: ReturnType | null = null; - private preReadyStdinQueue: string[] = []; // OSC 133;A scanner state โ€” shared with v2 host-service via @superset/shared private scanState: ShellReadyScanState = createScanState(); @@ -918,29 +919,23 @@ export class Session { /** * Write data to the PTY's stdin. * - * While the shell is initializing (`pending` state), writes are triaged: - * - **Escape sequences** (`\x1b`-prefixed) are dropped. These are stale - * responses from the renderer's xterm to terminal queries the shell - * sent during startup (DA, DSR). If queued and flushed later they - * appear as typed text like `?62;4;9;22c`. - * - **Interactive writes** (user keyboard input, `interactive: true`) are - * forwarded directly so prompts during initialization (e.g. oh-my-zsh - * update confirmation) can receive user input normally. - * - **Everything else** (preset/programmatic commands) is buffered and - * flushed in FIFO order once readiness resolves, ensuring they run at - * the first shell prompt rather than mid-initialization. + * Escape-sequence responses (`\x1b`-prefixed) are dropped while the shell + * is still initializing โ€” these are stale DA/DSR replies from the + * renderer's xterm to terminal queries the shell sent during startup. If + * forwarded, they appear as typed text like `?62;4;9;22c` at the shell + * prompt. The headless emulator answers those queries directly (see + * constructor), so dropping the renderer's duplicate is safe. + * + * All other data โ€” user keystrokes and preset commands alike โ€” passes + * through immediately. Buffering here previously froze workspaces when + * shell init commands (e.g. fnm's `use-on-cd` hook) opened an interactive + * prompt before the OSC 133;A marker fired. See #3478. */ - write(data: string, options?: { interactive?: boolean }): void { + write(data: string, _options?: { interactive?: boolean }): void { if (!this.subprocess || !this.subprocessReady) { throw new Error("PTY not spawned"); } - if (this.shellReadyState === "pending") { - if (data.startsWith("\x1b")) return; - if (options?.interactive) { - this.sendWriteToSubprocess(data); - } else { - this.preReadyStdinQueue.push(data); - } + if (this.shellReadyState === "pending" && data.startsWith("\x1b")) { return; } this.sendWriteToSubprocess(data); @@ -1074,7 +1069,6 @@ export class Session { clearTimeout(this.shellReadyTimeoutId); this.shellReadyTimeoutId = null; } - this.preReadyStdinQueue = []; this.scanState = createScanState(); this.subprocessStdinQueue = []; this.subprocessStdinQueuedBytes = 0; @@ -1114,8 +1108,7 @@ export class Session { /** * Transition out of `pending`. Flushes any partially-matched marker - * bytes as terminal output (they weren't a real marker), then sends - * all buffered stdin writes to the PTY in order. Idempotent. + * bytes as terminal output (they weren't a real marker). Idempotent. */ private resolveShellReady(state: "ready" | "timed_out"): void { if (this.shellReadyState !== "pending") return; @@ -1131,12 +1124,6 @@ export class Session { this.scanState.heldBytes = ""; } this.scanState.matchPos = 0; - // Flush queued writes in FIFO order - const queue = this.preReadyStdinQueue; - this.preReadyStdinQueue = []; - for (const data of queue) { - this.sendWriteToSubprocess(data); - } } /** diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/EditableCodeBlockView/EditableCodeBlockView.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/EditableCodeBlockView/EditableCodeBlockView.tsx index 2e0a3e78a09..43bd13de0b5 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/EditableCodeBlockView/EditableCodeBlockView.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/EditableCodeBlockView/EditableCodeBlockView.tsx @@ -1,3 +1,4 @@ +import { mermaid } from "@streamdown/mermaid"; import { DropdownMenu, DropdownMenuContent, @@ -7,12 +8,22 @@ import { import type { NodeViewProps } from "@tiptap/react"; import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; import { useState } from "react"; -import { HiCheck, HiChevronDown, HiOutlineClipboard } from "react-icons/hi2"; +import { + HiCheck, + HiChevronDown, + HiOutlineClipboard, + HiOutlineCodeBracket, + HiOutlineEye, +} from "react-icons/hi2"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { FILE_VIEW_CODE_BLOCK_LANGUAGES, getCodeBlockLanguageLabel, } from "renderer/lib/tiptap/code-block-languages"; +import { useTheme } from "renderer/stores"; +import { Streamdown } from "streamdown"; + +const mermaidPlugins = { mermaid }; export function EditableCodeBlockView({ node, @@ -20,6 +31,8 @@ export function EditableCodeBlockView({ extension, }: NodeViewProps) { const [menuOpen, setMenuOpen] = useState(false); + const theme = useTheme(); + const isDark = theme?.type !== "light"; const attrs = node.attrs as { language?: string }; const htmlAttrs = extension.options.HTMLAttributes as { class?: string }; @@ -30,6 +43,16 @@ export function EditableCodeBlockView({ currentLanguage, ); + const isMermaid = currentLanguage === "mermaid"; + const mermaidSource = node.textContent; + const mermaidHasContent = mermaidSource.trim().length > 0; + const [mermaidMode, setMermaidMode] = useState<"preview" | "source">(() => + mermaidHasContent ? "preview" : "source", + ); + const showMermaidPreview = + isMermaid && mermaidMode === "preview" && mermaidHasContent; + const showMermaidToggle = isMermaid && mermaidHasContent; + const { copyToClipboard, copied } = useCopyToClipboard(); const handleCopy = () => { copyToClipboard(node.textContent); @@ -41,15 +64,43 @@ export function EditableCodeBlockView({ }; return ( - +
+ {showMermaidToggle && ( + + )}
- + {showMermaidPreview && ( +
+ + {`\`\`\`\`mermaid\n${mermaidSource}\n\`\`\`\``} + +
+ )} + +
diff --git a/apps/desktop/src/renderer/hotkeys/registry.ts b/apps/desktop/src/renderer/hotkeys/registry.ts index d36558c9a10..4a9e1653a23 100644 --- a/apps/desktop/src/renderer/hotkeys/registry.ts +++ b/apps/desktop/src/renderer/hotkeys/registry.ts @@ -186,6 +186,17 @@ export const HOTKEYS_REGISTRY = { label: "Toggle Changes Tab", category: "Layout", }, + OPEN_DIFF_VIEWER: { + key: { + mac: "meta+shift+l", + windows: "ctrl+shift+alt+l", + linux: "ctrl+shift+alt+l", + }, + label: "Open Diff Viewer", + category: "Layout", + description: + "Open the diff viewer in a new tab, or focus the existing diff viewer", + }, TOGGLE_EXPAND_SIDEBAR: { key: { mac: "meta+shift+l", @@ -194,6 +205,7 @@ export const HOTKEYS_REGISTRY = { }, label: "Toggle Expand Sidebar", category: "Layout", + description: "Toggle sidebar between tabs and changes view", }, TOGGLE_WORKSPACE_SIDEBAR: { key: { mac: "meta+b", windows: "ctrl+shift+b", linux: "ctrl+shift+b" }, diff --git a/apps/desktop/src/renderer/lib/dev-chat.ts b/apps/desktop/src/renderer/lib/dev-chat.ts index b15e0897fd6..35e27230061 100644 --- a/apps/desktop/src/renderer/lib/dev-chat.ts +++ b/apps/desktop/src/renderer/lib/dev-chat.ts @@ -3,6 +3,11 @@ import { env } from "renderer/env.renderer"; import { MOCK_ORG_ID } from "shared/constants"; export const DEV_CHAT_MODELS: ModelOption[] = [ + { + id: "anthropic/claude-opus-4-7", + name: "Opus 4.7", + provider: "Anthropic", + }, { id: "anthropic/claude-opus-4-6", name: "Opus 4.6", diff --git a/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts b/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts new file mode 100644 index 00000000000..c68eba7ce7f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + sanitizeTerminalFontFamily, +} from "./index"; + +type MeasureFn = (text: string) => { width: number }; + +/** + * Stub `document.createElement("canvas")` so `getContext("2d").measureText` + * returns widths from `measureForFont`. Non-canvas tags defer to the + * existing test-setup stub. + */ +function stubCanvas(measureForFont: (font: string) => MeasureFn) { + const originalCreate = document.createElement; + // biome-ignore lint/suspicious/noExplicitAny: bun:test `mock` wraps arbitrary fns + (document as any).createElement = mock((tag: string) => { + if (tag !== "canvas") { + // biome-ignore lint/suspicious/noExplicitAny: delegating stub accepts any tag + return (originalCreate as any).call(document, tag); + } + let currentFont = ""; + return { + getContext: (kind: string) => { + if (kind !== "2d") return null; + return { + set font(value: string) { + currentFont = value; + }, + get font() { + return currentFont; + }, + measureText: (text: string) => measureForFont(currentFont)(text), + }; + }, + }; + }); + return () => { + // biome-ignore lint/suspicious/noExplicitAny: restoring stubbed method + (document as any).createElement = originalCreate; + }; +} + +const equalWidths: MeasureFn = (text) => ({ width: text.length * 10 }); +const proportionalWidths: MeasureFn = (text) => { + let width = 0; + for (const ch of text) width += ch === "M" ? 16 : 6; + return { width }; +}; + +describe("sanitizeTerminalFontFamily", () => { + let restore: (() => void) | null = null; + + afterEach(() => { + restore?.(); + restore = null; + }); + + test("returns default for null / empty / whitespace", () => { + expect(sanitizeTerminalFontFamily(null)).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(sanitizeTerminalFontFamily(undefined)).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(sanitizeTerminalFontFamily(" ")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("trusts all-generic monospace values without canvas", () => { + expect(sanitizeTerminalFontFamily("monospace")).toBe("monospace"); + expect(sanitizeTerminalFontFamily("ui-monospace")).toBe("ui-monospace"); + }); + + test("falls back when the primary family is a proportional generic", () => { + expect(sanitizeTerminalFontFamily("sans-serif")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("serif")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("cursive")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + // CSS resolves the first generic, so a later monospace entry never wins. + expect(sanitizeTerminalFontFamily("cursive, monospace")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("passes through a stack whose primary generic is monospace", () => { + // The browser resolves the first generic, so "monospace, sans-serif" + // actually renders as monospace โ€” safe. + expect(sanitizeTerminalFontFamily("monospace, sans-serif")).toBe( + "monospace, sans-serif", + ); + }); + + test("falls back when a concrete mono follows a proportional generic", () => { + // Regression: earlier logic picked the first non-generic as the primary, + // letting `sans-serif, "JetBrains Mono"` slip through even though CSS + // renders sans-serif. Validate the actual CSS primary instead. + expect(sanitizeTerminalFontFamily('sans-serif, "JetBrains Mono"')).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("passes a monospace font through when the stack already ends with monospace", () => { + restore = stubCanvas(() => equalWidths); + expect(sanitizeTerminalFontFamily('"JetBrains Mono", monospace')).toBe( + '"JetBrains Mono", monospace', + ); + }); + + test("appends a monospace fallback when the stack lacks one", () => { + // If the primary isn't installed, the browser otherwise falls back to a + // proportional default โ€” appending "monospace" forces OS monospace. + restore = stubCanvas(() => equalWidths); + expect(sanitizeTerminalFontFamily('"JetBrains Mono"')).toBe( + '"JetBrains Mono", monospace', + ); + expect(sanitizeTerminalFontFamily("Menlo")).toBe("Menlo, monospace"); + }); + + test("falls back to default for a proportional primary family (quoted)", () => { + restore = stubCanvas(() => proportionalWidths); + expect(sanitizeTerminalFontFamily('"Inter", sans-serif')).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("falls back to default for a proportional primary family (bare)", () => { + restore = stubCanvas(() => proportionalWidths); + expect(sanitizeTerminalFontFamily("Inter")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("trusts the value when canvas measurement throws", () => { + restore = stubCanvas(() => () => { + throw new Error("canvas unsupported"); + }); + // Use a unique family so the module-level monospace cache doesn't mask + // the canvas error path. + expect(sanitizeTerminalFontFamily('"UnmeasurableFont-ABC-123"')).toBe( + '"UnmeasurableFont-ABC-123", monospace', + ); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/appearance/index.ts b/apps/desktop/src/renderer/lib/terminal/appearance/index.ts index 147f29e9b3e..456e0896683 100644 --- a/apps/desktop/src/renderer/lib/terminal/appearance/index.ts +++ b/apps/desktop/src/renderer/lib/terminal/appearance/index.ts @@ -61,6 +61,114 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = serializeFontFamilyList([ export const DEFAULT_TERMINAL_FONT_SIZE = 14; +const MONOSPACE_GENERIC_FAMILIES = new Set(["monospace", "ui-monospace"]); + +/** Parse a CSS font-family list into trimmed entries, respecting quoted names. */ +function parseFontFamilyList(cssValue: string): string[] { + const families: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (const ch of cssValue) { + if (inQuote) { + if (ch === inQuote) inQuote = null; + else current += ch; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === ",") { + const trimmed = current.trim(); + if (trimmed) families.push(trimmed); + current = ""; + } else { + current += ch; + } + } + const last = current.trim(); + if (last) families.push(last); + return families; +} + +const monospaceCheckCache = new Map(); + +/** + * Heuristically decide whether `family` is a monospace font using canvas + * measurement โ€” monospace fonts render narrow ("iiiiii") and wide ("MMMMMM") + * runs at the same width. Returns `true` (permissive) when the canvas API + * is unavailable (tests/SSR) so we never block a legitimate font. + */ +function isFontFamilyMonospace(family: string): boolean { + const key = family.toLowerCase(); + if (MONOSPACE_GENERIC_FAMILIES.has(key)) return true; + + const cached = monospaceCheckCache.get(key); + if (cached !== undefined) return cached; + + try { + if (typeof document === "undefined") return true; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext?.("2d"); + if (!ctx) return true; + + ctx.font = `16px "${family}"`; + const narrow = ctx.measureText("iiiiii").width; + const wide = ctx.measureText("MMMMMM").width; + // Sub-pixel jitter tolerance. + const isMono = Math.abs(narrow - wide) < 1; + monospaceCheckCache.set(key, isMono); + return isMono; + } catch { + return true; + } +} + +/** + * Guard against a persisted terminal font that would break xterm rendering + * (e.g. a proportional font like "Inter"). Returns the raw CSS value when + * the primary family is monospace; otherwise falls back to the bundled + * default so a poisoned setting can never blank the app on startup. + * + * See issue #3513. The settings UI already prevents new non-monospace + * selections for the terminal, but this recovers users whose DB was + * poisoned before the UI restriction was added. + */ +export function sanitizeTerminalFontFamily( + cssValue: string | null | undefined, +): string { + if (!cssValue || !cssValue.trim()) return DEFAULT_TERMINAL_FONT_FAMILY; + const families = parseFontFamilyList(cssValue); + if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; + + // Validate the actual CSS primary (first entry), not the first non-generic. + // A value like `sans-serif, "JetBrains Mono"` resolves to sans-serif in the + // browser regardless of what follows, so inspecting the later entry would + // let proportional stacks slip through. + const primary = families[0]; + const primaryKey = primary.toLowerCase(); + + if (GENERIC_FONT_FAMILIES.has(primaryKey)) { + if (MONOSPACE_GENERIC_FAMILIES.has(primaryKey)) return cssValue; + console.warn( + `[terminal] Font stack "${cssValue}" has no monospace primary family; falling back to default terminal font.`, + ); + return DEFAULT_TERMINAL_FONT_FAMILY; + } + + if (!isFontFamilyMonospace(primary)) { + console.warn( + `[terminal] Font "${primary}" is not monospace; falling back to default terminal font.`, + ); + return DEFAULT_TERMINAL_FONT_FAMILY; + } + // Ensure a generic monospace tail โ€” if the configured primary isn't + // installed on this machine, the browser falls back to the OS monospace + // generic instead of a proportional default (mirrors VS Code's behavior + // in src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts). + const hasMonoTail = families.some((f) => + MONOSPACE_GENERIC_FAMILIES.has(f.toLowerCase()), + ); + return hasMonoTail ? cssValue : `${cssValue}, monospace`; +} + /** Reads localStorage theme cache for flash-free first paint. */ export function getDefaultTerminalAppearance(): TerminalAppearance { const theme = readCachedTerminalTheme(); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index f0e9296f151..b29bff72a61 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -156,11 +156,13 @@ export function createRuntime( wrapper.style.width = "100%"; wrapper.style.height = "100%"; terminal.open(wrapper); - restoreBuffer(terminalId, terminal); terminal.attachCustomKeyEventHandler((event) => !isAppHotkey(event)); + // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, + // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) const addonsResult = loadAddons(terminal); + restoreBuffer(terminalId, terminal); return { terminalId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts index a8a19b15a17..413a8489ee7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts @@ -4,6 +4,7 @@ import { type DestroyWorkspaceError, useDestroyWorkspace, } from "renderer/hooks/host-service/useDestroyWorkspace"; +import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; interface UseDestroyDialogStateOptions { workspaceId: string; @@ -15,11 +16,16 @@ interface UseDestroyDialogStateOptions { /** * Drives the delete flow for `DashboardSidebarDeleteDialog`. * - * UX pattern (mirrors v1's deleteWithToast): - * - On confirm, close the dialog immediately and run the destroy - * in the background under a toast.loading โ†’ success/error. - * - For decision-required errors (CONFLICT, TEARDOWN_FAILED) we - * reopen the dialog in the matching error pane so the user can + * UX pattern: + * - On confirm, close the dialog immediately, mark the workspace as + * deleting (sidebar row hides optimistically), and run destroy in + * the background silently. No loading toast โ€” destroy can take + * 10โ€“20s and a persistent toast across that window feels bad. The + * hidden row is the feedback. + * - On success, `onDeleted` removes the row from sidebar state. + * - On error, `clearDeleting` runs in the `finally` block so the row + * reappears. For decision-required errors (CONFLICT, TEARDOWN_FAILED) + * we reopen the dialog in the matching error pane so the user can * force-retry with full context. The branch opt-in is preserved. * - For unknown errors we just toast.error โ€” no reopen. */ @@ -30,6 +36,7 @@ export function useDestroyDialogState({ onDeleted, }: UseDestroyDialogStateOptions) { const { destroy } = useDestroyWorkspace(workspaceId); + const { markDeleting, clearDeleting } = useDeletingWorkspaces(); const [deleteBranch, setDeleteBranch] = useState(false); const [error, setError] = useState(null); @@ -60,29 +67,36 @@ export function useDestroyDialogState({ // on a decision-required error. setError(null); onOpenChange(false); - - const loadingId = toast.loading(`Deleting ${workspaceName}...`); + markDeleting(workspaceId); try { const result = await destroy({ deleteBranch, force }); - toast.success(`Deleted ${workspaceName}`, { id: loadingId }); for (const warning of result.warnings) toast.warning(warning); setDeleteBranch(false); onDeleted?.(); } catch (err) { const e = err as DestroyWorkspaceError; if (e.kind === "conflict" || e.kind === "teardown-failed") { - toast.dismiss(loadingId); setError(e); onOpenChange(true); } else { - toast.error(`Failed to delete: ${e.message}`, { id: loadingId }); + toast.error(`Failed to delete ${workspaceName}: ${e.message}`); } } finally { + clearDeleting(workspaceId); inFlight.current = false; } }, - [destroy, deleteBranch, workspaceName, onOpenChange, onDeleted], + [ + destroy, + deleteBranch, + workspaceName, + workspaceId, + onOpenChange, + onDeleted, + markDeleting, + clearDeleting, + ], ); return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 0278602c92e..e8ee22f67d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -28,6 +28,7 @@ export function DashboardSidebarWorkspaceItem({ projectId, accentColor = null, hostType, + hostIsOnline, name, branch, creationStatus, @@ -36,6 +37,7 @@ export function DashboardSidebarWorkspaceItem({ const { cancelRename, handleClick, + handleCopyBranchName, handleCopyPath, handleCreateSection, handleDeleted, @@ -54,6 +56,7 @@ export function DashboardSidebarWorkspaceItem({ workspaceId: id, projectId, workspaceName: name, + branch, }); const navigate = useNavigate(); @@ -79,6 +82,7 @@ export function DashboardSidebarWorkspaceItem({ )} removeWorkspaceFromSidebar(id)} onRename={startRename} onDelete={() => setIsDeleteDialogOpen(true)} @@ -176,6 +181,7 @@ export function DashboardSidebarWorkspaceItem({ isLocalWorkspace={hostType === "local-device"} onOpenInFinder={handleOpenInFinder} onCopyPath={handleCopyPath} + onCopyBranchName={handleCopyBranchName} onRemoveFromSidebar={() => removeWorkspaceFromSidebar(id)} onRename={startRename} onDelete={() => setIsDeleteDialogOpen(true)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx index e153977e529..5f21eac6ef6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx @@ -7,6 +7,7 @@ import { DashboardSidebarWorkspaceIcon } from "../DashboardSidebarWorkspaceIcon" interface DashboardSidebarCollapsedWorkspaceButtonProps extends ComponentPropsWithoutRef<"button"> { hostType: DashboardSidebarWorkspaceHostType; + hostIsOnline: boolean | null; isActive: boolean; workspaceStatus?: ActivePaneStatus | null; creationStatus?: "preparing" | "generating-branch" | "creating" | "failed"; @@ -19,6 +20,7 @@ export const DashboardSidebarCollapsedWorkspaceButton = forwardRef< ( { hostType, + hostIsOnline, isActive, workspaceStatus = null, creationStatus, @@ -41,6 +43,7 @@ export const DashboardSidebarCollapsedWorkspaceButton = forwardRef< > -

Worktree workspace

+

+ {hostType === "local-device" + ? "Local workspace" + : hostType === "remote-device" + ? hostIsOnline === false + ? "Remote workspace โ€” device offline" + : "Remote workspace" + : "Cloud workspace"} +

- Isolated copy for parallel development + {hostType === "local-device" + ? "Running on this device" + : hostType === "remote-device" + ? hostIsOnline === false + ? "The associated device isn't reachable right now" + : "Running on a paired device" + : "Hosted in the cloud"}

diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index 49dba6a2d97..550488fc0af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -22,6 +22,7 @@ import { LuCopy, LuFolderOpen, LuFolderPlus, + LuGitBranch, LuPencil, LuTrash2, LuX, @@ -38,6 +39,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { onMoveToSection: (sectionId: string | null) => void; onOpenInFinder: () => void; onCopyPath: () => void; + onCopyBranchName: () => void; onRemoveFromSidebar: () => void; onRename: () => void; onDelete: () => void; @@ -54,6 +56,7 @@ export function DashboardSidebarWorkspaceContextMenu({ onMoveToSection, onOpenInFinder, onCopyPath, + onCopyBranchName, onRemoveFromSidebar, onRename, onDelete, @@ -96,6 +99,11 @@ export function DashboardSidebarWorkspaceContextMenu({ )} + {!isLocalWorkspace && } + + + Copy Branch Name + diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx index 85248582359..fb9df6f6d4f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx @@ -1,6 +1,6 @@ import { cn } from "@superset/ui/utils"; import { HiExclamationTriangle } from "react-icons/hi2"; -import { LuCloud, LuFolderGit2, LuLaptop } from "react-icons/lu"; +import { LuCloud, LuCloudOff } from "react-icons/lu"; import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import type { ActivePaneStatus } from "shared/tabs-types"; @@ -8,6 +8,7 @@ import type { DashboardSidebarWorkspaceHostType } from "../../../../types"; interface DashboardSidebarWorkspaceIconProps { hostType: DashboardSidebarWorkspaceHostType; + hostIsOnline: boolean | null; isActive: boolean; variant: "collapsed" | "expanded"; workspaceStatus?: ActivePaneStatus | null; @@ -21,12 +22,45 @@ const OVERLAY_POSITION = { export function DashboardSidebarWorkspaceIcon({ hostType, + hostIsOnline, isActive, variant, workspaceStatus = null, creationStatus, }: DashboardSidebarWorkspaceIconProps) { const overlayPosition = OVERLAY_POSITION[variant]; + const iconColor = isActive ? "text-foreground" : "text-muted-foreground"; + const isRemoteDeviceOffline = + hostType === "remote-device" && hostIsOnline === false; + + const renderHostIcon = () => { + if (hostType === "local-device") { + return ( + + ); + } + + if (isRemoteDeviceOffline) { + return ( + + ); + } + + return ( + + ); + }; return ( <> @@ -34,33 +68,8 @@ export function DashboardSidebarWorkspaceIcon({ ) : creationStatus || workspaceStatus === "working" ? ( - ) : hostType === "cloud" ? ( - - ) : hostType === "remote-device" ? ( - ) : ( - + renderHostIcon() )} {workspaceStatus && workspaceStatus !== "working" && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index a9296ede735..3eb9f4f059f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -16,12 +16,14 @@ interface UseDashboardSidebarWorkspaceItemActionsOptions { workspaceId: string; projectId: string; workspaceName: string; + branch: string; } export function useDashboardSidebarWorkspaceItemActions({ workspaceId, projectId, workspaceName, + branch, }: UseDashboardSidebarWorkspaceItemActionsOptions) { const navigate = useNavigate(); const matchRoute = useMatchRoute(); @@ -144,10 +146,26 @@ export function useDashboardSidebarWorkspaceItemActions({ } }; + const handleCopyBranchName = async () => { + if (!branch) { + toast.error("Branch name is not available"); + return; + } + try { + await copyToClipboard(branch); + toast.success("Branch name copied"); + } catch (error) { + toast.error( + `Failed to copy branch name: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }; + return { cancelRename, handleClick, handleCopyPath, + handleCopyBranchName, handleCreateSection, handleDeleted, handleOpenInFinder, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index b9a8b6b5c3a..4f915b32ae2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -16,8 +16,9 @@ import type { DashboardSidebarWorkspace, } from "../../types"; -// Pending workspaces are always rendered at the end of the project's workspace list -const PENDING_WORKSPACE_TAB_ORDER = Number.MAX_SAFE_INTEGER; +// Sits above every real workspace so the pending row lines up with the real one, +// which is inserted via getPrependTabOrder. +const PENDING_WORKSPACE_TAB_ORDER = Number.MIN_SAFE_INTEGER; export function useDashboardSidebarData() { const { data: session } = authClient.useSession(); @@ -120,6 +121,7 @@ export function useDashboardSidebarData() { projectId: sidebarWorkspaces.sidebarState.projectId, hostId: workspaces.hostId, hostMachineId: hosts?.machineId ?? null, + hostIsOnline: hosts?.isOnline ?? null, name: workspaces.name, branch: workspaces.branch, createdAt: workspaces.createdAt, @@ -239,6 +241,10 @@ export function useDashboardSidebarData() { projectId: workspace.projectId, hostId: workspace.hostId, hostType, + hostIsOnline: + hostType === "remote-device" + ? (workspace.hostIsOnline ?? null) + : null, accentColor: null, name: workspace.name, branch: workspace.branch, @@ -290,6 +296,7 @@ export function useDashboardSidebarData() { projectId: pw.projectId, hostId: "", hostType: "local-device", + hostIsOnline: null, accentColor: null, name: pw.name, branch: pw.branchName, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index 329d90bd701..e2ff2406ec2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -25,6 +25,7 @@ export interface DashboardSidebarWorkspace { projectId: string; hostId: string; hostType: DashboardSidebarWorkspaceHostType; + hostIsOnline: boolean | null; accentColor: string | null; name: string; branch: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts index 8bb6ac97efd..fb3965347ff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts @@ -107,8 +107,7 @@ function getLegacyImagePayload( } export function useChatDisplay(options: UseChatDisplayOptions) { - const { sessionId, workspaceId, enabled = true, fps = 60 } = options; - const utils = workspaceTrpc.useUtils(); + const { sessionId, workspaceId, enabled = true, fps = 4 } = options; const [commandError, setCommandError] = useState(null); const queryInput = sessionId === null ? undefined : { sessionId, workspaceId }; @@ -119,8 +118,6 @@ export function useChatDisplay(options: UseChatDisplayOptions) { refetchInterval: refetchIntervalMs, refetchIntervalInBackground: true, refetchOnWindowFocus: false, - staleTime: 0, - gcTime: 0, } as const; const displayQuery = workspaceTrpc.chat.getDisplayState.useQuery( @@ -351,20 +348,6 @@ export function useChatDisplay(options: UseChatDisplayOptions) { ], ); - useEffect(() => { - if (!queryInput) return; - if (!isRunning) return; - void Promise.all([ - utils.chat.getDisplayState.invalidate(queryInput), - utils.chat.listMessages.invalidate(queryInput), - ]); - }, [ - isRunning, - queryInput, - utils.chat.getDisplayState, - utils.chat.listMessages, - ]); - return { ...displayState, messages, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index e346d860a81..54995d2629b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -32,6 +32,7 @@ import { DEFAULT_FILE_OPEN_MODE, } from "shared/constants"; import { LinkHoverTooltip } from "./components/LinkHoverTooltip"; +import { useLinkClickHint } from "./hooks/useLinkClickHint"; import { useLinkHoverState } from "./hooks/useLinkHoverState"; import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; import { shellEscapePaths } from "./utils"; @@ -68,6 +69,7 @@ export function TerminalPane({ onHover: onLinkHover, onLeave: onLinkLeave, } = useLinkHoverState(); + const { hint, showHint } = useLinkClickHint(); const paneData = ctx.pane.data as TerminalPaneData; // FORK NOTE: Guard against legacy pane data format {sessionKey, cwd, launchMode} // saved in local DB before the terminalId migration. @@ -175,7 +177,10 @@ export function TerminalPane({ } }, onFileLinkClick: (event, link) => { - if (!event.metaKey && !event.ctrlKey) return; + if (!event.metaKey && !event.ctrlKey) { + showHint(event.clientX, event.clientY); + return; + } event.preventDefault(); if (event.shiftKey) { openInExternalEditor(link.resolvedPath, { @@ -218,6 +223,7 @@ export function TerminalPane({ openInExternalEditor, onLinkHover, onLinkLeave, + showHint, ]); useHotkey( @@ -343,7 +349,7 @@ export function TerminalPane({ Disconnected )} - + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx index 87323399029..36cc26e390d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx @@ -1,75 +1,68 @@ -import type { ExternalApp } from "@superset/local-db"; -import { useEffect, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; import { createPortal } from "react-dom"; -import { getAppOption } from "renderer/components/OpenInExternalDropdown/constants"; import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { LinkClickHint } from "../../hooks/useLinkClickHint"; import type { HoveredLink } from "../../hooks/useLinkHoverState"; const TOOLTIP_OFFSET_PX = 14; +const TOOLTIP_CLASSES = + "pointer-events-none fixed z-50 w-fit rounded-md bg-foreground px-3 py-1.5 text-xs text-background"; + +const isMac = + typeof navigator !== "undefined" && + navigator.platform.toLowerCase().includes("mac"); +const MOD_LABEL = isMac ? "โŒ˜" : "Ctrl"; +const MOD_SHIFT_LABEL = isMac ? "โŒ˜โ‡ง" : "Ctrl+Shift"; +const HINT_LABEL = `Hold ${MOD_LABEL} to open ยท ${MOD_SHIFT_LABEL} for external`; interface LinkHoverTooltipProps { hoveredLink: HoveredLink | null; + hint: LinkClickHint | null; } -function getAppLabel(app: ExternalApp): string { - const option = getAppOption(app); - return option?.displayLabel ?? option?.label ?? "external editor"; -} - -function getLabel( - info: LinkHoverInfo, - shift: boolean, - defaultEditor: ExternalApp | null, -): string { +function getLabel(info: LinkHoverInfo, shift: boolean): string { if (info.kind === "url") { - return shift ? "Open in external browser" : "Open in browser"; - } - if (shift) { - return defaultEditor - ? `Open in ${getAppLabel(defaultEditor)}` - : "Open externally"; + return shift ? "Open in external browser" : "Open in pane"; } - return info.isDirectory ? "Reveal in sidebar" : "Open in editor"; + if (shift) return "Open in external editor"; + return info.isDirectory ? "Reveal in sidebar" : "Open in pane"; } -export function LinkHoverTooltip({ hoveredLink }: LinkHoverTooltipProps) { - const [defaultEditor, setDefaultEditor] = useState(null); - - useEffect(() => { - let cancelled = false; - electronTrpcClient.settings.getDefaultEditor - .query() - .then((editor) => { - if (!cancelled) setDefaultEditor(editor); - }) - .catch((error) => { - if (cancelled) return; - console.warn( - "[LinkHoverTooltip] Failed to fetch default editor:", - error, - ); - setDefaultEditor(null); - }); - return () => { - cancelled = true; - }; - }, []); - - if (!hoveredLink || !hoveredLink.modifier) return null; - - const label = getLabel(hoveredLink.info, hoveredLink.shift, defaultEditor); +export function LinkHoverTooltip({ hoveredLink, hint }: LinkHoverTooltipProps) { + const showingHover = Boolean(hoveredLink?.modifier); return createPortal( -
- {label} -
, + <> + {hoveredLink?.modifier && ( +
+ {getLabel(hoveredLink.info, hoveredLink.shift)} +
+ )} + + {hint && !showingHover && ( + + {HINT_LABEL} + + )} + + , document.body, ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts new file mode 100644 index 00000000000..c9aacc8b15e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts @@ -0,0 +1 @@ +export { type LinkClickHint, useLinkClickHint } from "./useLinkClickHint"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts new file mode 100644 index 00000000000..4c6d5286f58 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface LinkClickHint { + clientX: number; + clientY: number; +} + +const HINT_DURATION_MS = 2000; +const MAX_HINTS_PER_SESSION = 2; + +let hintsRemaining = MAX_HINTS_PER_SESSION; + +export function useLinkClickHint() { + const [hint, setHint] = useState(null); + const timeoutRef = useRef | null>(null); + + const showHint = useCallback((clientX: number, clientY: number) => { + if (hintsRemaining <= 0) return; + hintsRemaining -= 1; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setHint({ clientX, clientY }); + timeoutRef.current = setTimeout(() => { + setHint(null); + timeoutRef.current = null; + }, HINT_DURATION_MS); + }, []); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { hint, showHint }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts index 55ff6314d7b..e6c9c8f9e21 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { - DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, getDefaultTerminalAppearance, + sanitizeTerminalFontFamily, type TerminalAppearance, } from "renderer/lib/terminal/appearance"; import { electronTrpcClient } from "renderer/lib/trpc-client"; @@ -21,8 +21,9 @@ export function useTerminalAppearance(): TerminalAppearance { return useMemo(() => { const theme = terminalTheme ?? fallbackTheme; - const fontFamily = - fontSettings?.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY; + const fontFamily = sanitizeTerminalFontFamily( + fontSettings?.terminalFontFamily, + ); const fontSize = fontSettings?.terminalFontSize ?? DEFAULT_TERMINAL_FONT_SIZE; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index 83308f52a08..205a334364c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -12,6 +12,7 @@ import type { StoreApi } from "zustand"; import type { BrowserPaneData, ChatPaneData, + DiffPaneData, PaneViewerData, TerminalPaneData, } from "../../types"; @@ -70,6 +71,33 @@ export function useWorkspaceHotkeys({ }); }); + useHotkey("OPEN_DIFF_VIEWER", () => { + if (collections.v2WorkspaceLocalState.get(workspaceId)) { + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.rightSidebarOpen = true; + draft.sidebarState.activeTab = "changes"; + }); + } + + const state = store.getState(); + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "diff") continue; + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + state.addTab({ + panes: [ + { + kind: "diff", + data: { path: "", collapsedFiles: [] } as DiffPaneData, + }, + ], + }); + }); + // --- Tab management --- const isClosingPaneRef = useRef(false); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx index 1acf3e0c45f..9417d6eea6f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx @@ -58,52 +58,48 @@ export function V2WorkspacesHeader({ counts }: V2WorkspacesHeaderProps) { }; return ( -
-
-

Workspaces

-

- Every workspace you can access across your devices. Open one to jump - in, or add it to your sidebar. -

-
+
+
+

Workspaces

-
- - - - - setSearchQuery(event.target.value)} - /> - +
+ + + + + setSearchQuery(event.target.value)} + /> + - { - if (value) setDeviceFilter(value as V2WorkspacesDeviceFilter); - }} - > - {DEVICE_FILTER_OPTIONS.map(({ value, label, Icon }) => ( - - {Icon ? : null} - {label} - - {countForFilter(value)} - - - ))} - + { + if (value) setDeviceFilter(value as V2WorkspacesDeviceFilter); + }} + > + {DEVICE_FILTER_OPTIONS.map(({ value, label, Icon }) => ( + + {Icon ? : null} + {label} + + {countForFilter(value)} + + + ))} + +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx index 10a29d40088..c237c351b1b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -7,10 +7,10 @@ import { EmptyMedia, EmptyTitle, } from "@superset/ui/empty"; -import { ItemGroup } from "@superset/ui/item"; import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; import { useMatchRoute } from "@tanstack/react-router"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { LuLayers, LuSearchX } from "react-icons/lu"; import type { AccessibleV2Workspace, @@ -20,18 +20,20 @@ import { useV2WorkspacesFilterStore, type V2WorkspacesDeviceFilter, } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; +import { SortableHeader } from "./components/SortableHeader"; import { V2WorkspaceRow } from "./components/V2WorkspaceRow"; +import { V2_WORKSPACES_ROW_GRID } from "./constants"; +import type { SortDirection, SortField } from "./types"; interface V2WorkspacesListProps { - pinned: AccessibleV2Workspace[]; - others: AccessibleV2Workspace[]; - hasAnyAccessible: boolean; + workspaces: AccessibleV2Workspace[]; } interface ProjectGroup { projectId: string; projectName: string; workspaces: AccessibleV2Workspace[]; + latestCreatedAt: number; } function matchesDeviceFilter( @@ -50,36 +52,95 @@ function matchesDeviceFilter( } } -function groupByProject(workspaces: AccessibleV2Workspace[]): ProjectGroup[] { - const groupsById = new Map(); +// Host-type rank used as a tiebreaker when sorting by host โ€” keeps local +// device first, then remote devices, then cloud. +function hostTypeRank(hostType: V2WorkspaceHostType): number { + switch (hostType) { + case "local-device": + return 0; + case "remote-device": + return 1; + case "cloud": + return 2; + } +} + +function compareWorkspaces( + a: AccessibleV2Workspace, + b: AccessibleV2Workspace, + field: SortField, + direction: SortDirection, +): number { + let cmp = 0; + switch (field) { + case "sidebar": + cmp = Number(a.isInSidebar) - Number(b.isInSidebar); + break; + case "name": + cmp = a.name.localeCompare(b.name); + break; + case "host": + cmp = hostTypeRank(a.hostType) - hostTypeRank(b.hostType); + if (cmp === 0) cmp = a.hostName.localeCompare(b.hostName); + break; + case "branch": + cmp = a.branch.localeCompare(b.branch); + break; + case "created": + cmp = a.createdAt.getTime() - b.createdAt.getTime(); + break; + } + if (cmp === 0) { + cmp = b.createdAt.getTime() - a.createdAt.getTime(); + } + return direction === "asc" ? cmp : -cmp; +} + +function groupByProject( + workspaces: AccessibleV2Workspace[], + sortField: SortField, + sortDirection: SortDirection, +): ProjectGroup[] { + const projectsById = new Map(); + for (const workspace of workspaces) { - const existing = groupsById.get(workspace.projectId); - if (existing) { - existing.workspaces.push(workspace); - } else { - groupsById.set(workspace.projectId, { + let project = projectsById.get(workspace.projectId); + if (!project) { + project = { projectId: workspace.projectId, projectName: workspace.projectName, - workspaces: [workspace], - }); + workspaces: [], + latestCreatedAt: 0, + }; + projectsById.set(workspace.projectId, project); + } + project.workspaces.push(workspace); + const createdAt = workspace.createdAt.getTime(); + if (createdAt > project.latestCreatedAt) { + project.latestCreatedAt = createdAt; } } - return Array.from(groupsById.values()).sort((a, b) => { - const aLatest = Math.max( - ...a.workspaces.map((workspace) => workspace.createdAt.getTime()), - ); - const bLatest = Math.max( - ...b.workspaces.map((workspace) => workspace.createdAt.getTime()), + + for (const project of projectsById.values()) { + project.workspaces.sort((a, b) => + compareWorkspaces(a, b, sortField, sortDirection), ); - return bLatest - aLatest; - }); + } + + return Array.from(projectsById.values()).sort( + (a, b) => b.latestCreatedAt - a.latestCreatedAt, + ); } -export function V2WorkspacesList({ - pinned, - others, - hasAnyAccessible, -}: V2WorkspacesListProps) { +const DEFAULT_DIRECTION_BY_FIELD: Record = { + sidebar: "desc", + name: "asc", + host: "asc", + branch: "asc", + created: "desc", +}; + +export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { const matchRoute = useMatchRoute(); const currentWorkspaceMatch = matchRoute({ to: "/v2-workspace/$workspaceId", @@ -93,136 +154,150 @@ export function V2WorkspacesList({ ); const resetFilters = useV2WorkspacesFilterStore((state) => state.reset); - // `pinned` / `others` already have the search filter applied upstream in - // useAccessibleV2Workspaces, so here we only narrow by device filter. - const filteredPinnedGroups = useMemo(() => { - const filtered = pinned.filter((workspace) => - matchesDeviceFilter(workspace.hostType, deviceFilter), - ); - return groupByProject(filtered); - }, [pinned, deviceFilter]); + const [sortField, setSortField] = useState("created"); + const [sortDirection, setSortDirection] = useState("desc"); - const filteredOtherGroups = useMemo(() => { - const filtered = others.filter((workspace) => + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDirection(DEFAULT_DIRECTION_BY_FIELD[field]); + } + }; + + const projectGroups = useMemo(() => { + const filtered = workspaces.filter((workspace) => matchesDeviceFilter(workspace.hostType, deviceFilter), ); - return groupByProject(filtered); - }, [others, deviceFilter]); + return groupByProject(filtered, sortField, sortDirection); + }, [workspaces, deviceFilter, sortField, sortDirection]); - const pinnedCount = filteredPinnedGroups.reduce( - (total, group) => total + group.workspaces.length, - 0, - ); - const othersCount = filteredOtherGroups.reduce( - (total, group) => total + group.workspaces.length, + const totalCount = projectGroups.reduce( + (total, project) => total + project.workspaces.length, 0, ); - const hasAnyMatches = pinnedCount > 0 || othersCount > 0; const hasActiveFilters = searchQuery.trim() !== "" || deviceFilter !== "all"; - if (!hasAnyAccessible) { - return ( - - - - - - No workspaces yet - - Create a workspace from the sidebar to get started. Workspaces you - have access to across all your devices will show up here. - - - - ); - } + const columnHeader = ( +
+ + + + + + +
+ ); - if (!hasAnyMatches) { + if (totalCount === 0) { return ( - - - - - - No workspaces match your filters - - Try a different search term or clear the device filter. - - - {hasActiveFilters ? ( - - - - ) : null} - +
+ {columnHeader} + + + + {hasActiveFilters ? : } + + + {hasActiveFilters + ? "No workspaces match your filters" + : "No workspaces yet"} + + + {hasActiveFilters + ? "Try a different search term or clear the device filter." + : "Workspaces you have access to across all your devices will show up here."} + + + {hasActiveFilters ? ( + + + + ) : null} + +
); } - const renderProjectGroups = (groups: ProjectGroup[]) => ( -
- {groups.map((group) => ( -
-
-

- {group.projectName} -

- - {group.workspaces.length} - -
- - {group.workspaces.map((workspace) => ( - - ))} - -
- ))} -
- ); - return ( - -
- {pinnedCount > 0 ? ( -
-
-

- In your sidebar -

- - {pinnedCount} - -
- {renderProjectGroups(filteredPinnedGroups)} -
- ) : null} - - {othersCount > 0 ? ( -
-
-

- Other workspaces -

- - {othersCount} + +
+ {columnHeader} + + {projectGroups.map((project) => ( +
+
+

+ {project.projectName} +

+ + {project.workspaces.length}
- {renderProjectGroups(filteredOtherGroups)} -
- ) : null} +
    + {project.workspaces.map((workspace) => ( + + ))} +
+
+ ))}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx new file mode 100644 index 00000000000..3d5a0cf8de3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx @@ -0,0 +1,60 @@ +import { cn } from "@superset/ui/utils"; +import { LuChevronDown, LuChevronsUpDown, LuChevronUp } from "react-icons/lu"; +import type { SortDirection, SortField } from "../../types"; + +interface SortableHeaderProps { + field: SortField; + label: string; + align?: "start" | "center"; + className?: string; + sortField: SortField; + sortDirection: SortDirection; + onSort: (field: SortField) => void; + srOnlyLabel?: boolean; +} + +export function SortableHeader({ + field, + label, + align = "start", + className, + sortField, + sortDirection, + onSort, + srOnlyLabel = false, +}: SortableHeaderProps) { + const isActive = sortField === field; + const Icon = !isActive + ? LuChevronsUpDown + : sortDirection === "asc" + ? LuChevronUp + : LuChevronDown; + const sortLabel = isActive + ? sortDirection === "asc" + ? "ascending" + : "descending" + : "not sorted"; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts new file mode 100644 index 00000000000..c85413e268f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts @@ -0,0 +1 @@ +export { SortableHeader } from "./SortableHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx index 4cc638df286..566c3bc3709 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx @@ -1,13 +1,6 @@ -import { Badge } from "@superset/ui/badge"; import { Button } from "@superset/ui/button"; -import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemMedia, - ItemTitle, -} from "@superset/ui/item"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; import { @@ -19,32 +12,44 @@ import { LuPlus, } from "react-icons/lu"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import type { AccessibleV2Workspace } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import type { + AccessibleV2Workspace, + V2WorkspaceHostType, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; -import { V2WorkspaceDeviceBadge } from "./components/V2WorkspaceDeviceBadge"; +import { V2_WORKSPACES_ROW_GRID } from "../../constants"; interface V2WorkspaceRowProps { workspace: AccessibleV2Workspace; - showProjectName: boolean; isCurrentRoute: boolean; } +function hostIconFor(hostType: V2WorkspaceHostType) { + switch (hostType) { + case "cloud": + return LuCloud; + case "local-device": + return LuLaptop; + case "remote-device": + return LuMonitor; + } +} + export function V2WorkspaceRow({ workspace, - showProjectName, isCurrentRoute, }: V2WorkspaceRowProps) { const navigate = useNavigate(); const { ensureWorkspaceInSidebar, removeWorkspaceFromSidebar } = useDashboardSidebarState(); - const HostIcon = - workspace.hostType === "cloud" - ? LuCloud - : workspace.hostType === "local-device" - ? LuLaptop - : LuMonitor; + const HostIcon = hostIconFor(workspace.hostType); + + // The local device is always reachable from here โ€” ignore any stale + // isOnline flag on that row. + const treatAsOffline = + !workspace.hostIsOnline && workspace.hostType !== "local-device"; const handleOpen = useCallback(() => { navigateToV2Workspace(workspace.id, navigate); @@ -70,8 +75,16 @@ export function V2WorkspaceRow({ ? "you" : (workspace.createdByName ?? "unknown"); + const timeLabel = getRelativeTime(workspace.createdAt.getTime(), { + format: "compact", + }); + const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { + // Ignore keystrokes bubbling from focused descendants (e.g. the + // Add/Remove icon buttons) โ€” `stopPropagation` on their click handlers + // doesn't catch keyboard events. + if (event.target !== event.currentTarget) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleOpen(); @@ -80,72 +93,129 @@ export function V2WorkspaceRow({ [handleOpen], ); + const hostCell = ( + + + {workspace.hostName} + {treatAsOffline ? ( + + ) : null} + + ); + return ( - - - - - - - - {workspace.name} - - - {showProjectName ? ( - - {workspace.projectName} - + {/* biome-ignore lint/a11y/useSemanticElements: interactive row needs nested buttons, so the outer element is a div with role/tabIndex */} +
+ + {workspace.isInSidebar ? ( + ) : null} - - - {workspace.branch} - - - - {getRelativeTime(workspace.createdAt.getTime(), { - format: "compact", - })}{" "} - by {creatorLabel} - - - - - - {workspace.isInSidebar ? ( - + {workspace.name} + + + + {treatAsOffline ? ( + + {hostCell} + Host is offline + ) : ( - + hostCell )} - - + + + + + {workspace.branch} + + + + + {timeLabel} ยท {creatorLabel} + + +
+ {workspace.isInSidebar ? ( + + + + + + {isCurrentRoute + ? "Can't remove the current workspace" + : "Remove from sidebar"} + + + ) : ( + + + + + Add to sidebar + + )} +
+
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx deleted file mode 100644 index 96872d2e490..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Badge } from "@superset/ui/badge"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; -import { LuCloud, LuLaptop, LuMonitor } from "react-icons/lu"; -import type { V2WorkspaceHostType } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; - -interface V2WorkspaceDeviceBadgeProps { - hostType: V2WorkspaceHostType; - hostName: string; - isOnline: boolean; -} - -export function V2WorkspaceDeviceBadge({ - hostType, - hostName, - isOnline, -}: V2WorkspaceDeviceBadgeProps) { - const Icon = - hostType === "cloud" - ? LuCloud - : hostType === "local-device" - ? LuLaptop - : LuMonitor; - - // The local device is always reachable from here โ€” ignore any stale - // isOnline flag on that row. - const treatAsOffline = !isOnline && hostType !== "local-device"; - - const badge = ( - - - {hostName} - {treatAsOffline ? ( - - ) : null} - - ); - - if (!treatAsOffline) { - return badge; - } - - return ( - - {badge} - Host is offline - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/index.ts deleted file mode 100644 index ca6ed56dfa0..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { V2WorkspaceDeviceBadge } from "./V2WorkspaceDeviceBadge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts new file mode 100644 index 00000000000..738705ecda4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts @@ -0,0 +1,5 @@ +// Shared grid template used by the column header row and every workspace row +// so the Sidebar / Name / Host / Branch / Created / Action columns align +// across the whole view. Columns hide progressively on narrower viewports. +export const V2_WORKSPACES_ROW_GRID = + "grid grid-cols-[1.25rem_minmax(0,1fr)_2.5rem] gap-4 md:grid-cols-[1.25rem_minmax(0,1fr)_12rem_2.5rem] lg:grid-cols-[1.25rem_minmax(0,1fr)_12rem_14rem_2.5rem] xl:grid-cols-[1.25rem_minmax(0,1fr)_12rem_14rem_11rem_2.5rem] items-center"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts new file mode 100644 index 00000000000..09ae323f499 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts @@ -0,0 +1,2 @@ +export type SortField = "sidebar" | "name" | "host" | "branch" | "created"; +export type SortDirection = "asc" | "desc"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx index d8286acf22d..ebaa0ba0e7e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx @@ -22,17 +22,12 @@ function V2WorkspacesPage() { resetFilters(); }, [resetFilters]); - const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery }); - const hasAnyAccessible = pinned.length > 0 || others.length > 0; + const { all, counts } = useAccessibleV2Workspaces({ searchQuery }); return (
- +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index c5fb9900224..24f86365c09 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -662,22 +662,22 @@ export function WorkspacePage({ return addBrowserShortcutListener(handleBrowserShortcut); }, [handleBrowserShortcut]); - const handleSearchInFiles = useCallback(() => { - if (!isSidebarOpen) { - setSidebarOpen(true); - } - setSidebarMode(SidebarMode.Tabs); - if (workspaceId) { - setRightSidebarTab(workspaceId, RightSidebarTab.Search); - } - }, [ - isSidebarOpen, - workspaceId, - setRightSidebarTab, - setSidebarMode, - setSidebarOpen, - ]); - useHotkey("SEARCH_IN_FILES", handleSearchInFiles, { enabled: isActive }); + // FORK NOTE: V1 intentionally skips OPEN_DIFF_VIEWER registration โ€” its + // โŒ˜โ‡งL binding collides with TOGGLE_EXPAND_SIDEBAR below, and V1 uses the + // expand-sidebar action. V2 workspace registers OPEN_DIFF_VIEWER itself. + useHotkey( + "SEARCH_IN_FILES", + () => { + if (!isSidebarOpen) { + setSidebarOpen(true); + } + setSidebarMode(SidebarMode.Tabs); + if (workspaceId) { + setRightSidebarTab(workspaceId, RightSidebarTab.Search); + } + }, + { enabled: isActive }, + ); // Toggle changes sidebar (โŒ˜L) useHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), { enabled: isActive }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 8cc31ea07b9..438d1b5be51 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -12,6 +12,15 @@ function getNextTabOrder(items: Array<{ tabOrder: number }>): number { return maxTabOrder + 1; } +function getPrependTabOrder(items: Array<{ tabOrder: number }>): number { + if (items.length === 0) return 1; + const minTabOrder = items.reduce( + (minValue, item) => Math.min(minValue, item.tabOrder), + Number.POSITIVE_INFINITY, + ); + return minTabOrder - 1; +} + function ensureSidebarProjectRecord( collections: Pick, projectId: string, @@ -60,7 +69,7 @@ function ensureSidebarWorkspaceRecord( createdAt: new Date(), sidebarState: { projectId, - tabOrder: getNextTabOrder(topLevelOrders), + tabOrder: getPrependTabOrder(topLevelOrders), sectionId: null, }, paneLayout: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index a7044fcefc3..6ea983f9ddb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -17,10 +17,8 @@ import { useUpdateListener } from "renderer/components/UpdateToast"; import { env } from "renderer/env.renderer"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useOnlineStatus } from "renderer/hooks/useOnlineStatus"; -import { isTearoffWindow } from "renderer/hooks/useTearoffInit/useTearoffInit"; import { migrateHotkeyOverrides } from "renderer/hotkeys/migrate"; import { authClient, getAuthToken } from "renderer/lib/auth-client"; -import { dispatchBrowserShortcutEvent } from "renderer/lib/browser-shortcut-events"; import { dragDropManager } from "renderer/lib/dnd"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { showWorkspaceAutoNameWarningToast } from "renderer/lib/workspaces/showWorkspaceAutoNameWarningToast"; @@ -39,6 +37,7 @@ import { MainWindowEffects } from "./components/MainWindowEffects"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; import { createPierreWorker } from "./lib/pierreWorker"; import { CollectionsProvider } from "./providers/CollectionsProvider"; +import { DeletingWorkspacesProvider } from "./providers/DeletingWorkspacesProvider"; import { LocalHostServiceProvider } from "./providers/LocalHostServiceProvider"; export const Route = createFileRoute("/_authenticated")({ @@ -59,7 +58,6 @@ function AuthenticatedLayout() { const setOriginRoute = useSettingsStore((s) => s.setOriginRoute); const utils = electronTrpc.useUtils(); const shownWorkspaceInitWarningsRef = useRef(new Set()); - const redirectingToSignIn = useRef(false); const { isV2CloudEnabled } = useIsV2CloudEnabled(); const isSignedIn = env.SKIP_ENV_VALIDATION || !!session?.user; @@ -77,9 +75,7 @@ function AuthenticatedLayout() { }); }, []); - // Update workspace-run pane state on terminal exit. - // Each window has its own useTabsStore, so the paneId lookup below naturally - // scopes the update to the window that actually owns the pane. + // Update workspace-run pane state on terminal exit electronTrpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { if ( @@ -109,15 +105,6 @@ function AuthenticatedLayout() { const updateInitProgress = useWorkspaceInitStore((s) => s.updateProgress); electronTrpc.workspaces.onInitProgress.useSubscription(undefined, { onData: (progress) => { - // React Query cache invalidation runs in every window (including - // tearoff) since each BrowserWindow has its own QueryClient and may - // be displaying the affected workspace. - if (progress.step === "ready" || progress.step === "failed") { - utils.workspaces.getAllGrouped.invalidate(); - utils.workspaces.get.invalidate({ id: progress.workspaceId }); - } - // The rest (progress store, toast, navigate) is main-window only. - if (isTearoffWindow()) return; updateInitProgress(progress); if ( progress.warning && @@ -131,6 +118,11 @@ function AuthenticatedLayout() { }, }); } + if (progress.step === "ready" || progress.step === "failed") { + // Invalidate both the grouped list AND the specific workspace + utils.workspaces.getAllGrouped.invalidate(); + utils.workspaces.get.invalidate({ id: progress.workspaceId }); + } }, onError: (error) => { console.error("[workspace-init-subscription] Subscription error:", error); @@ -140,39 +132,17 @@ function AuthenticatedLayout() { // Menu navigation subscription electronTrpc.menu.subscribe.useSubscription(undefined, { onData: (event) => { - if (isTearoffWindow()) return; if (event.type === "open-settings") { const section = event.data.section || "account"; navigate({ to: `/settings/${section}` as "/settings/account" }); } else if (event.type === "open-workspace") { navigate({ to: `/workspace/${event.data.workspaceId}` }); - } else if (event.type === "browser-action") { - dispatchBrowserShortcutEvent(event.data.action); } }, }); - // Redirect to sign-in via useEffect to avoid React infinite update loops. - // can re-trigger during concurrent renders when already at the - // target URL, causing router.load() โ†’ setState โ†’ re-render cycles. - const shouldRedirectToSignIn = - (!env.SKIP_ENV_VALIDATION && isPending && !hasLocalToken) || - (!isSignedIn && - !(hasLocalToken && !isOnline) && - !(isPending || (isRefetching && !session?.user && hasLocalToken))); - - useEffect(() => { - if (shouldRedirectToSignIn && !redirectingToSignIn.current) { - redirectingToSignIn.current = true; - void navigate({ to: "/sign-in", replace: true }); - } - if (!shouldRedirectToSignIn) { - redirectingToSignIn.current = false; - } - }, [shouldRedirectToSignIn, navigate]); - if (isPending && !hasLocalToken && !env.SKIP_ENV_VALIDATION) { - return null; + return ; } if ( (isPending || (isRefetching && !session?.user && hasLocalToken)) && @@ -203,7 +173,7 @@ function AuthenticatedLayout() { } if (!isSignedIn) { - return null; + return ; } if (!activeOrganizationId) { @@ -215,24 +185,26 @@ function AuthenticatedLayout() { - - - - - {isV2CloudEnabled ? ( - - ) : ( - - )} - - {/* FORK NOTE: GitOperationDialog kept for PR/Changes sidebar prompts */} - - - - + + + + + + {isV2CloudEnabled ? ( + + ) : ( + + )} + + {/* FORK NOTE: GitOperationDialog kept for PR/Changes sidebar prompts */} + + + + + diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/DeletingWorkspacesProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/DeletingWorkspacesProvider.tsx new file mode 100644 index 00000000000..72f477304af --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/DeletingWorkspacesProvider.tsx @@ -0,0 +1,76 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +interface DeletingWorkspacesContextValue { + isDeleting: (workspaceId: string) => boolean; + markDeleting: (workspaceId: string) => void; + clearDeleting: (workspaceId: string) => void; +} + +const DeletingWorkspacesContext = + createContext(null); + +/** + * Tracks workspaces whose `workspaceCleanup.destroy` call is in flight. + * The sidebar hides these rows optimistically so users get instant feedback + * instead of watching the row sit there during the 10โ€“20s destroy window. + * On error the caller calls `clearDeleting` and the row reappears; on + * success the row is naturally unmounted via `v2WorkspaceLocalState.delete`. + */ +export function DeletingWorkspacesProvider({ + children, +}: { + children: ReactNode; +}) { + const [ids, setIds] = useState>(() => new Set()); + + const isDeleting = useCallback( + (workspaceId: string) => ids.has(workspaceId), + [ids], + ); + + const markDeleting = useCallback((workspaceId: string) => { + setIds((prev) => { + if (prev.has(workspaceId)) return prev; + const next = new Set(prev); + next.add(workspaceId); + return next; + }); + }, []); + + const clearDeleting = useCallback((workspaceId: string) => { + setIds((prev) => { + if (!prev.has(workspaceId)) return prev; + const next = new Set(prev); + next.delete(workspaceId); + return next; + }); + }, []); + + const value = useMemo( + () => ({ isDeleting, markDeleting, clearDeleting }), + [isDeleting, markDeleting, clearDeleting], + ); + + return ( + + {children} + + ); +} + +export function useDeletingWorkspaces() { + const ctx = useContext(DeletingWorkspacesContext); + if (!ctx) { + throw new Error( + "useDeletingWorkspaces must be used within DeletingWorkspacesProvider", + ); + } + return ctx; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/index.ts new file mode 100644 index 00000000000..be08e18fe85 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/index.ts @@ -0,0 +1,4 @@ +export { + DeletingWorkspacesProvider, + useDeletingWorkspaces, +} from "./DeletingWorkspacesProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx index 2f88f8d9a75..12cb52659a1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx @@ -56,6 +56,11 @@ export function FontFamilyCombobox({ return { nerdFonts: nerd, monoFonts: mono, otherFonts: other }; }, [fonts]); + // Terminal fonts must be monospace โ€” arbitrary free-form names would let + // users pick proportional fonts (see issue #3513), so the custom-entry + // escape hatches below are gated off for the terminal variant. + const allowCustomEntry = variant !== "terminal"; + const hasExactMatch = useMemo(() => { if (!search.trim()) return true; const lower = search.toLowerCase().trim(); @@ -120,7 +125,7 @@ export function FontFamilyCombobox({ /> - {search.trim() ? ( + {allowCustomEntry && search.trim() ? (