diff --git a/.cursor/mcp.json b/.cursor/mcp.json
new file mode 120000
index 00000000000..c67157dc4ab
--- /dev/null
+++ b/.cursor/mcp.json
@@ -0,0 +1 @@
+../.mcp.json
\ No newline at end of file
diff --git a/.github/workflows/cleanup-preview.yml b/.github/workflows/cleanup-preview.yml
index e7b4e732e26..79774cdc5f9 100644
--- a/.github/workflows/cleanup-preview.yml
+++ b/.github/workflows/cleanup-preview.yml
@@ -7,6 +7,7 @@ on:
jobs:
cleanup:
name: Cleanup Preview Resources
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml
index ecd07b1cd7d..84045155038 100644
--- a/.github/workflows/deploy-preview.yml
+++ b/.github/workflows/deploy-preview.yml
@@ -23,6 +23,7 @@ env:
jobs:
deploy-database:
name: Deploy Database (Neon)
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: preview
@@ -80,6 +81,7 @@ jobs:
deploy-electric:
name: Deploy Electric (Fly.io)
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
needs: deploy-database
@@ -125,6 +127,7 @@ jobs:
deploy-api:
name: Deploy API
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: preview
needs: deploy-database
@@ -288,6 +291,7 @@ jobs:
deploy-web:
name: Deploy Web
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment:
name: preview
@@ -406,6 +410,7 @@ jobs:
deploy-marketing:
name: Deploy Marketing
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: preview
needs: deploy-database
@@ -505,6 +510,7 @@ jobs:
deploy-admin:
name: Deploy Admin
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: preview
needs: deploy-database
@@ -623,6 +629,7 @@ jobs:
deploy-docs:
name: Deploy Docs
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: preview
needs: deploy-database
@@ -691,8 +698,8 @@ jobs:
post-final-comment:
name: Post Deployment Comment
+ if: github.repository == 'superset-sh/superset' && always()
runs-on: ubuntu-latest
- if: always()
needs: [deploy-database, deploy-electric, deploy-api, deploy-web, deploy-marketing, deploy-admin, deploy-docs]
permissions:
contents: read
diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml
index a6a32cd0285..05fc0908307 100644
--- a/.github/workflows/deploy-production.yml
+++ b/.github/workflows/deploy-production.yml
@@ -5,12 +5,16 @@ 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
@@ -42,6 +46,7 @@ jobs:
deploy-api:
name: Deploy API to Vercel
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: production
needs: deploy-database
@@ -177,6 +182,7 @@ jobs:
deploy-web:
name: Deploy Web to Vercel
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: production
needs: deploy-database
@@ -266,6 +272,7 @@ jobs:
deploy-marketing:
name: Deploy Marketing to Vercel
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: production
needs: deploy-database
@@ -349,6 +356,7 @@ jobs:
deploy-admin:
name: Deploy Admin to Vercel
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: production
needs: deploy-database
@@ -440,6 +448,7 @@ jobs:
deploy-electric:
name: Deploy Electric to Fly.io
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: production
@@ -467,6 +476,7 @@ jobs:
deploy-electric-proxy:
name: Deploy Electric Proxy to Cloudflare
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: production
@@ -498,6 +508,7 @@ jobs:
deploy-docs:
name: Deploy Docs to Vercel
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
environment: production
needs: deploy-database
diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml
index 98d1324174c..bcbf1918dbd 100644
--- a/.github/workflows/generate-changelog.yml
+++ b/.github/workflows/generate-changelog.yml
@@ -9,6 +9,7 @@ on:
jobs:
generate-changelog:
name: Generate Changelog
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
permissions:
contents: write
diff --git a/.github/workflows/release-desktop-canary.yml b/.github/workflows/release-desktop-canary.yml
index bc408fbe2f7..51c3a92f37f 100644
--- a/.github/workflows/release-desktop-canary.yml
+++ b/.github/workflows/release-desktop-canary.yml
@@ -18,6 +18,7 @@ permissions:
jobs:
check-changes:
name: Check for changes
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -69,7 +70,7 @@ jobs:
build:
needs: check-changes
- if: needs.check-changes.outputs.should_build == 'true'
+ if: github.repository == 'superset-sh/superset' && needs.check-changes.outputs.should_build == 'true'
uses: ./.github/workflows/build-desktop.yml
with:
channel: canary
@@ -82,7 +83,7 @@ jobs:
release:
name: Update Canary Release
needs: [check-changes, build]
- if: needs.check-changes.outputs.should_build == 'true'
+ if: github.repository == 'superset-sh/superset' && needs.check-changes.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml
index b7aa66e97b0..9e405193c8f 100644
--- a/.github/workflows/triage-issue.yml
+++ b/.github/workflows/triage-issue.yml
@@ -17,6 +17,7 @@ concurrency:
jobs:
triage:
name: Triage Issue
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml
index 383594a667a..5f3dfc9f5f2 100644
--- a/.github/workflows/update-docs.yml
+++ b/.github/workflows/update-docs.yml
@@ -9,6 +9,7 @@ on:
jobs:
update-docs:
name: Update Docs
+ if: github.repository == 'superset-sh/superset'
runs-on: ubuntu-latest
permissions:
contents: write
diff --git a/.gitignore b/.gitignore
index f4011029bab..722c0505af7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,10 @@ next-env.d.ts
# Superset (track scripts/config; ignore generated workspace artifacts)
.superset/ports.json
.superset/config.local.json
+# Fork-local: TODO autonomous agent runtime artifacts (goal.md, state files)
+.superset/todo/
+# Fork-local: Claude Code's local worktree scratch dirs
+.claude/worktrees/
# tsbuildinfo
*.tsbuildinfo
@@ -79,14 +83,16 @@ apps/streams/data/
# Generated by setup.sh
Caddyfile
superset-dev-data/
+.upstream-builds/
# Codex workspace config (track only shared config/symlinks; ignore runtime state)
.codex/*
!.codex/config.toml
!.codex/commands
!.codex/prompts
+.serena/
+test-conflict-repo/
.amp/*
-# MCP config (contains per-user server URLs/tokens)
-.mcp.json
-.cursor/mcp.json
+# Claude Code session lock (runtime artifact)
+.claude/scheduled_tasks.lock
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000000..3651aa27461
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,37 @@
+{
+ "mcpServers": {
+ "superset": {
+ "type": "http",
+ "url": "https://api.superset.sh/api/agent/mcp"
+ },
+ "expo-mcp": {
+ "type": "http",
+ "url": "https://mcp.expo.dev/mcp",
+ "enabled": false
+ },
+ "maestro": {
+ "command": "maestro",
+ "args": ["mcp"]
+ },
+ "neon": {
+ "type": "http",
+ "url": "https://mcp.neon.tech/mcp"
+ },
+ "linear": {
+ "type": "http",
+ "url": "https://mcp.linear.app/mcp"
+ },
+ "sentry": {
+ "type": "http",
+ "url": "https://mcp.sentry.dev/mcp"
+ },
+ "posthog": {
+ "type": "http",
+ "url": "https://mcp.posthog.com/mcp"
+ },
+ "desktop-automation": {
+ "command": "bun",
+ "args": ["run", "packages/desktop-mcp/src/bin.ts"]
+ }
+ }
+}
diff --git a/README.md b/README.md
index 86ef7998d8a..40bc76bab43 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,117 @@ Works with any CLI agent. Built for local worktree-based development.
+## Fork 固有の変更点
+
+このリポジトリは [superset-sh/superset](https://github.com/superset-sh/superset) のフォークです。以下の独自変更が含まれています。
+
+| 変更 | 概要 | PR | 追加日 |
+|:-----|:-----|:--:|:------:|
+| **Excel/スプレッドシート ビューア** | .xlsx/.xls/.ods ファイルを書式付きで表示。罫線・結合セル・テーマカラー・リッチテキスト対応。複数シートタブ切り替え、コンテナ幅への自動フィット | [#1](https://github.com/MocA-Love/superset/pull/1) | 2026-03-27 |
+| **Excel diff ビューア** | スプレッドシートのサイドバイサイド差分表示。セル単位の変更ハイライト、Prev/Next ナビゲーション、左右同期スクロール | [#1](https://github.com/MocA-Love/superset/pull/1) | 2026-03-27 |
+| **フォーク版アップデート通知** | 本家 electron-updater を無効化し、GitHub API でフォークリリースをチェックする方式に変更。新バージョン検出時にトースト通知を表示し「Open releases」からダウンロードページへ遷移。4時間ごと+起動時に自動チェック | [#3](https://github.com/MocA-Love/superset/pull/3) [#17](https://github.com/MocA-Love/superset/pull/17) | 2026-03-29 |
+| **ブラウザ webview リロード防止** | タブ/ワークスペース切り替え時に Electron の webview がリロードされる問題を修正。webview を含むタブを keep-alive し、ワークスペースページをルーター上位で保持。WorkspaceIdContext による正しいコンテキスト分離、ホットキーの active-only 制御も実装 | [#2](https://github.com/MocA-Love/superset/pull/2) | 2026-03-28 |
+| **マウス戻る/進むボタン対応** | ブラウザ webview 内でマウスの戻る/進むボタンが動作するように対応。macOS は guest ページへのスクリプト注入、Windows/Linux は app-command イベントで処理 | [#2](https://github.com/MocA-Love/superset/pull/2) | 2026-03-28 |
+| **AI コミットメッセージ生成** | コミットメッセージ入力欄のスパークルボタンで AI が conventional commit メッセージを日本語で自動生成。階層的要約方式(gptcommit 式)により大量差分でも高精度。staged/unstaged/untracked 全対応、lock ファイル・バイナリ自動スキップ | [#4](https://github.com/MocA-Love/superset/pull/4) | 2026-03-28 |
+| **ポートリストのリサイズ・フィルタ** | サイドバーの Ports セクションの高さをドラッグでリサイズ可能に(80–600px、永続化)。フィルタトグルで ports.json に定義されたポートのみ表示し、自動検出ポートを非表示にできる | [#6](https://github.com/MocA-Love/superset/pull/6) | 2026-03-28 |
+| **大規模ファイル diff 高速化** | 2000行超のファイルで CodeMirror 6 ベースの仮想化 diff ビューアに自動切替。ビューポート分のDOMのみ描画し、15000行でもスムーズ表示。既存テーマ・シンタックスハイライト再利用、未変更領域の自動折りたたみ | [#5](https://github.com/MocA-Love/superset/pull/5) | 2026-03-28 |
+| **ports.json ポートの常時表示** | ports.json に定義されたポートをプロセス検出の有無にかかわらず常にサイドバーに表示。Docker 等で検知できないポートもラベル付きで一覧に出る。検出済みポートは従来通りアクティブ表示、未検出は グレー表示で区別 | [#7](https://github.com/MocA-Love/superset/pull/7) | 2026-03-28 |
+| **Ports ワークスペース名の改善** | Ports セクションのワークスペース名をワークツリーのディレクトリ名ベースに変更。同名ワークスペースが複数ある場合でもどのワークツリーか一目で区別可能 | [#8](https://github.com/MocA-Love/superset/pull/8) | 2026-03-28 |
+| **ブラウザタブ機能強化** | ズーム倍率表示と [-]/[+] ボタン(Cmd+/- と同期)、target="_blank" リンクや Cmd+click を新しいブラウザタブで開く機能、URL コピーボタンを追加。タブが非表示中でもリンクイベントを正しく処理するグローバルハンドラ実装 | [#10](https://github.com/MocA-Love/superset/pull/10) | 2026-03-29 |
+| **タブのポップアウト** | ペインツールバーの Pop out ボタンでタブを独立ウィンドウとして分離。閉じるとメインウィンドウに自動返却。ターミナルセッション維持、preload 同期注入方式で Zustand persist との競合を排除 | [#11](https://github.com/MocA-Love/superset/pull/11) | 2026-03-29 |
+| **タブカラー設定** | タブを右クリック → Set Color で13色から背景色を設定可能。ワークスペースセクションと同じカラーパレットを再利用。アクティブ/非アクティブで濃淡が変化し、設定は自動永続化 | [#12](https://github.com/MocA-Love/superset/pull/12) | 2026-03-29 |
+| **クラッシュリカバリー強化** | macOS でアプリが白画面/フリーズする問題を修正。GPU クラッシュ時に最大化/フルスクリーンでもコンポジター再構築を実行、レンダラークラッシュ時の自動リロード/再起動、clipboard 操作のエラーハンドリング追加 | [#13](https://github.com/MocA-Love/superset/pull/13) | 2026-03-29 |
+| **Excel 描画オブジェクト・斜線表示** | Excel ファイルの描画オブジェクト(線・矩形)とセル斜線を表示。xlsx ZIP から drawing XML を直接パースし、CSS transform 方式の SVG オーバーレイで正確に配置 | [#16](https://github.com/MocA-Love/superset/pull/16) | 2026-03-29 |
+| **Chrome 拡張機能インストール** | Chrome Web Store の URL または拡張 ID からブラウザ拡張機能をインストール。CRX ダウンロード・展開、互換性チェック(Electron 非対応 API 検出)、設定画面での管理(有効/無効/削除)。BrowserPane ツールバーに拡張アイコンを表示し、クリックでポップアップウィンドウを表示。GPL ライブラリ不使用、Electron 標準 API のみで自前実装 | [#20](https://github.com/MocA-Love/superset/pull/20) | 2026-03-29 |
+| **Excel diff インラインハイライト** | Excel 差分表示で変更セル内のテキスト差分を文字レベルでインライン表示。追加部分は緑、削除部分は赤+取り消し線。セルからはみ出る場合はホバーでツールチップにフル差分を表示 | [#19](https://github.com/MocA-Love/superset/pull/19) | 2026-03-29 |
+| **Files タブのツールチップ** | ファイルツリーのファイル/フォルダ名にホバーで相対パスをツールチップ表示。ツールバーのトグルボタンで ON/OFF 切り替え、設定は永続化 | [#22](https://github.com/MocA-Love/superset/pull/22) | 2026-03-29 |
+| **Inspect Element(右クリック検証)** | ブラウザペインの右クリックメニューに「Inspect Element」を追加。クリック位置の要素を直接 DevTools でインスペクト可能 | [#23](https://github.com/MocA-Love/superset/pull/23) | 2026-03-30 |
+| **Branch ワークスペースの PR 表示対応** | worktree を切らない「branch」タイプのワークスペースでも Review タブに PR 情報・チェック結果・レビューコメントを表示。`getGitHubStatus` / `getGitHubPRComments` が worktree レコード必須だった制限を、`mainRepoPath` へのフォールバックで解消 | [#24](https://github.com/MocA-Love/superset/pull/24) | 2026-03-30 |
+| **シェル履歴サジェスト** | ターミナル入力時に ~/.zsh_history からコマンド候補をドロップダウン表示。↑↓で選択、→で確定、Escで破棄。選択中コマンドのフルプレビュー付き(補完部分を緑色で強調)。8件超はスクロール、末尾到達で追加読み込み。設定画面から ON/OFF 切り替え可能 | [#24](https://github.com/MocA-Love/superset/pull/24) | 2026-03-30 |
+| **Sentry エラー監視統合** | 自前の Sentry プロジェクトと連携可能。`.env` に `SENTRY_DSN_DESKTOP` を設定するだけで本番ビルドのクラッシュ・エラーを自動収集 | [#26](https://github.com/MocA-Love/superset/pull/26) | 2026-03-30 |
+| **デスクトップ安定性修正** | シェル履歴サジェストが表示されないバグ(useEffect 依存配列の問題)、アプリ終了時の napi_fatal_error クラッシュ(SQLite 未クローズ)、webview パーキング後の getURL() エラー、サイドバーリサイズが webview 上で効かない問題を修正 | [#26](https://github.com/MocA-Love/superset/pull/26) | 2026-03-30 |
+| **Review パネル強化** | GitHub Actions チェックを展開してジョブ内ステップの進捗を表示。レビューコメントを展開して Markdown レンダリング全文表示(GitHub Alerts 対応)。コメントのファイルパス+行番号クリックでエディタの該当行にジャンプ | [#27](https://github.com/MocA-Love/superset/pull/27) | 2026-03-30 |
+| **サジェストバグ修正** | ドロップダウンのはみ出し防止(上側表示切替)、alternate screen(Claude Code等)中のサジェスト完全抑制(4層防御)、Agent操作中の非表示化、日本語文字化け修正(zsh metafied エンコーディング対応) | [#31](https://github.com/MocA-Love/superset/pull/31) | 2026-03-30 |
+| **サジェスト履歴削除** | サジェスト一覧の各候補にバツボタンを追加し、クリックで ~/.zsh_history から直接削除。atomic write でファイル破損防止、metafied エンコーディング対応 | [#34](https://github.com/MocA-Love/superset/pull/34) | 2026-03-30 |
+| **ブラウザアドレスバー選択修正** | アドレスバーでURLをマウスドラッグで範囲選択しようとするとペインが移動する問題を修正。input の mousedown イベント伝播を阻止 | [#34](https://github.com/MocA-Love/superset/pull/34) | 2026-03-30 |
+| **git blame インライン表示** | ファイルビューアで行番号横に blame 情報をインライン表示。行ホバーで作者・コミットメッセージ・日時のポップアップを表示。表示タイミングを修正し、ファイル切り替え後も正しく動作 | [#38](https://github.com/MocA-Love/superset/pull/38) | 2026-03-31 |
+| **マージコンフリクト解消 UI** | diff ビューア内でコンフリクトマーカーをインラインで検出し、VSCode スタイルの「Accept Current / Accept Incoming / Accept Both」ボタンを表示。ワンクリックでコンフリクトを解消可能 | [#38](https://github.com/MocA-Love/superset/pull/38) | 2026-03-31 |
+| **GitGraph 詳細パネル修正** | GitGraph の詳細パネルがペイン外にはみ出る問題を修正。パネルの位置計算を改善し、画面端でも正しく収まるよう対応 | [#38](https://github.com/MocA-Love/superset/pull/38) | 2026-03-31 |
+| **ConflictViewer 表示・スタイル修正** | ConflictViewer の表示条件とスタイルを修正 | [#38](https://github.com/MocA-Love/superset/pull/38) | 2026-03-31 |
+| **ワークスペース切替・レビュー系 UX 強化** | Branch picker の検索・作成導線とブランチ情報表示を改善、blame tooltip に GitHub avatar を追加。ターミナル履歴サジェストの Enter/補完・プレビュー挙動を改善 | [#40](https://github.com/MocA-Love/superset/pull/40) | 2026-03-31 |
+| **Review パネル URL ナビゲーション改善** | Review 内のコメント・PR タイトル・Markdown 内リンクを Superset のブラウザタブで新規開くよう統一。既存ブラウザタブの URL 差し替え問題を回避 | [#35](https://github.com/MocA-Love/superset/pull/35) | 2026-03-30 |
+| **Problems / Database Explorer / Search 強化** | エディターの問題診断 `Problems` タブを追加し、Workspace 全体の警告・エラーを絞り込み・再取得・該当行ジャンプ可能に。右サイドバーへ Database Explorer と Search(glob/正規表現/置換)を追加 | [#44](https://github.com/MocA-Love/superset/pull/44) | 2026-04-01 |
+| **言語診断の多言語対応拡張** | Diagnostics の LSP 基盤を外部 Language Server 化し、YAML / HTML / CSS / Python / Go / Rust / Dockerfile / GraphQL に対応。provider の ON/OFF 切替と runtime materialization を整備 | [#48](https://github.com/MocA-Love/superset/pull/48) | 2026-04-02 |
+| **Docker サイドバーと検索・DB設定の大規模追加** | 右サイドバーに Docker ビューを追加してコンテナ/イメージ/ボリュームを管理。Search を木構造・仮想スクロール化し大量件数を高速化。workspace DB 設定の読み書き UI を追加 | [#51](https://github.com/MocA-Love/superset/pull/51) | 2026-04-02 |
+| **ブラウザブックマーク管理** | ブックマークのフォルダ作成・ネスト・並び替え、Netscape HTML 形式のインポート/エクスポート、フォルダアイコン・カラー設定 | [#55](https://github.com/MocA-Love/superset/pull/55) | 2026-04-03 |
+| **.env / CSV / TSV シンタックスハイライト** | `.env` / `.env.*` ファイルのシンタックスハイライト対応。CSV / TSV は列ごとにテーマカラーをローテーションして表示 | [#64](https://github.com/MocA-Love/superset/pull/64) | 2026-04-04 |
+| **HTML ファイルプレビュー** | HTML ファイルをサンドボックス化された webview でレンダリング表示。ズーム操作(+/-/リセット)、リフレッシュボタン、ファイル変更時の自動リロード対応 | [#69](https://github.com/MocA-Love/superset/pull/69) [#77](https://github.com/MocA-Love/superset/pull/77) [#144](https://github.com/MocA-Love/superset/pull/144) | 2026-04-04 |
+| **PDF ファイルプレビュー** | Chromium 内蔵の PDF ビューアを webview 経由で利用。ズーム・ページ送り・テキスト検索がそのまま使用可能 | [#70](https://github.com/MocA-Love/superset/pull/70) | 2026-04-04 |
+| **GitHub Actions ログビューア** | Review タブの Checks から「View logs」でネイティブログ表示。ジョブ一覧+ステップ開閉式ログ、ANSI カラー対応、ログ検索、ログコピー(ANSI/タイムスタンプ除去)。Re-run ボタン、リアルタイムポーリング更新 | [#72](https://github.com/MocA-Love/superset/pull/72) [#73](https://github.com/MocA-Love/superset/pull/73) [#122](https://github.com/MocA-Love/superset/pull/122) | 2026-04-04 |
+| **Workflow Dispatch UI** | workflow_dispatch の inputs(choice/boolean/string/number)を YAML からパースして UI 表示。ワークフロー実行後はリアルタイムでログに自動遷移 | [#75](https://github.com/MocA-Love/superset/pull/75) | 2026-04-04 |
+| **フォークリポジトリ PR 対応** | fork / tracking remote / upstream が混在するリポジトリで PR の向き先候補を自動解決。base repository 選択 UI を追加 | [#71](https://github.com/MocA-Love/superset/pull/71) [#101](https://github.com/MocA-Love/superset/pull/101) | 2026-04-04 |
+| **GitHub API 最適化** | 複数ポーリング経路を GitHubSyncService に統合。指数バックオフ付きレートリミッター、アクティブワークスペースのみポーリング(API calls/min: ~75 → ~15) | [#78](https://github.com/MocA-Love/superset/pull/78) [#80](https://github.com/MocA-Love/superset/pull/80) | 2026-04-05 |
+| **Docker タブ UX 改善** | コンテナに Rebuild/Delete ボタンとステータス連動コントロールを追加。Database サイドバーをワークスペースごとにスコープ化。Dockerfile 単体プロジェクトでも Docker タブを表示 | [#69](https://github.com/MocA-Love/superset/pull/69) [#76](https://github.com/MocA-Love/superset/pull/76) [#79](https://github.com/MocA-Love/superset/pull/79) | 2026-04-04 |
+| **Markdown / シンタックスハイライト強化** | CodeMirror で Lezer の全タグをカバーし VS Code 並のハイライト品質を実現。Markdown の fenced code blocks 内で 19 言語のネスト言語ハイライト対応 | [#90](https://github.com/MocA-Love/superset/pull/90) | 2026-04-06 |
+| **VS Code Extension Host Shim** | VS Code 拡張機能ホストシム層を追加(約30 API をシム実装)。Claude Code 拡張の完全なチャット UI 表示・MCP 接続、Codex/ChatGPT 拡張のチャット UI 表示に対応。Webview 配信、Commands、Workspace API 等を実装 | [#91](https://github.com/MocA-Love/superset/pull/91) | 2026-04-06 |
+| **インライン自動補完(Inception)** | FIM(Fill-in-the-Middle)を優先し Next Edit をフォールバックに使う補完フロー。Inception usage のローカル集計と設定画面表示。過剰発火の抑制 | [#92](https://github.com/MocA-Love/superset/pull/92) [#132](https://github.com/MocA-Love/superset/pull/132) | 2026-04-06 |
+| **vscode.diff コマンド対応** | Codex 拡張の「Review changes」ボタンから Superset の diff viewer を直接開けるよう `vscode.diff` コマンドをシム実装 | [#104](https://github.com/MocA-Love/superset/pull/104) | 2026-04-08 |
+| **メモタブ(Memo)** | `.superset/memos/` に保存されるメモを作成可能。Markdown エディタで画像を貼り付けると assets に保存し相対パスを自動挿入。自動保存対応 | [#129](https://github.com/MocA-Love/superset/pull/129) | 2026-04-09 |
+| **右サイドバー初期幅設定** | 右サイドバーから開く Files や Changes diff ビューの初期幅を設定で変更可能に | [#130](https://github.com/MocA-Love/superset/pull/130) | 2026-04-09 |
+| **リファレンスグラフ** | LSP 基盤を拡張し、シンボルの参照関係・呼び出し階層をインタラクティブなグラフで可視化。@xyflow/react + ELK.js による自動レイアウト、Shiki シンタックスハイライト統合、PNG エクスポート対応。エディタ右クリックから「Show Reference Graph」で起動 | [#147](https://github.com/MocA-Love/superset/pull/147) [#148](https://github.com/MocA-Love/superset/pull/148) | 2026-04-11 |
+| **Git 操作ダイアログ統一** | Git 関連エラーとユーザー判断を統一 `GitOperationDialog` に集約。25 種類のエラー自動分類、merge-pr・bulk-stage-all・workflow-dispatch 等の確認ダイアログ、silent auto-repair 通知 | [#153](https://github.com/MocA-Love/superset/pull/153) | 2026-04-12 |
+| **UX 改善バッチ** | Clone 進捗のストリーミング表示(プログレスバー+キャンセル)、Diff Viewer 内検索、タブ切替時の editor state 保持、Git サイドバーの複数選択 stage/unstage(Shift/Cmd+Click)、内蔵ブラウザの Cmd+F 検索 | [#154](https://github.com/MocA-Love/superset/pull/154) | 2026-04-13 |
+| **Hover / Go-to-Definition** | エディタで変数・関数にホバーすると Markdown レンダリング対応の型情報・ドキュメントを表示。Shiki ベースのコードブロックハイライト付き。F12 / Cmd+Click / 右クリック「Go to Definition」で定義元にジャンプ。Cmd 押下時にトークンへ下線表示。TypeScript + 外部 LSP 対応 | [#156](https://github.com/MocA-Love/superset/pull/156) [#166](https://github.com/MocA-Love/superset/pull/166) | 2026-04-14 |
+| **タブ分割ボタン** | タブツールバーに縦分割・横分割ボタンを追加。ワンクリックでペインを分割可能 | [#155](https://github.com/MocA-Love/superset/pull/155) | 2026-04-14 |
+| **安定性・パフォーマンス改善** | LSP language services の安定性修正、拡張機能ホストのメモリリーク修正、ターミナル再表示遅延改善、認証切れ時の無限ループ防止、git status タイムアウト追加、ブラウザリダイレクトループ修正、ポップアウトウィンドウの認証修正、エラーの正規化と Sentry フィルタリング | [#88](https://github.com/MocA-Love/superset/pull/88) [#123](https://github.com/MocA-Love/superset/pull/123) [#121](https://github.com/MocA-Love/superset/pull/121) [#67](https://github.com/MocA-Love/superset/pull/67) [#66](https://github.com/MocA-Love/superset/pull/66) [#158](https://github.com/MocA-Love/superset/pull/158) [#146](https://github.com/MocA-Love/superset/pull/146) [#98](https://github.com/MocA-Love/superset/pull/98) | 2026-04-04〜14 |
+| **内部ブラウザの File System Access API 拒否回避** | 内部ブラウザで react-dropzone 系サイトを開くと `FileSystemFileHandle.getFile()` が NotAllowedError で落ちる問題を修正。`persist:superset` セッションに preload を追加し `DataTransferItem.getAsFileSystemHandle()` を null 返却に差し替えて legacy D&D パスへフォールバック | [#207](https://github.com/MocA-Love/superset/pull/207) | 2026-04-16 |
+| **PR コメント返信** | Review タブのコメント右上に Reply ボタンを追加。ダイアログから直接返信を投稿できる。レビュースレッドへの返信と通常 PR コメントの両方に対応 | [#206](https://github.com/MocA-Love/superset/pull/206) | 2026-04-16 |
+| **TODO Agent スケジュール実行** | 毎日デプロイ / 毎時 lint のような定型 TODO を UI ビルダー (毎時/毎日/毎週/毎月/cron) で登録可能。アプリ起動中に時刻が来ると TODO セッションが自動作成され発火トーストを表示。前回未完了時は skip / queue 選択可 | [#211](https://github.com/MocA-Love/superset/pull/211) | 2026-04-16 |
+| **TODO 詳細の添付画像 chip 化+プレビュー** | TODO 作成時に「やってほしいこと」「ゴール」へ貼り付けた画像を、タスク詳細画面でクリップマーク + ファイル名の chip として表示。クリックでネスト Dialog の画像プレビューを開ける(AgentManager は閉じない)。`todo-agent/attachments/` 配下のみを許可するパス検証付き `readAttachment` tRPC を追加 | [#229](https://github.com/MocA-Love/superset/pull/229) | 2026-04-16 |
+| **AgentManager 見切れ救済** | AgentManager 左サイドバーのワークスペース見出し・セッションタイトル、右 ChangesSidebar のブランチ/ファイルパス/コミット subject/選択ヘッダーが狭幅で見切れていた問題を修正。`truncate` + ホバー時 `Tooltip` で全文表示 | [#254](https://github.com/MocA-Love/superset/pull/254) | 2026-04-17 |
+| **Excel diff / raw viewer の透過抑止** | Appearance の透過設定 (vibrancy) ON 時に Excel ビューア / Excel diff / 画像プレビュー / HTML プレビューの背景まで透けていた問題を修正。これらの読み取り専用サーフェスは `bg-background-solid` に差し替えてダイアログと同様に不透明で維持 | [#266](https://github.com/MocA-Love/superset/pull/266) | 2026-04-17 |
+
+## Fork のビルド方法 (macOS)
+
+### 前提条件
+
+- [Bun](https://bun.sh/) v1.0+
+- Git 2.20+
+- Xcode Command Line Tools (`xcode-select --install`)
+
+### 手順
+
+```bash
+# 1. リポジトリをクローン
+git clone https://github.com/MocA-Love/superset.git
+cd superset
+
+# 2. 依存関係のインストール
+bun install
+
+# 3. デスクトップアプリをビルド
+cd apps/desktop
+SUPERSET_WORKSPACE_NAME=superset bun run build
+
+# 4. ビルド成果物を開く
+open release
+```
+
+`release` フォルダ内の `.dmg` ファイルを開き、Superset.app を Applications にドラッグしてインストールしてください。
+
+> **⚠️ ビルド時の注意**: `bun dev` でアプリを起動中にビルドすると、開発用の環境変数(`SUPERSET_WORKSPACE_NAME=default` 等)がバイナリに焼き込まれ、本番データ(`~/.superset/`)が参照されなくなります。ビルド時は必ず `SUPERSET_WORKSPACE_NAME=superset` を明示的に指定してください。
+
+> **📦 上書きインストールについて**: 公式版の `.dmg` をフォーク版で上書きしても、ワークスペース・ターミナル履歴・設定はすべて `~/.superset/` に保持されるため、データが消えることはありません。
+
+### 開発モードで実行
+
+```bash
+bun install
+bun run dev --filter=@superset/desktop
+```
+
+---
+
## Code 10x Faster With No Switching Cost
Superset orchestrates CLI-based coding agents across isolated git worktrees, with built-in terminal, review, and open-in-editor workflows.
diff --git a/a.html b/a.html
new file mode 100644
index 00000000000..d462412092a
--- /dev/null
+++ b/a.html
@@ -0,0 +1,183 @@
+
+
+
+
+ 画像添付アイコン候補
+
+
+
+
+
+
+
+
+
+
+
HiMiniPaperClip
+
+ Heroicons · 「添付」の普遍的表現
(PlusMenuで既に使用中)
+
+
+
+
+ screenshot-2026.png
+ ×
+
+
+
+
+
+
HiMiniPhoto
+
+ Heroicons · 画像固有(山+太陽)。現在の実装
+
+
+
+
+ screenshot-2026.png
+ ×
+
+
+
+
+
+
HiMiniCamera
+
Heroicons · スクショ感
+
+
+
+ screenshot-2026.png
+ ×
+
+
+
+
+
+
LuPaperclip
+
Lucide · 線画の軽快さ
+
+
+
+ screenshot-2026.png
+ ×
+
+
+
+
+
+
LuImage
+
+ Lucide · 枠+山+円。outlineでシャープ
(RepositoryPanelで使用中)
+
+
+
+
+ screenshot-2026.png
+ ×
+
+
+
+
+
+
LuFileImage
+
+ Lucide · ファイル+画像。「画像ファイル」を明示
+
+
+
+
+ screenshot-2026.png
+ ×
+
+
+
+
+
+
LuImageUp
+
Lucide · 画像+上向き矢印(アップロード)
+
+
+
+ screenshot-2026.png
+ ×
+
+
+
+
+
+
LuImages
+
Lucide · 画像スタック、複数枚感
+
+
+
+ screenshot-2026.png
+ ×
+
+
+
+
+
+
+
+
diff --git a/plans/v2-pr-link-command-design.md b/apps/desktop/V2_PR_LINK_COMMAND_DESIGN.md
similarity index 100%
rename from plans/v2-pr-link-command-design.md
rename to apps/desktop/V2_PR_LINK_COMMAND_DESIGN.md
diff --git a/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md b/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md
index d0db24298c3..282aa33f170 100644
--- a/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md
+++ b/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md
@@ -1,18 +1,6 @@
# V2 Workspace Creation Modal — Gap Analysis vs V1
-> Generated 2026-04-11. Last updated 2026-04-12. Compares V2 (`DashboardNewWorkspaceModal`) against V1 (`NewWorkspaceModal`).
-
-## Status Summary
-
-| # | Gap | Status |
-|---|-----|--------|
-| 1 | Project Picker — Open/New project actions | Open |
-| 2 | Branch Picker — Worktree awareness | Open |
-| 3 | AI Branch Name Generation | Open |
-| 4 | GitHub Issue Content Auto-Fetching | Open |
-| 5 | Agent Launch Request Building | Open |
-| 6 | Dedicated "Create from PR" Flow | Open |
-| 7 | PR URL Parsing and Cross-Repo Validation | ✅ Resolved (PR #3356) — extended to issues |
+> Generated 2026-04-11. Compares V2 (`DashboardNewWorkspaceModal`) against V1 (`NewWorkspaceModal`).
## File References
@@ -99,17 +87,14 @@
---
-### 7. PR URL Parsing and Cross-Repo Validation — ✅ Resolved (PR #3356)
+### 7. PR URL Parsing and Cross-Repo Validation
**V1**: `PRLinkCommand` parses pasted GitHub PR URLs (`github.com/:owner/:repo/pull/:number`), detects cross-repository links, and shows an error ("PR URL must match {repo}") for mismatched repos.
-**V2 (resolved)**: Server-side `normalizeGitHubQuery` in host-service handles URL parsing, `#123` / bare-number shorthand, and cross-repo validation. Response returns `{ repoMismatch: "owner/repo" }` and client shows "PR URL must match owner/repo." Same normalization also extended to `searchGitHubIssues`. Debounce-gap loading state (`isPendingDebounce`) added to prevent empty-state flash.
+**V2**: `PRLinkCommand` uses host-service `searchPullRequests` endpoint only. No client-side URL parsing or cross-repo validation.
-**Resolved by**: PR #3356 (merged 2026-04-11)
-**Refs**:
-- `packages/host-service/src/trpc/router/workspace-creation/normalize-github-query.ts`
-- `…/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx`
-- `…/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx`
+**V1 ref**: `PRLinkCommand.tsx:37-53, 86-97`
+**V2 ref**: `PRLinkCommand.tsx` (V2 version)
---
@@ -119,7 +104,7 @@
---
-## Priority Assessment (remaining)
+## Priority Assessment
| # | Gap | Impact | Effort |
|---|-----|--------|--------|
@@ -129,4 +114,4 @@
| 6 | Dedicated "create from PR" flow | Medium — PR workspaces may not set up branches properly | Medium |
| 2 | Branch picker worktree awareness | Medium — can't discover/open existing worktrees | High |
| 1 | Project picker open/new actions | Low — can do this outside the modal | Low |
-| ~~7~~ | ~~PR URL parsing / cross-repo validation~~ | ✅ Resolved by #3356 | — |
+| 7 | PR URL parsing / cross-repo validation | Low — server search covers most cases | Low |
diff --git a/apps/desktop/docs/LANGUAGE_SERVICES.md b/apps/desktop/docs/LANGUAGE_SERVICES.md
new file mode 100644
index 00000000000..99d8347102c
--- /dev/null
+++ b/apps/desktop/docs/LANGUAGE_SERVICES.md
@@ -0,0 +1,128 @@
+# Desktop Language Services
+
+This document tracks the IDE-oriented diagnostics stack used by the desktop app.
+
+## Goals
+
+- Keep editor and sidebar UI stable while adding language-specific diagnostics.
+- Match VS Code behavior as closely as practical for each language.
+- Make it easy to add new providers behind the same manager/store/router flow.
+
+## Current Providers
+
+### TypeScript / JavaScript / TSX / JSX
+
+- Backend: `tsserver`
+- Reason: VS Code uses `tsserver` for TypeScript and JavaScript language features, so this is the closest path to parity.
+- Source:
+ - https://github.com/microsoft/TypeScript/wiki/Standalone-Server-%28tsserver%29
+
+### JSON / JSONC
+
+- Backend: `vscode-json-languageservice`
+- Reason: This is the JSON language service used in the VS Code ecosystem and supports schema-backed validation.
+- Source:
+ - https://github.com/microsoft/vscode-json-languageservice
+
+### YAML
+
+- Backend: `yaml-language-server`
+- Reason: This is the YAML language server used by the Red Hat YAML extension and supports schema-backed validation through SchemaStore.
+- Source:
+ - https://github.com/redhat-developer/yaml-language-server
+
+### HTML
+
+- Backend: `vscode-html-language-server` from `vscode-langservers-extracted`
+- Reason: The language service package itself does not expose diagnostics, so HTML now uses the bundled VS Code language server path.
+- Source:
+ - https://www.npmjs.com/package/vscode-langservers-extracted
+
+### CSS / SCSS / LESS
+
+- Backend: `vscode-css-languageservice`
+- Reason: This is the CSS language service used in the VS Code ecosystem.
+- Source:
+ - https://github.com/microsoft/vscode-css-languageservice
+
+### TOML
+
+- Backend: `@taplo/lib`
+- Reason: Taplo is the de facto TOML toolkit with a maintained JavaScript/WASM entrypoint suitable for desktop embedding.
+- Source:
+ - https://taplo.tamasfe.dev/lib/javascript/lib.html
+
+### Dart / Flutter
+
+- Backend: Dart language server via `dart language-server`
+- Reason: This matches the official Dart analysis server/LSP flow and works for both Dart and Flutter projects.
+- Sources:
+ - https://dart.dev/tools/analysis-server
+ - https://raw.githubusercontent.com/dart-lang/sdk/main/pkg/analysis_server/tool/lsp_spec/README.md
+
+### Python
+
+- Backend: `pyright-langserver`
+- Reason: Pyright is the TypeScript-based Python language server behind the Pyright ecosystem and is close to the VS Code extension path.
+- Source:
+ - https://github.com/microsoft/pyright
+
+### Go
+
+- Backend: `gopls`
+- Reason: `gopls` is the official Go language server maintained by the Go team.
+- Source:
+ - https://go.dev/gopls/
+
+### Rust
+
+- Backend: `rust-analyzer`
+- Reason: `rust-analyzer` is the standard Rust language server used by most editors, including VS Code setups.
+- Source:
+ - https://rust-analyzer.github.io/book/
+
+### Dockerfile
+
+- Backend: `dockerfile-language-server-nodejs`
+- Reason: This is the Dockerfile language server used by the VS Code Docker tooling ecosystem.
+- Source:
+ - https://github.com/rcjsuen/dockerfile-language-server-nodejs
+
+### GraphQL
+
+- Backend: `graphql-language-service-cli`
+- Reason: This provides the `graphql-lsp` server from the GraphiQL language-service stack.
+- Source:
+ - https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service-cli
+
+## Architecture
+
+- `main/lib/language-services/manager.ts`
+ - Registers providers
+ - Tracks provider enable/disable state
+ - Produces workspace snapshots for the Problems view
+- `main/lib/language-services/diagnostics-store.ts`
+ - Holds normalized diagnostics per provider/file/workspace
+- `main/lib/language-services/lsp/StdioJsonRpcClient.ts`
+ - Shared stdio JSON-RPC transport for LSP-based providers
+- `main/lib/language-services/lsp/ExternalLspLanguageProvider.ts`
+ - Shared LSP provider implementation for stdio-based language servers
+- `renderer/providers/LanguageServicesProvider`
+ - Syncs open editor documents to enabled providers
+- `renderer/routes/_authenticated/settings/behavior/components/DiagnosticsSettings`
+ - Lets users toggle providers on or off
+
+## Adding a New Provider
+
+1. Implement `LanguageServiceProvider`.
+2. Normalize diagnostics into `LanguageServiceDiagnostic`.
+3. Register the provider in `LanguageServiceManager`.
+4. Add a renderer-side language mapping in `LanguageServicesProvider`.
+5. Add syntax highlighting support if needed in `detect-language.ts` and `loadLanguageSupport.ts`.
+6. Extend the settings store/provider ID union if the provider should be user-toggleable.
+
+## Runtime Notes
+
+- TypeScript, Python, YAML, Dockerfile and GraphQL diagnostics are bundled from Node packages and launched with `ELECTRON_RUN_AS_NODE=1`.
+- Go diagnostics require `gopls` to be available on the user's PATH.
+- Rust diagnostics require `rust-analyzer` to be available on the user's PATH.
diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts
index 2c33562eb2c..7d1f3b68f00 100644
--- a/apps/desktop/electron-builder.ts
+++ b/apps/desktop/electron-builder.ts
@@ -114,6 +114,9 @@ const config: Configuration = {
// Required for macOS microphone permission prompt
NSMicrophoneUsageDescription:
"Superset needs microphone access so voice-enabled tools like Codex transcription can capture audio input.",
+ // Required for macOS camera permission prompt
+ NSCameraUsageDescription:
+ "Superset needs camera access so websites and tools running inside the app can capture video input.",
// Required for macOS local network permission prompt
NSLocalNetworkUsageDescription:
"Superset needs access to your local network to discover and connect to development servers running on your network.",
diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
index 5f073935c45..2bc9acd76f6 100644
--- a/apps/desktop/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -36,8 +36,8 @@ const workspaceDependencies = Object.keys(dependencies).filter((dependency) =>
// Sentry plugin for uploading sourcemaps (only in CI with auth token)
const sentryPlugin = process.env.SENTRY_AUTH_TOKEN
? sentryVitePlugin({
- org: "superset-sh",
- project: "desktop",
+ org: "maguro-bot-corp",
+ project: "electron",
authToken: process.env.SENTRY_AUTH_TOKEN,
release: { name: version },
})
@@ -107,10 +107,17 @@ export default defineConfig({
"terminal-host": resolve("src/main/terminal-host/index.ts"),
// PTY subprocess - spawned by terminal-host for each terminal
"pty-subprocess": resolve("src/main/terminal-host/pty-subprocess.ts"),
+ // TODO agent daemon - owns `claude -p` children so autonomous
+ // TODO sessions survive app restarts (issue #237).
+ "todo-daemon": resolve("src/main/todo-daemon/index.ts"),
// Worker-thread entrypoint for heavy git/status computations
"git-task-worker": resolve("src/main/git-task-worker.ts"),
// Workspace service - local HTTP/tRPC server per org
"host-service": resolve("src/main/host-service/index.ts"),
+ // VS Code extension host worker - one per active workspace
+ "extension-host-worker": resolve(
+ "src/main/extension-host-worker/index.ts",
+ ),
},
output: {
dir: resolve(devPath, "main"),
@@ -154,6 +161,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve("src/preload/index.ts"),
+ "webview-compat": resolve("src/preload/webview-compat.ts"),
},
},
},
@@ -179,6 +187,10 @@ export default defineConfig({
process.env.NEXT_PUBLIC_MARKETING_URL,
"https://superset.sh",
),
+ "process.env.NEXT_PUBLIC_OPEN_LINK_URL": defineEnv(
+ process.env.NEXT_PUBLIC_OPEN_LINK_URL,
+ "https://superset.m4gu.dev",
+ ),
"process.env.NEXT_PUBLIC_ELECTRIC_URL": defineEnv(
process.env.NEXT_PUBLIC_ELECTRIC_URL,
"https://electric-proxy.avi-6ac.workers.dev",
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 2ae0bd31e70..a07bf1c8bd5 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -18,8 +18,8 @@
"start": "electron-vite preview",
"generate:icons": "bun run scripts/generate-file-icons.ts",
"predev": "cross-env NODE_ENV=development bun run clean:dev && bun run generate:icons && cross-env NODE_ENV=development bun run scripts/clean-launch-services.ts && cross-env NODE_ENV=development bun run scripts/patch-dev-protocol.ts",
- "dev": "cross-env NODE_ENV=development electron-vite dev --watch",
- "compile:app": "cross-env NODE_OPTIONS=--max-old-space-size=8192 electron-vite build",
+ "dev": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 electron-vite dev --watch",
+ "compile:app": "cross-env NODE_OPTIONS=--max-old-space-size=12288 electron-vite build",
"copy:native-modules": "bun run scripts/copy-native-modules.ts",
"validate:native-runtime": "bun run scripts/validate-native-runtime.ts",
"prebuild": "bun run clean:dev && bun run generate:icons && bun run compile:app && bun run copy:native-modules && bun run validate:native-runtime",
@@ -58,6 +58,7 @@
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.2",
"@codemirror/legacy-modes": "^6.5.2",
+ "@codemirror/merge": "^6.12.1",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -85,6 +86,7 @@
"@superset/host-service": "workspace:*",
"@superset/local-db": "workspace:*",
"@superset/macos-process-metrics": "workspace:*",
+ "@superset/macos-window-blur": "workspace:*",
"@superset/panes": "workspace:*",
"@superset/shared": "workspace:*",
"@superset/trpc": "workspace:*",
@@ -99,6 +101,7 @@
"@tanstack/react-router": "^1.147.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
+ "@taplo/lib": "^0.5.0",
"@tiptap/core": "^3.17.1",
"@tiptap/extension-blockquote": "^3.17.1",
"@tiptap/extension-bold": "^3.17.1",
@@ -148,21 +151,32 @@
"@xterm/addon-webgl": "0.20.0-beta.194",
"@xterm/headless": "6.1.0-beta.195",
"@xterm/xterm": "6.1.0-beta.195",
+ "@xyflow/react": "^12.10.0",
"ai": "^6.0.0",
+ "ansi_up": "^6.0.6",
"better-auth": "1.5.6",
"better-sqlite3": "12.6.2",
"bindings": "^1.5.0",
"bufferutil": "^4.1.0",
"clsx": "^2.1.1",
+ "cron-parser": "^5.5.0",
+ "cronstrue": "^3.14.0",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"default-shell": "^2.2.0",
"detect-libc": "2.0.4",
"dexie": "^4.4.2",
+ "diff": "^7.0.0",
"dnd-core": "^16.0.1",
+ "dockerfile-ast": "0.7.1",
+ "dockerfile-language-server-nodejs": "^0.15.0",
+ "dockerfile-language-service": "0.16.1",
+ "dockerfile-utils": "0.16.3",
"dotenv": "^17.3.1",
"drizzle-orm": "0.45.1",
"electron-updater": "^6.8.3",
+ "elkjs": "^0.11.1",
+ "exceljs": "^4.4.0",
"execa": "^9.6.0",
"express": "^5.1.0",
"fast-glob": "^3.3.3",
@@ -170,10 +184,15 @@
"framer-motion": "^12.23.26",
"friendly-words": "^1.3.1",
"fuse.js": "^7.1.0",
+ "graphql": "^16.13.2",
+ "graphql-language-service-cli": "^3.5.0",
"highlight.js": "^11.11.1",
+ "html-to-image": "^1.11.13",
"http-proxy": "^1.18.1",
"idb-keyval": "^6.2.2",
"jose": "^6.1.3",
+ "js-yaml": "^4.1.1",
+ "jszip": "^3.10.1",
"libsql": "0.5.22",
"line-column-path": "^3.0.0",
"lodash": "^4.17.21",
@@ -185,17 +204,19 @@
"node-addon-api": "^7.1.0",
"node-pty": "1.1.0",
"os-locale": "^6.0.2",
+ "pg": "8.20.0",
"pidtree": "^0.6.0",
"pidusage": "^4.0.1",
"posthog-js": "1.310.1",
"posthog-node": "^5.24.7",
"prebuild-install": "^7.1.1",
+ "pyright": "^1.1.408",
"react": "19.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.2.0",
"react-hook-form": "^7.71.1",
- "react-hotkeys-hook": "^5.2.4",
+ "react-hotkeys-hook": "5.2.4",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-mosaic-component": "^6.1.1",
@@ -204,9 +225,11 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
+ "remark-github-blockquote-alert": "^2.1.0",
"semver": "^7.7.3",
"shell-env": "^4.0.3",
"shell-quote": "^1.8.3",
+ "shiki": "^3.21.0",
"simple-git": "^3.30.0",
"streamdown": "2.5.0",
"strip-ansi": "^7.1.2",
@@ -220,6 +243,13 @@
"use-resize-observer": "^9.1.0",
"utf-8-validate": "^6.0.6",
"uuid": "^13.0.0",
+ "vscode-css-languageservice": "^6.3.10",
+ "vscode-html-languageservice": "^5.6.2",
+ "vscode-json-languageservice": "^5.7.2",
+ "vscode-langservers-extracted": "^4.10.0",
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-languageserver-types": "3.17.3",
+ "yaml-language-server": "^1.21.0",
"zod": "^4.3.5",
"zustand": "^5.0.8"
},
@@ -232,9 +262,12 @@
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.2.17",
"@types/culori": "^4.0.1",
+ "@types/diff": "^6.0.0",
"@types/http-proxy": "^1.17.17",
+ "@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.20",
"@types/node": "^24.9.1",
+ "@types/pg": "8.15.6",
"@types/react": "~19.2.2",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
diff --git a/apps/desktop/plans/20260405-quit-tray-lifecycle.md b/apps/desktop/plans/20260405-quit-tray-lifecycle.md
index ec31c8ba75d..6b527810611 100644
--- a/apps/desktop/plans/20260405-quit-tray-lifecycle.md
+++ b/apps/desktop/plans/20260405-quit-tray-lifecycle.md
@@ -1,6 +1,6 @@
# macOS Quit & Tray Lifecycle
-## Decision (2026-04-05)
+## Decision (2025-04-05)
All quit paths fully exit the app. No background-to-tray behavior for now.
@@ -8,9 +8,11 @@ The tray exists while the app is running and provides host-service management an
### What shipped
-- **Removed macOS background-to-tray block** from `before-quit` (#3205). The old block prevented quit and kept tray alive when `hasActiveInstances()` was true, but left the dock icon visible (confusing UX).
-- **Updater fix**: `installUpdate()` calls `quitAndInstall()` then `exitImmediately()`, bypassing the quit protocol entirely. The old `prepareQuit("release")` approach coupled the updater to the quit lifecycle unnecessarily.
-- **Hardened `before-quit` cleanup**: host-service cleanup wrapped in try/catch so `app.exit(0)` always runs. Without this, an exception in cleanup would skip `app.exit(0)`, and the macOS window close handler (`event.preventDefault()` + `hide()`, added in #3157) would block the quit.
+- **Lifecycle intents** (`exit_release`, `exit_stop`, `restart`) replace the overloaded `QuitMode` (`"release" | "stop"`). Explicit intents skip the confirm-on-quit dialog and route directly to the exit path.
+- **Updater fix**: `installUpdate()` uses `prepareIntent("exit_release")` so `before-quit` skips the confirm dialog and exits cleanly. The old `prepareQuit("release")` was intercepted by the macOS background-to-tray block when services were active, preventing updates from installing.
+- **Tray menu rename**: "Quit (Keep Services Running)" is now "Quit Superset" for clarity.
+- **Restart consolidation**: `restartApp` tRPC endpoint uses `requestExit("restart")` instead of manual `app.relaunch()` + `app.exit(0)`.
+- **Removed macOS background-to-tray block** from `before-quit`. The old block prevented quit and kept tray alive when `hasActiveInstances()` was true, but left the dock icon visible (confusing UX).
### What was deferred
@@ -29,19 +31,21 @@ Background-to-tray on macOS (Cmd+Q destroys windows but keeps tray alive) is the
| Dock right-click Quit | Same |
| App menu Quit | Same |
| Window close (red-X / Cmd+W) | macOS: hide window (standard behavior). Non-macOS: close window, then app quits. |
-| Tray "Quit (Keep Services Running)" | `requestQuit("release")` — release services, full exit |
-| Tray "Quit & Stop Services" | `requestQuit("stop")` — stop services, full exit |
+| Tray "Quit Superset" | `requestExit("exit_release")` — release services, full exit |
+| Tray "Quit & Stop Services" | `requestExit("exit_stop")` — stop services, full exit |
| Tray host-service "Stop" | Stops individual service, app stays running |
-| Update install | `quitAndInstall()` + `exitImmediately()` — bypasses quit protocol |
+| Settings "Restart App" | `requestExit("restart")` — release services, relaunch, exit |
+| Update install | `prepareIntent("exit_release")` + `quitAndInstall()` — full exit, updater handles install |
### Host-service lifecycle on quit
-- **Release** (default): services keep running as detached processes. On next app launch, they are re-adopted via manifest files.
-- **Stop** (`requestQuit("stop")`): services are terminated via `SIGTERM`.
+- **Release** (`exit_release`, implicit quit): services keep running as detached processes. On next app launch, they are re-adopted via manifest files.
+- **Stop** (`exit_stop`): services are terminated via `SIGTERM`.
### Key files
-- `src/main/index.ts` — `before-quit` handler, `requestQuit`, `exitImmediately`
+- `src/main/lib/lifecycle.ts` — lifecycle intent model
+- `src/main/index.ts` — `before-quit` handler
- `src/main/windows/main.ts` — window close behavior
- `src/main/lib/tray/index.ts` — tray menu and actions
- `src/main/lib/auto-updater.ts` — update install flow
diff --git a/apps/desktop/plans/20260416-todo-schedule.md b/apps/desktop/plans/20260416-todo-schedule.md
new file mode 100644
index 00000000000..8fbf7afc181
--- /dev/null
+++ b/apps/desktop/plans/20260416-todo-schedule.md
@@ -0,0 +1,186 @@
+# TODO Agent スケジュール実行 実装計画
+
+既存の TODO 自律エージェントに **cron ライクな定期実行** 機能を追加する。
+ユーザーはスケジュールを登録しておくと、指定時刻にそのプロンプトで TODO セッションが自動作成・キュー投入される。
+
+## 目的
+
+- 「毎日 9:00 にデプロイ」「1時間ごとに lint 走らせる」のような
+ 定型的な AI タスクを手動トリガーなしで実行できる。
+- 既存の TODO 作成フロー・実行エンジン (supervisor) をそのまま再利用し、
+ スケジュール層は薄く、単純にトリガー役に徹する。
+- フォーク限定機能。`apps/desktop` 内に閉じる。
+
+## 前提 (ユーザー決定事項)
+
+1. 発火通知は **トースト**
+2. cron 式の直接入力ではなく **UX 重視のビルダー UI** (プリセット + カスタム)
+3. 前回実行中の発火時の挙動 (skip / queue) は **スケジュール毎にユーザーが選択**
+4. UI は TodoManager **内に統合** (独立モーダルにはしない)
+
+## 非目的 (v1)
+
+- missed firing の補完 (閉じてた間の発火を後で実行): 初回は **skip + 通知のみ**
+- タイムゾーン切替: ローカル TZ 固定
+- スケジュール間の依存関係 / 順序制御
+- スケジュール共有 (エクスポート/インポート)
+
+## アーキテクチャ
+
+```
+Renderer Main process
+──────── ────────────
+TodoManager TodoScheduler (singleton)
+ └─ SchedulesSection ├─ tick (setInterval every 30s)
+ ├─ ScheduleList ├─ compute nextRunAt for each schedule
+ └─ ScheduleEditor │ and compare to now
+ └─ ScheduleFrequencyPicker ├─ on fire:
+ │ ├─ check overlap mode
+ │ ├─ call TodoSupervisor.createFromSchedule()
+ │ └─ emit `schedule.fired` event
+ └─ scheduleStore (SQLite)
+
+trpc todoAgent.schedule.* ─► scheduleStore CRUD
+trpc todoAgent.schedule.onFire ─► observable
+ (for toast in renderer)
+```
+
+## DB schema (`packages/local-db/src/schema/todo-schedules.ts`)
+
+```ts
+todo_schedules {
+ id: text pk
+ workspaceId: text (FK workspaces, cascade)
+ projectId: text (FK projects, set null)
+ name: text not null -- 表示名
+ enabled: int bool not null dflt 1
+
+ -- スケジュール定義 (UI ビルダー経由で設定)
+ frequency: text enum("hourly","daily","weekly","monthly","custom") not null
+ minute: int -- 0-59 (hourly+)
+ hour: int -- 0-23 (daily+)
+ weekday: int -- 0-6, 0=Sun (weekly)
+ monthday: int -- 1-31 (monthly)
+ cronExpr: text -- frequency=custom のときのみ
+
+ -- 発火時に作成する TODO の雛形
+ title: text not null
+ description: text not null
+ goal: text
+ verifyCommand: text
+ maxIterations: int not null dflt 10
+ maxWallClockSec: int not null dflt 1800
+ customSystemPrompt:text
+
+ overlapMode: text enum("skip","queue") not null dflt "skip"
+
+ lastRunAt: int
+ lastRunSessionId: text
+ nextRunAt: int -- 予測値。tick で使う
+ createdAt: int
+ updatedAt: int
+}
+
+index (workspaceId), (enabled, nextRunAt)
+```
+
+マイグレーション生成:
+```sh
+cd packages/local-db
+bun run generate --name=add_todo_schedules
+```
+
+## スケジューラ (`apps/desktop/src/main/todo-agent/scheduler.ts`)
+
+- `setInterval(tick, 30_000)` でポーリング
+- tick: 有効なスケジュールを DB から取得、`nextRunAt <= now` なものを発火
+- 発火:
+ 1. overlap チェック (skip なら、同 scheduleId の未完了セッションがあればスキップ)
+ 2. `TodoSupervisor.createFromSchedule(schedule)` で TODO セッションを作成
+ 3. `session-store` に挿入 → 既存のキュー機構に乗る
+ 4. `lastRunAt = now`, `lastRunSessionId = ...`, `nextRunAt = computeNext(schedule, now)` を保存
+ 5. `schedule.fired` イベントを emit → UI 側のトースト購読に届く
+- `nextRunAt` 計算は frequency enum に応じた専用ヘルパ (custom のみ cron パース)
+- cron パースは `cron-parser` (小さい・7日以上前のリリース確認必須)
+
+## UI (統合: TodoManager 内 Schedules セクション)
+
+配置: TodoManager の左サイドバーにタブ「Tasks / Schedules」を追加。
+
+### ScheduleList
+- 行: enable トグル / 名前 / 次回実行時刻 / 最終実行結果 / ... メニュー (edit / delete)
+- 空状態: "+ New Schedule" ボタン
+
+### ScheduleEditor (ダイアログ)
+
+ビルダー UI:
+1. **名前**: テキスト
+2. **ワークスペース**: select (existing workspaces)
+3. **プロンプト**: 既存の TodoComposer と同じ UI (description / goal / verify / preset / attachments)
+4. **頻度ビルダー**:
+ - Hourly: `毎時 :MM 分`
+ - Daily: `毎日 HH:MM`
+ - Weekly: `毎週[曜日] HH:MM` (曜日チップ複数選択)
+ - Monthly: `毎月 DD 日 HH:MM`
+ - Custom: raw cron 式入力 + `cronstrue` でヒューマン表示
+5. **重複時の挙動**: radio `前回が走っていたらスキップ` / `キューに追加`
+6. **有効/無効**: トグル
+
+次回実行予定をプレビュー表示 (`cronstrue` の locale=ja-JP).
+
+## トースト
+
+`electronTrpc.todoAgent.schedule.onFire.useSubscription` を TodoManager or
+グローバルプロバイダで購読し、以下を表示:
+
+- 成功: `📅 {name} を実行しました` (→ セッション詳細への遷移リンク)
+- skip: `⏭️ {name} の実行をスキップしました (前回が実行中)`
+
+## 実装ファイル一覧 (新規のみ)
+
+### Backend
+- `packages/local-db/src/schema/todo-schedules.ts`
+- `packages/local-db/drizzle/00XX_add_todo_schedules.sql` (自動生成)
+- `packages/local-db/src/schema/index.ts` (追記)
+- `apps/desktop/src/main/todo-agent/scheduler.ts`
+- `apps/desktop/src/main/todo-agent/schedule-store.ts`
+- `apps/desktop/src/main/todo-agent/trpc-router.ts` (nested `schedule` router 追記)
+- `apps/desktop/src/main/todo-agent/supervisor.ts` (`createFromSchedule` 追加)
+
+### Frontend
+- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/SchedulesSection.tsx`
+- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleList/ScheduleList.tsx`
+- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditor/ScheduleEditor.tsx`
+- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/FrequencyBuilder/FrequencyBuilder.tsx`
+- `apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/hooks/useScheduleFireToast/useScheduleFireToast.ts`
+- `apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx` (タブ追加・1箇所変更)
+
+### 依存パッケージ追加
+- `cron-parser` (main side; for custom cron parsing + next-fire computation)
+- `cronstrue` (renderer; human-readable cron)
+両方とも 7日以上前のリリースが存在する安定 lib。
+
+## テスト計画
+
+- `scheduler.test.ts`: frequency → nextRunAt 計算, overlap 判定
+- `schedule-store.test.ts`: CRUD / inserted の shape
+- `FrequencyBuilder` の簡易描画テスト (optional)
+
+## ロールアウト
+
+1. DB schema + migration
+2. scheduler + store + tRPC
+3. TodoManager UI 統合
+4. トースト
+5. 型チェック + lint + 既存 todo セッションテストに干渉しないことを確認
+6. PR → セルフレビュー → マージ
+
+## リスクと対策
+
+| リスク | 対策 |
+|------|------|
+| アプリ閉じてる間の発火が消える | v1 は諦める。UI に「アプリ起動中のみ」明記 |
+| 破壊的コマンドの暴走 | `verifyCommand` は既存通り任意。ユーザー責任。初期はドキュメントで警告 |
+| スケジュールの重複暴発 | overlapMode=skip デフォルト + DB index で pending 検出 |
+| Claude API 料金の想定外消費 | maxIterations / maxWallClockSec は既存制約をそのまま使う |
+| タイムゾーンずれ | ローカル TZ 固定。将来 tz 列追加で拡張可能 |
diff --git a/apps/desktop/plans/todo-agent-plan.md b/apps/desktop/plans/todo-agent-plan.md
new file mode 100644
index 00000000000..213e7d6de0a
--- /dev/null
+++ b/apps/desktop/plans/todo-agent-plan.md
@@ -0,0 +1,285 @@
+# TODO 自律エージェント 実装計画
+
+フォーク内限定の機能。ワークスペースの `Run` ボタンの左側にボタンを追加し、
+ユーザーが定義した目標が検証可能な形で達成されるまで、無人で実行を続ける
+自律的な Claude Code ループを起動できるようにする。実行中のワーカー端末は
+常にライブで可視化され、ユーザーは必要に応じて介入できる。
+
+## 目的
+
+- ユーザーは (1) 何をしてほしいか と (2) 明確なゴール
+ (受け入れ判定コマンド)を入力するだけでよく、その後は追加の指示なしで
+ システムが Claude Code を完了まで動かす。
+- ライブ可視性: 実行中ワーカーは実際の PTY であり、既存の
+ `TerminalPane` コンポーネントで描画されるため、誰でも監視したり
+ 直接入力したりできる。
+- 信頼性: 完了判定は決定的な verify コマンドの終了コードで行い、
+ LLM の自己申告には依存しない。
+- 逐次実行: 同時にアクティブなのは 1 タスクのみとし、それ以外はキューに入れる。
+- upstream とのマージ容易性: 新規コードはすべて新しいファイル / ディレクトリに
+ 置き、既存ファイルへの変更は追記のみ、かつ 1 行変更を 3 箇所に限定する。
+
+## 非目的(v1)
+
+- タスクの並列実行。
+- Cloud / Modal 上のサンドボックス実行
+ (ローカル worktree のみを対象とする)。
+- セッションをまたいだ LLM 判定。最終判定はシェルの verify コマンドとする。
+- PR の自動作成。(v2 で対応予定)
+
+## アーキテクチャ
+
+```
+Renderer Main process
+──────── ────────────
+TodoButton (PresetsBar) TodoSupervisor (singleton)
+ └─ TodoModal ──► trpc todo.create ──────► createSession()
+ ├─ writes .superset/todo//goal.md
+ ├─ inserts DB row (queued)
+ └─ returns sessionId
+TodoPanel enqueue / runQueue loop
+ ├─ trpc todo.subscribeState ◄─────────── state observable (per session)
+ ├─ embeds ◄──────── (paneId assigned by renderer)
+ ├─ Abort / Pause buttons ├─ spawnWorker(paneId) via
+ └─ Intervene input ──► trpc todo.sendKey ─┘ existing terminal.write
+ ├─ subscribe data:${paneId}
+ │ (idle timer + log capture)
+ ├─ runVerify() (child_process)
+ └─ update state / next iteration
+```
+
+Supervisor は **メインプロセス上で動く純粋な TypeScript** であり、
+2 つ目の Claude Code インスタンスではない。これが最も重要な単純化ポイントで、
+LLM 間通信は存在せず、「管理」役は決定論的な TS コードで担い、
+創造的な処理はすべてワーカー側に集約する。
+
+## 実行ループ
+
+各セッションは状態遷移ごとに DB へ永続化する:
+
+```
+queued → preparing → running → verifying → done
+ │ │
+ │ └──► running (fail, under budget)
+ │ │
+ │ └──► escalated (futility)
+ └──► aborted
+```
+
+各イテレーションの流れ:
+
+1. Supervisor はワーカー用 PTY ペインの存在を確認する
+ (初回は renderer が `tabs.addTerminalPane` で作成し、
+ `todo.attachPane` で `paneId` を登録する)。
+2. `goal.md`、現在の `state.json`、およびリトライ時は verify 失敗ログの末尾を
+ もとにプロンプトを組み立てる。
+3. Supervisor はそのプロンプトを `terminal.write` 経由で PTY に書き込む。
+ ワーカー側では、対話モードの `claude` が既にペイン内で待機している。
+4. Supervisor は node-pty emitter の `data:${paneId}` イベントを購読する
+ (メインプロセスから
+ `getWorkspaceRuntimeRegistry().getDefault().terminal` で直接参照可能)。
+ チャンクを受け取るたびに 5 秒のアイドルタイマーをリセットする。
+5. ストリームがしきい値時間だけアイドル状態になり、かつ
+ ターン完了ヒューリスティックを満たしたら、Supervisor は worktree 上で
+ `verifyCommand` を独立した child process として実行し、
+ 終了コードとログ末尾を取得する。
+6. `exit 0` の場合は状態を `done` にし、判定結果を記録して通知を送る。
+7. 非 0 の場合は futile 判定
+ (同じ failing test が N 回連続、または同じ diff が 2 回連続)を行い、
+ 次イテレーションへ進むか、`escalated` にするかを決める。
+8. 状態が変わるたびに Supervisor は `sessionId` をキーにした
+ `EventEmitter` へ通知し、それを trpc subscription 側が購読する。
+
+### Stop hook ではなく idle 検知を使う理由
+
+Stop hook の方がきれいだが、ワーカー起動コマンドへ
+`--settings ` を差し込む必要があり、これはインストール済みの
+Claude Code バイナリがそのフラグをサポートしているかに依存する。v1 では、
+Claude Code CLI の内部仕様と結合しないように idle 検知を使う。
+Stop hook 連携は v2 の拡張項目として、後述の `Unresolved` に記載する。
+
+### 予算と futile ガード
+
+- `maxIterations`(デフォルト 10)
+- `maxWallClockSec`(デフォルト 1800)
+- `maxTurnsPerIteration` は強制しない
+ (対話モードのため)。wall-clock と iteration 上限を優先する。
+- Futility: verify が同じテスト名で 3 イテレーション連続失敗する、
+ あるいは worktree diff が前回イテレーションと完全一致する場合。
+- 予算超過または futility 検知時は `escalated` とし、セッションは永続化しつつ、
+ ワーカーペインはそのまま残してユーザーが引き継げるようにする。
+
+## 介入 UX
+
+- PTY は通常のターミナルなので、`TerminalPane` を開いているユーザーは
+ 直接入力できる。Supervisor が入力を専有することはない。
+- `TodoPanel` でもワンクリックの `Send` 入力欄を提供し、
+ ユーザーがターミナルにフォーカスを移さなくても
+ `terminal.write({paneId, data})` を実行できるようにする。
+- `Pause` ボタンはイテレーションスケジューラを停止するだけで、
+ ワーカーの現在のターン自体は継続する。kill はしない。
+- `Abort` は PTY に `Ctrl-C`(`\x03`)を 2 回送ったうえで、
+ 状態を `aborted` にする。
+
+## UI サーフェス
+
+- **`TodoButton`**: `PresetsBar.tsx:488` の `WorkspaceRunButton` 左に置く
+ コンパクトなボタン。キュー中 + 実行中セッション数の小さなカウンターを表示する。
+ クリックで `New TODO`、`Open panel`、最近のセッションを含むドロップダウンを開く。
+- **`TodoModal`**: フォーム項目は以下。
+ - タイトル(必須)
+ - 説明(必須、複数行)
+ - ゴール / 受け入れ条件(必須、複数行)
+ - Verify コマンド(デフォルト: `bun test`)
+ - 予算: 最大イテレーション数(デフォルト 10)、
+ wall-clock 分数(デフォルト 30)
+- **`TodoPanel`**: 右側ドロワー。左にセッション一覧、右に詳細。
+ 詳細にはゴール、フェーズ、イテレーション、残り予算、最新の判定結果、
+ ワーカー用に埋め込まれた ``、および
+ Pause / Abort / Send コントロールを表示する。
+
+## フォーク衝突面
+
+### 新規ファイル(衝突リスクなし)
+
+```
+apps/desktop/plans/todo-agent-plan.md (this file)
+apps/desktop/src/main/todo-agent/
+ index.ts barrel
+ types.ts shared types + zod schemas
+ supervisor.ts singleton loop driver
+ session-store.ts in-memory session map + EventEmitter fan-out
+ worker-pty.ts thin wrapper around terminal.write / onData
+ verify-runner.ts child_process exec of verifyCommand
+ futility-detector.ts repeat-failure / diff-stall detection
+ prompt-builder.ts composes the claude prompt per iteration
+ trpc-router.ts tRPC router factory (createTodoAgentRouter)
+packages/local-db/src/schema/todo-sessions.ts (new table)
+apps/desktop/src/renderer/features/todo-agent/
+ TodoButton/TodoButton.tsx
+ TodoButton/index.ts
+ TodoModal/TodoModal.tsx
+ TodoModal/index.ts
+ TodoPanel/TodoPanel.tsx
+ TodoPanel/index.ts
+ hooks/useTodoSession.ts
+ hooks/useTodoQueue.ts
+```
+
+### 変更する既存ファイル(最小限、追記のみ)
+
+1. `packages/local-db/src/schema/index.ts` および `schema.ts`
+ 1 行追加: `export * from "./todo-sessions";`
+2. `apps/desktop/src/lib/trpc/routers/index.ts`
+ import 1 行 + router object に 1 行追加:
+ `todoAgent: createTodoAgentRouter()`.
+3. `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/PresetsBar.tsx`
+ 既存の `` 描画直前の 1 行
+ (488 行目付近)に
+ ``
+ を追加。
+
+この 3 つの変更はいずれも 1 行単位で孤立しているため、
+upstream 側で多少の変更があっても衝突しにくい。
+
+## データモデル
+
+```ts
+// packages/local-db/src/schema/todo-sessions.ts (SQLite)
+export const todoSessions = pgTable("todo_sessions", {
+ id: uuid().primaryKey().defaultRandom(),
+ organizationId: uuid("organization_id").notNull().references(() => organizations.id),
+ projectId: uuid("project_id").references(() => projects.id),
+ workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id),
+ createdByUserId: uuid("created_by_user_id").references(() => users.id),
+
+ title: text().notNull(),
+ description: text().notNull(),
+ goal: text().notNull(),
+ verifyCommand: text("verify_command").notNull(),
+
+ // Budget
+ maxIterations: integer("max_iterations").notNull().default(10),
+ maxWallClockSec: integer("max_wall_clock_sec").notNull().default(1800),
+
+ // State
+ status: text().notNull().default("queued"), // queued|preparing|running|verifying|done|failed|escalated|aborted
+ phase: text(),
+ iteration: integer().notNull().default(0),
+ attachedPaneId: text("attached_pane_id"),
+
+ // Verdict
+ verdictPassed: boolean("verdict_passed"),
+ verdictReason: text("verdict_reason"),
+ verdictFailingTest: text("verdict_failing_test"),
+
+ // Artifacts
+ artifactPath: text("artifact_path").notNull(), // .superset/todo//
+
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
+ startedAt: timestamp("started_at"),
+ completedAt: timestamp("completed_at"),
+}, (table) => [
+ index("todo_sessions_workspace_idx").on(table.workspaceId),
+ index("todo_sessions_status_idx").on(table.status),
+]);
+
+export type InsertTodoSession = typeof todoSessions.$inferInsert;
+export type SelectTodoSession = typeof todoSessions.$inferSelect;
+```
+
+ユーザー側で `bunx drizzle-kit generate --name="add_todo_sessions"` を実行する。
+リポジトリポリシーに従い、こちらでは実行しない。
+
+## tRPC サーフェス
+
+```
+todoAgent.create(input) → { sessionId }
+todoAgent.list(workspaceId) → SelectTodoSession[]
+todoAgent.get(sessionId) → SelectTodoSession
+todoAgent.attachPane(sessionId, paneId) → void
+todoAgent.pause(sessionId) → void
+todoAgent.resume(sessionId) → void
+todoAgent.abort(sessionId) → void
+todoAgent.sendInput(sessionId, data) → void (passthrough to terminal.write)
+todoAgent.subscribeState(sessionId) → observable
+```
+
+すべての subscription は `observable` ヘルパーを使い、
+`apps/desktop/AGENTS.md` に記載された trpc-electron の制約を満たす。
+
+## 段階的な提供
+
+**Phase 1(このブランチ)**
+- DB テーブル + migration
+- 単一タスク対応・キューなし・idle 検知ループ・child_process による verify を備えた
+ Supervisor の骨組み
+- ライブペイン埋め込み付きの `TodoButton` + `TodoModal` + `TodoPanel`
+- Pause / Abort / Send Input
+
+**Phase 2**
+- キュー
+ (複数セッションの逐次実行)
+- Futility 検知の強化
+- `--settings` を使った Stop hook 連携の任意対応
+- Issue URL の自動取り込み
+ (`gh issue view` → ゴールの事前入力)
+
+**Phase 3**
+- `done` 時の PR draft 自動作成
+- 通知
+- 追加 worktree による並列実行
+
+## 未解決事項
+
+- インストール済みの Claude Code バイナリが、セッション単位の hook 注入用に
+ `--settings ` フラグをサポートしているかどうか。
+ Phase 2 の確認項目とする。
+- `verifyCommand` をワーカー PTY 内で実行するべきか、
+ 別 child process で実行するべきか。現行案では、
+ verify 出力でユーザーに見えるターミナルを汚さないため、
+ 別 child process を使う。verify 出力をインラインで見たい要望が強ければ再検討する。
+- クラウドワークスペース実行時に、artifact
+ (`.superset/todo//`)をどこへ永続化するか。
+ v1 ではローカル限定のため対象外。
diff --git a/apps/desktop/runtime-dependencies.ts b/apps/desktop/runtime-dependencies.ts
index f039d9f6fe7..591c8f14d6a 100644
--- a/apps/desktop/runtime-dependencies.ts
+++ b/apps/desktop/runtime-dependencies.ts
@@ -49,6 +49,12 @@ const externalizedRuntimeModules: ExternalizedRuntimeModule[] = [
packagedCopies: [copyWholeModule("@superset/macos-process-metrics")],
asarUnpackGlobs: ["**/node_modules/@superset/macos-process-metrics/**/*"],
},
+ {
+ specifier: "@superset/macos-window-blur",
+ materialize: ["@superset/macos-window-blur"],
+ packagedCopies: [copyWholeModule("@superset/macos-window-blur")],
+ asarUnpackGlobs: ["**/node_modules/@superset/macos-window-blur/**/*"],
+ },
{
specifier: "@ast-grep/napi",
materialize: ["@ast-grep/napi"],
@@ -83,6 +89,17 @@ const packagedSupportModules = [
copyWholeModule("is-extglob"),
copyWholeModule("picomatch"),
copyWholeModule("node-addon-api"),
+ copyWholeModule("typescript"),
+ copyWholeModule("yaml-language-server"),
+ copyWholeModule("dockerfile-language-server-nodejs"),
+ copyWholeModule("graphql-language-service-cli"),
+ copyWholeModule("graphql"),
+ copyWholeModule("pyright"),
+ copyWholeModule("vscode-css-languageservice"),
+ copyWholeModule("vscode-html-languageservice"),
+ copyWholeModule("vscode-json-languageservice"),
+ copyWholeModule("vscode-languageserver-textdocument"),
+ copyWholeModule("vscode-langservers-extracted"),
];
export const mainExternalizedDependencies = [
@@ -110,4 +127,15 @@ export const requiredMaterializedNodeModules = [
"is-extglob",
"picomatch",
"node-addon-api",
+ "typescript",
+ "yaml-language-server",
+ "dockerfile-language-server-nodejs",
+ "graphql-language-service-cli",
+ "graphql",
+ "pyright",
+ "vscode-css-languageservice",
+ "vscode-html-languageservice",
+ "vscode-json-languageservice",
+ "vscode-languageserver-textdocument",
+ "vscode-langservers-extracted",
];
diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts
index 3b34f3ecc75..c730c9de9ac 100644
--- a/apps/desktop/scripts/copy-native-modules.ts
+++ b/apps/desktop/scripts/copy-native-modules.ts
@@ -24,8 +24,8 @@ import {
realpathSync,
rmSync,
} from "node:fs";
-import { dirname, join } from "node:path";
-import { satisfies } from "semver";
+import { dirname, join, relative } from "node:path";
+import { maxSatisfying, satisfies } from "semver";
import { requiredMaterializedNodeModules } from "../runtime-dependencies";
// Target architecture for cross-compilation. When set, platform-specific
@@ -53,15 +53,40 @@ function getBunStoreDir(nodeModulesDir: string): string {
function findBunStoreFolderName(
bunStoreDir: string,
moduleName: string,
- version: string,
+ versionRange: string,
): string | null {
if (!existsSync(bunStoreDir)) return null;
const entries = readdirSync(bunStoreDir);
const modulePrefix = `${moduleName.replace("/", "+")}@`;
- const exactPrefix = `${modulePrefix}${version}`;
- const exactMatch = entries.find((entry) => entry.startsWith(exactPrefix));
- if (exactMatch) return exactMatch;
- return entries.find((entry) => entry.startsWith(modulePrefix)) ?? null;
+ const matchingEntries = entries.filter((entry) =>
+ entry.startsWith(modulePrefix),
+ );
+
+ const extractVersion = (entry: string): string | null => {
+ const remainder = entry.slice(modulePrefix.length);
+ const candidate = remainder.split("_")[0];
+ return candidate.length > 0 ? candidate : null;
+ };
+
+ const versions = matchingEntries
+ .map((entry) => ({ entry, version: extractVersion(entry) }))
+ .filter(
+ (item): item is { entry: string; version: string } =>
+ item.version !== null,
+ );
+
+ const exactMatch = versions.find((item) => item.version === versionRange);
+ if (exactMatch) return exactMatch.entry;
+
+ const bestMatch = maxSatisfying(
+ versions.map((item) => item.version),
+ versionRange,
+ );
+ if (!bestMatch) {
+ return null;
+ }
+
+ return versions.find((item) => item.version === bestMatch)?.entry ?? null;
}
function copyModuleIfSymlink(
@@ -142,6 +167,7 @@ function copyExactModuleVersion(
moduleName,
);
if (existsSync(sourcePath)) {
+ rmSync(destPath, { recursive: true, force: true });
mkdirSync(dirname(destPath), { recursive: true });
cpSync(sourcePath, destPath, { recursive: true });
console.log(` Copied ${moduleName}@${version} to: ${destPath}`);
@@ -163,43 +189,179 @@ function copyExactModuleVersion(
return false;
}
+function resolveDependencySource(
+ moduleName: string,
+ versionRange: string,
+): {
+ sourceModuleName: string;
+ sourceVersionRange: string;
+} {
+ if (!versionRange.startsWith("npm:")) {
+ return {
+ sourceModuleName: moduleName,
+ sourceVersionRange: versionRange,
+ };
+ }
+
+ const aliasSpec = versionRange.slice(4);
+ const match = aliasSpec.match(/^((?:@[^/]+\/)?[^@]+)@(.+)$/);
+ if (!match) {
+ return {
+ sourceModuleName: moduleName,
+ sourceVersionRange: versionRange,
+ };
+ }
+
+ return {
+ sourceModuleName: match[1],
+ sourceVersionRange: match[2],
+ };
+}
+
function copyDependencyForPackage(
nodeModulesDir: string,
parentModuleName: string,
dependencyName: string,
dependencyRange: string,
required: boolean,
-): void {
+ options?: {
+ preferNested?: boolean;
+ },
+): string | null {
+ const resolvedDependency = resolveDependencySource(
+ dependencyName,
+ dependencyRange,
+ );
const topLevelDependencyPath = join(nodeModulesDir, dependencyName);
const topLevelVersion = readInstalledModuleVersion(topLevelDependencyPath);
+ const sourceTopLevelDependencyPath = join(
+ nodeModulesDir,
+ resolvedDependency.sourceModuleName,
+ );
+ const sourceTopLevelVersion = readInstalledModuleVersion(
+ sourceTopLevelDependencyPath,
+ );
+ const nestedDependencyPath = join(
+ nodeModulesDir,
+ parentModuleName,
+ "node_modules",
+ dependencyName,
+ );
+ const preferNested = options?.preferNested ?? false;
+
+ const materializeNestedFromSource = (sourcePath: string): string => {
+ rmSync(nestedDependencyPath, { recursive: true, force: true });
+ mkdirSync(dirname(nestedDependencyPath), { recursive: true });
+ cpSync(sourcePath, nestedDependencyPath, {
+ recursive: true,
+ });
+ return nestedDependencyPath;
+ };
+ const materializeTopLevelFromSource = (sourcePath: string): string => {
+ rmSync(topLevelDependencyPath, { recursive: true, force: true });
+ mkdirSync(dirname(topLevelDependencyPath), { recursive: true });
+ cpSync(sourcePath, topLevelDependencyPath, {
+ recursive: true,
+ });
+ return topLevelDependencyPath;
+ };
+
+ if (preferNested) {
+ const nestedVersion = readInstalledModuleVersion(nestedDependencyPath);
+ if (
+ nestedVersion &&
+ satisfies(nestedVersion, resolvedDependency.sourceVersionRange)
+ ) {
+ const nestedStats = lstatSync(nestedDependencyPath);
+ if (nestedStats.isSymbolicLink()) {
+ const realPath = realpathSync(nestedDependencyPath);
+ rmSync(nestedDependencyPath);
+ cpSync(realPath, nestedDependencyPath, {
+ recursive: true,
+ });
+ }
+ return nestedDependencyPath;
+ }
- if (topLevelVersion && satisfies(topLevelVersion, dependencyRange)) {
+ if (
+ topLevelVersion &&
+ satisfies(topLevelVersion, resolvedDependency.sourceVersionRange)
+ ) {
+ // Do NOT materialize the top-level symlink; electron-builder would then
+ // traverse it and find its deps missing (they are placed nested here).
+ // Instead, dereference the symlink and copy directly to the nested path.
+ const realSource = lstatSync(topLevelDependencyPath).isSymbolicLink()
+ ? realpathSync(topLevelDependencyPath)
+ : topLevelDependencyPath;
+ return materializeNestedFromSource(realSource);
+ }
+
+ if (
+ resolvedDependency.sourceModuleName !== dependencyName &&
+ sourceTopLevelVersion &&
+ satisfies(sourceTopLevelVersion, resolvedDependency.sourceVersionRange)
+ ) {
+ const realSource = lstatSync(
+ sourceTopLevelDependencyPath,
+ ).isSymbolicLink()
+ ? realpathSync(sourceTopLevelDependencyPath)
+ : sourceTopLevelDependencyPath;
+ return materializeNestedFromSource(realSource);
+ }
+
+ console.log(
+ ` ${dependencyName}: materializing nested copy for ${parentModuleName} (${topLevelVersion ?? sourceTopLevelVersion ?? "missing"} does not satisfy ${resolvedDependency.sourceVersionRange})`,
+ );
+ copyExactModuleVersion(
+ nodeModulesDir,
+ resolvedDependency.sourceModuleName,
+ resolvedDependency.sourceVersionRange,
+ nestedDependencyPath,
+ required,
+ );
+ return nestedDependencyPath;
+ }
+
+ if (
+ topLevelVersion &&
+ satisfies(topLevelVersion, resolvedDependency.sourceVersionRange)
+ ) {
copyModuleIfSymlink(nodeModulesDir, dependencyName, required);
- return;
+ return topLevelDependencyPath;
+ }
+
+ if (
+ resolvedDependency.sourceModuleName !== dependencyName &&
+ sourceTopLevelVersion &&
+ satisfies(sourceTopLevelVersion, resolvedDependency.sourceVersionRange)
+ ) {
+ copyModuleIfSymlink(
+ nodeModulesDir,
+ resolvedDependency.sourceModuleName,
+ required,
+ );
+ return materializeTopLevelFromSource(sourceTopLevelDependencyPath);
}
if (!topLevelVersion) {
console.log(
- ` ${dependencyName}: top-level version missing; materializing ${dependencyRange} at the workspace root`,
+ ` ${dependencyName}: top-level version missing; materializing ${resolvedDependency.sourceVersionRange} at the workspace root`,
);
copyExactModuleVersion(
nodeModulesDir,
- dependencyName,
- dependencyRange,
+ resolvedDependency.sourceModuleName,
+ resolvedDependency.sourceVersionRange,
topLevelDependencyPath,
required,
);
- return;
+ return topLevelDependencyPath;
}
- const nestedDependencyPath = join(
- nodeModulesDir,
- parentModuleName,
- "node_modules",
- dependencyName,
- );
const nestedVersion = readInstalledModuleVersion(nestedDependencyPath);
- if (nestedVersion && satisfies(nestedVersion, dependencyRange)) {
+ if (
+ nestedVersion &&
+ satisfies(nestedVersion, resolvedDependency.sourceVersionRange)
+ ) {
const nestedStats = lstatSync(nestedDependencyPath);
if (nestedStats.isSymbolicLink()) {
const realPath = realpathSync(nestedDependencyPath);
@@ -208,20 +370,80 @@ function copyDependencyForPackage(
recursive: true,
});
}
- return;
+ return nestedDependencyPath;
}
console.log(
- ` ${dependencyName}: top-level version ${topLevelVersion ?? "missing"} does not satisfy ${dependencyRange}; materializing nested copy for ${parentModuleName}`,
+ ` ${dependencyName}: top-level version ${topLevelVersion ?? sourceTopLevelVersion ?? "missing"} does not satisfy ${resolvedDependency.sourceVersionRange}; materializing nested copy for ${parentModuleName}`,
);
copyExactModuleVersion(
nodeModulesDir,
- dependencyName,
- dependencyRange,
+ resolvedDependency.sourceModuleName,
+ resolvedDependency.sourceVersionRange,
nestedDependencyPath,
required,
);
+
+ return nestedDependencyPath;
+}
+
+function materializeProductionDependencyTree(
+ nodeModulesDir: string,
+ packageRelativePath: string,
+ seen: Set,
+): void {
+ const packagePath = join(nodeModulesDir, packageRelativePath);
+ const packageJsonPath = join(packagePath, "package.json");
+
+ if (!existsSync(packageJsonPath)) {
+ return;
+ }
+
+ type PackageJson = {
+ name?: string;
+ version?: string;
+ dependencies?: Record;
+ };
+
+ const packageJson = JSON.parse(
+ readFileSync(packageJsonPath, "utf8"),
+ ) as PackageJson;
+ const packageKey = packageJson.name
+ ? `${packageJson.name}@${packageJson.version ?? "0.0.0"}`
+ : realpathSync(packagePath);
+
+ if (seen.has(packageKey)) {
+ return;
+ }
+ seen.add(packageKey);
+
+ try {
+ for (const [dependencyName, dependencyRange] of Object.entries(
+ packageJson.dependencies ?? {},
+ )) {
+ const dependencyPath = copyDependencyForPackage(
+ nodeModulesDir,
+ packageRelativePath,
+ dependencyName,
+ dependencyRange,
+ true,
+ { preferNested: true },
+ );
+
+ if (!dependencyPath) {
+ continue;
+ }
+
+ materializeProductionDependencyTree(
+ nodeModulesDir,
+ relative(nodeModulesDir, dependencyPath),
+ seen,
+ );
+ }
+ } finally {
+ seen.delete(packageKey);
+ }
}
/**
@@ -484,6 +706,33 @@ function prepareNativeModules() {
copyModuleIfSymlink(nodeModulesDir, moduleName, true);
}
+ console.log("\nMaterializing runtime dependency trees...");
+ const runtimeDependencyRoots = [
+ "yaml-language-server",
+ "dockerfile-language-server-nodejs",
+ "dockerfile-language-service",
+ "dockerfile-ast",
+ "dockerfile-utils",
+ "graphql-language-service-cli",
+ "pyright",
+ "vscode-css-languageservice",
+ "vscode-html-languageservice",
+ "vscode-json-languageservice",
+ "vscode-languageserver-textdocument",
+ "vscode-languageserver-types",
+ "vscode-langservers-extracted",
+ "strip-ansi",
+ ];
+ const seenPackages = new Set();
+ for (const moduleName of runtimeDependencyRoots) {
+ copyModuleIfSymlink(nodeModulesDir, moduleName, true);
+ materializeProductionDependencyTree(
+ nodeModulesDir,
+ moduleName,
+ seenPackages,
+ );
+ }
+
console.log("\nPreparing ast-grep platform package...");
copyAstGrepPlatformPackages(nodeModulesDir);
copyParcelWatcherPlatformPackages(nodeModulesDir);
diff --git a/apps/desktop/scripts/validate-native-runtime.ts b/apps/desktop/scripts/validate-native-runtime.ts
index 5115b848f7f..6c6bdb2136e 100644
--- a/apps/desktop/scripts/validate-native-runtime.ts
+++ b/apps/desktop/scripts/validate-native-runtime.ts
@@ -19,6 +19,7 @@ import {
const projectRoot = join(import.meta.dirname, "..");
const allowedBareRequirePackages = new Set([
"electron",
+ "source-map-support",
...mainExternalizedDependencies,
]);
const builtinModuleSpecifiers = new Set([
diff --git a/apps/desktop/src/lib/electron/request-media-access.ts b/apps/desktop/src/lib/electron/request-media-access.ts
new file mode 100644
index 00000000000..1e5e9fe157f
--- /dev/null
+++ b/apps/desktop/src/lib/electron/request-media-access.ts
@@ -0,0 +1,50 @@
+import type { SitePermissionKind } from "@superset/local-db";
+import { shell, systemPreferences } from "electron";
+
+const MEDIA_ACCESS_SETTINGS_URLS: Record = {
+ microphone:
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
+ camera:
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
+};
+
+interface RequestMediaAccessResult {
+ granted: boolean;
+ openedSystemSettings: boolean;
+}
+
+export async function requestMediaAccess(
+ kind: SitePermissionKind,
+): Promise {
+ if (process.platform !== "darwin") {
+ return {
+ granted: true,
+ openedSystemSettings: false,
+ };
+ }
+
+ try {
+ if (systemPreferences.getMediaAccessStatus(kind) === "granted") {
+ return {
+ granted: true,
+ openedSystemSettings: false,
+ };
+ }
+
+ const granted = await systemPreferences.askForMediaAccess(kind);
+ if (granted) {
+ return {
+ granted: true,
+ openedSystemSettings: false,
+ };
+ }
+ } catch {
+ // Fall through to opening System Settings.
+ }
+
+ await shell.openExternal(MEDIA_ACCESS_SETTINGS_URLS[kind]);
+ return {
+ granted: false,
+ openedSystemSettings: true,
+ };
+}
diff --git a/apps/desktop/src/lib/trpc/routers/browser/browser.ts b/apps/desktop/src/lib/trpc/routers/browser/browser.ts
index 50681573e85..47750092e04 100644
--- a/apps/desktop/src/lib/trpc/routers/browser/browser.ts
+++ b/apps/desktop/src/lib/trpc/routers/browser/browser.ts
@@ -1,6 +1,12 @@
+import {
+ SITE_PERMISSION_KINDS,
+ SITE_PERMISSION_VALUES,
+} from "@superset/local-db";
import { observable } from "@trpc/server/observable";
import { session } from "electron";
+import { requestMediaAccess } from "lib/electron/request-media-access";
import { browserManager } from "main/lib/browser/browser-manager";
+import { browserSitePermissionManager } from "main/lib/browser/browser-site-permission-manager";
import { z } from "zod";
import { publicProcedure, router } from "../..";
@@ -115,6 +121,32 @@ export const createBrowserRouter = () => {
});
}),
+ /** Global subscription for new-window events from any browser pane. */
+ onAnyNewWindow: publicProcedure.subscription(() => {
+ return observable<{ paneId: string; url: string }>((emit) => {
+ const handler = (data: { paneId: string; url: string }) => {
+ emit.next(data);
+ };
+ browserManager.on("new-window", handler);
+ return () => {
+ browserManager.off("new-window", handler);
+ };
+ });
+ }),
+
+ /** Global subscription for HTML5 fullscreen enter/leave from any browser pane. */
+ onFullscreenChange: publicProcedure.subscription(() => {
+ return observable<{ paneId: string; isFullscreen: boolean }>((emit) => {
+ const handler = (data: { paneId: string; isFullscreen: boolean }) => {
+ emit.next(data);
+ };
+ browserManager.on("fullscreen-change", handler);
+ return () => {
+ browserManager.off("fullscreen-change", handler);
+ };
+ });
+ }),
+
onContextMenuAction: publicProcedure
.input(z.object({ paneId: z.string() }))
.subscription(({ input }) => {
@@ -136,6 +168,108 @@ export const createBrowserRouter = () => {
return { success: true };
}),
+ findInPage: publicProcedure
+ .input(
+ z.object({
+ paneId: z.string(),
+ text: z.string(),
+ forward: z.boolean().optional(),
+ findNext: z.boolean().optional(),
+ matchCase: z.boolean().optional(),
+ }),
+ )
+ .mutation(({ input }) => {
+ const requestId = browserManager.findInPage(input.paneId, input.text, {
+ forward: input.forward,
+ findNext: input.findNext,
+ matchCase: input.matchCase,
+ });
+ return { requestId };
+ }),
+
+ stopFindInPage: publicProcedure
+ .input(
+ z.object({
+ paneId: z.string(),
+ action: z
+ .enum(["clearSelection", "keepSelection", "activateSelection"])
+ .optional(),
+ }),
+ )
+ .mutation(({ input }) => {
+ browserManager.stopFindInPage(
+ input.paneId,
+ input.action ?? "clearSelection",
+ );
+ return { success: true };
+ }),
+
+ onFoundInPage: publicProcedure
+ .input(z.object({ paneId: z.string() }))
+ .subscription(({ input }) => {
+ return observable<{
+ requestId: number;
+ activeMatchOrdinal: number;
+ matches: number;
+ finalUpdate: boolean;
+ }>((emit) => {
+ const handler = (data: {
+ requestId: number;
+ activeMatchOrdinal: number;
+ matches: number;
+ finalUpdate: boolean;
+ }) => {
+ emit.next(data);
+ };
+ browserManager.on(`found-in-page:${input.paneId}`, handler);
+ return () => {
+ browserManager.off(`found-in-page:${input.paneId}`, handler);
+ };
+ });
+ }),
+
+ onFindRequested: publicProcedure
+ .input(z.object({ paneId: z.string() }))
+ .subscription(({ input }) => {
+ return observable<{ type: "open" | "escape" }>((emit) => {
+ const openHandler = () => emit.next({ type: "open" });
+ const escapeHandler = () => emit.next({ type: "escape" });
+ browserManager.on(`find-requested:${input.paneId}`, openHandler);
+ browserManager.on(`find-escape:${input.paneId}`, escapeHandler);
+ return () => {
+ browserManager.off(`find-requested:${input.paneId}`, openHandler);
+ browserManager.off(`find-escape:${input.paneId}`, escapeHandler);
+ };
+ });
+ }),
+
+ setZoomLevel: publicProcedure
+ .input(z.object({ paneId: z.string(), level: z.number() }))
+ .mutation(({ input }) => {
+ const wc = browserManager.getWebContents(input.paneId);
+ if (!wc) return { success: false };
+ wc.setZoomLevel(input.level);
+ return { success: true };
+ }),
+
+ onZoomChanged: publicProcedure
+ .input(z.object({ paneId: z.string() }))
+ .subscription(({ input }) => {
+ return observable<{ zoomLevel: number }>((emit) => {
+ let lastLevel: number | null = null;
+ const interval = setInterval(() => {
+ const wc = browserManager.getWebContents(input.paneId);
+ if (!wc) return;
+ const level = wc.getZoomLevel();
+ if (level !== lastLevel) {
+ lastLevel = level;
+ emit.next({ zoomLevel: level });
+ }
+ }, 300);
+ return () => clearInterval(interval);
+ });
+ }),
+
getPageInfo: publicProcedure
.input(z.object({ paneId: z.string() }))
.query(({ input }) => {
@@ -150,6 +284,71 @@ export const createBrowserRouter = () => {
};
}),
+ getSitePermissions: publicProcedure
+ .input(z.object({ url: z.string() }))
+ .query(({ input }) => {
+ return browserSitePermissionManager.getPermissionsForUrl(input.url);
+ }),
+
+ setSitePermission: publicProcedure
+ .input(
+ z.object({
+ origin: z.string(),
+ kind: z.enum(SITE_PERMISSION_KINDS),
+ value: z.enum(SITE_PERMISSION_VALUES),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const sitePermissions = browserSitePermissionManager.setPermission(
+ input.origin,
+ input.kind,
+ input.value,
+ );
+
+ const mediaAccess =
+ input.value === "allow" ? await requestMediaAccess(input.kind) : null;
+
+ return {
+ ...sitePermissions,
+ mediaAccess,
+ };
+ }),
+
+ resetSitePermissions: publicProcedure
+ .input(z.object({ origin: z.string() }))
+ .mutation(({ input }) => {
+ browserSitePermissionManager.resetPermissions(input.origin);
+ return { success: true };
+ }),
+
+ onSitePermissionRequested: publicProcedure
+ .input(z.object({ paneId: z.string() }))
+ .subscription(({ input }) => {
+ return observable<{
+ paneId: string;
+ origin: string;
+ permissions: ("microphone" | "camera")[];
+ }>((emit) => {
+ const handler = (event: {
+ paneId: string;
+ origin: string;
+ permissions: ("microphone" | "camera")[];
+ }) => {
+ emit.next(event);
+ };
+ browserSitePermissionManager.on(
+ `permission-requested:${input.paneId}`,
+ handler,
+ );
+ return () => {
+ browserSitePermissionManager.off(
+ `permission-requested:${input.paneId}`,
+ handler,
+ );
+ };
+ });
+ }),
+
clearBrowsingData: publicProcedure
.input(
z.object({
diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts
index 8283e4b4e29..dd67361aa15 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts
@@ -1,4 +1,7 @@
+import { access } from "node:fs/promises";
+import { join, resolve } from "node:path";
import { worktrees } from "@superset/local-db";
+import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import type { SimpleGit } from "simple-git";
@@ -11,12 +14,70 @@ import {
} from "../workspaces/utils/base-branch-config";
import { getCurrentBranch } from "../workspaces/utils/git";
import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
-import { gitSwitchBranch } from "./security/git-commands";
-import {
- assertRegisteredWorktree,
- getRegisteredWorktree,
-} from "./security/path-validation";
-import { clearStatusCacheForWorktree } from "./utils/status-cache";
+import { gitCreateBranch, gitSwitchBranch } from "./security/git-commands";
+import { assertRegisteredWorktree } from "./security/path-validation";
+import { clearWorktreeStatusCaches } from "./utils/worktree-status-caches";
+
+const DEFAULT_REF_SEARCH_LIMIT = 50;
+const MAX_REF_SEARCH_LIMIT = 200;
+const GIT_PROGRESS_OPERATIONS = [
+ { kind: "merge", path: "MERGE_HEAD" },
+ { kind: "cherry-pick", path: "CHERRY_PICK_HEAD" },
+ { kind: "revert", path: "REVERT_HEAD" },
+ { kind: "bisect", path: "BISECT_LOG" },
+] as const;
+
+type BranchProgressOperation =
+ | "merge"
+ | "rebase"
+ | "cherry-pick"
+ | "revert"
+ | "bisect";
+
+type SearchableRef = {
+ name: string;
+ displayName: string;
+ ref: string;
+ kind: "branch" | "tag";
+ scope: "local" | "remote" | "tag";
+ lastCommitDate: number;
+ shortHash: string | null;
+ authorName: string | null;
+ subject: string | null;
+ checkedOutPath: string | null;
+};
+
+type ParsedRefEntry = {
+ name: string;
+ shortHash: string | null;
+ authorName: string | null;
+ subject: string | null;
+ lastCommitDate: number;
+};
+
+const REF_FIELD_SEPARATOR = "\u001f";
+const REF_RECORD_SEPARATOR = "\u001e";
+
+function normalizeBranchRef(branch: string): string {
+ if (branch.startsWith("refs/heads/")) {
+ return branch.slice("refs/heads/".length);
+ }
+ if (branch.startsWith("refs/remotes/origin/")) {
+ return branch.slice("refs/remotes/origin/".length);
+ }
+ if (branch.startsWith("remotes/origin/")) {
+ return branch.slice("remotes/origin/".length);
+ }
+ return branch;
+}
+
+async function assertWorktreePathExists(worktreePath: string): Promise {
+ if (await pathExists(worktreePath)) return;
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Worktree path does not exist: ${worktreePath}`,
+ });
+}
export const createBranchesRouter = () => {
return router({
@@ -34,6 +95,7 @@ export const createBranchesRouter = () => {
currentBranch: string | null;
}> => {
assertRegisteredWorktree(input.worktreePath);
+ await assertWorktreePathExists(input.worktreePath);
const git = await getSimpleGitWithShellPath(input.worktreePath);
@@ -92,6 +154,71 @@ export const createBranchesRouter = () => {
},
),
+ searchRefs: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string(),
+ search: z.string().default(""),
+ limit: z.number().int().min(1).max(MAX_REF_SEARCH_LIMIT).optional(),
+ includeTags: z.boolean().default(true),
+ }),
+ )
+ .query(
+ async ({
+ input,
+ }): Promise<{
+ refs: SearchableRef[];
+ defaultBranch: string;
+ currentBranch: string | null;
+ }> => {
+ assertRegisteredWorktree(input.worktreePath);
+
+ const git = await getSimpleGitWithShellPath(input.worktreePath);
+ const currentBranch = await getCurrentBranch(input.worktreePath);
+ const checkedOutBranches = await getCheckedOutBranches(
+ git,
+ input.worktreePath,
+ );
+ const refs = await getSearchableRefs(git, {
+ search: input.search,
+ includeTags: input.includeTags,
+ });
+ const remoteBranchNames = refs
+ .filter((ref) => ref.kind === "branch" && ref.scope === "remote")
+ .map((ref) => ref.name);
+ const defaultBranch = await getDefaultBranch(git, remoteBranchNames);
+
+ const sortedRefs = refs.sort((a, b) => {
+ if (a.kind !== b.kind) return a.kind === "branch" ? -1 : 1;
+ if (a.kind === "branch" && b.kind === "branch") {
+ if (a.name === currentBranch) return -1;
+ if (b.name === currentBranch) return 1;
+ if (a.name === defaultBranch) return -1;
+ if (b.name === defaultBranch) return 1;
+ if (a.scope !== b.scope) return a.scope === "local" ? -1 : 1;
+ }
+ if (a.lastCommitDate !== b.lastCommitDate) {
+ return b.lastCommitDate - a.lastCommitDate;
+ }
+ return a.displayName.localeCompare(b.displayName);
+ });
+
+ return {
+ refs: sortedRefs
+ .slice(0, input.limit ?? DEFAULT_REF_SEARCH_LIMIT)
+ .map((ref) => ({
+ ...ref,
+ checkedOutPath:
+ ref.kind === "branch"
+ ? (checkedOutBranches[ref.name] ?? null)
+ : null,
+ })),
+ defaultBranch,
+ currentBranch,
+ };
+ },
+ ),
+
switchBranch: publicProcedure
.input(
z.object({
@@ -100,27 +227,70 @@ export const createBranchesRouter = () => {
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
- const worktree = getRegisteredWorktree(input.worktreePath);
- await gitSwitchBranch(input.worktreePath, input.branch);
-
- const gitStatus = worktree.gitStatus
- ? { ...worktree.gitStatus, branch: input.branch }
- : null;
-
- localDb
- .update(worktrees)
- .set({
- branch: input.branch,
- baseBranch: null,
- gitStatus,
- })
- .where(eq(worktrees.path, input.worktreePath))
- .run();
-
- clearStatusCacheForWorktree(input.worktreePath);
+ await assertWorktreePathExists(input.worktreePath);
+ const branch = normalizeBranchRef(input.branch);
+ await gitSwitchBranch(input.worktreePath, branch);
+ const currentBranch =
+ (await getCurrentBranch(input.worktreePath)) ?? branch;
+ persistWorktreeBranch(input.worktreePath, currentBranch);
+
+ clearWorktreeStatusCaches(input.worktreePath);
return { success: true };
}),
+ getBranchGuardState: publicProcedure
+ .input(z.object({ worktreePath: z.string() }))
+ .query(
+ async ({
+ input,
+ }): Promise<{
+ operationInProgress: BranchProgressOperation | null;
+ }> => {
+ assertRegisteredWorktree(input.worktreePath);
+
+ const git = await getSimpleGitWithShellPath(input.worktreePath);
+
+ return {
+ operationInProgress: await detectGitProgressOperation(
+ git,
+ input.worktreePath,
+ ),
+ };
+ },
+ ),
+
+ createBranch: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string(),
+ branch: z.string(),
+ startPoint: z.string().nullish(),
+ }),
+ )
+ .mutation(
+ async ({ input }): Promise<{ success: boolean; branch: string }> => {
+ assertRegisteredWorktree(input.worktreePath);
+
+ const git = await getSimpleGitWithShellPath(input.worktreePath);
+ const branchSummary = await git.branchLocal();
+ if (branchSummary.all.includes(input.branch)) {
+ throw new Error(`Branch "${input.branch}" already exists.`);
+ }
+
+ await gitCreateBranch(
+ input.worktreePath,
+ input.branch,
+ input.startPoint ?? undefined,
+ );
+ const currentBranch =
+ (await getCurrentBranch(input.worktreePath)) ?? input.branch;
+ persistWorktreeBranch(input.worktreePath, currentBranch);
+
+ clearWorktreeStatusCaches(input.worktreePath);
+ return { success: true, branch: currentBranch };
+ },
+ ),
+
updateBaseBranch: publicProcedure
.input(
z.object({
@@ -150,13 +320,16 @@ export const createBranchesRouter = () => {
});
}
- localDb
- .update(worktrees)
- .set({ baseBranch: input.baseBranch })
- .where(eq(worktrees.path, input.worktreePath))
- .run();
+ const persistedWorktree = getPersistedWorktree(input.worktreePath);
+ if (persistedWorktree) {
+ localDb
+ .update(worktrees)
+ .set({ baseBranch: input.baseBranch })
+ .where(eq(worktrees.path, input.worktreePath))
+ .run();
+ }
- clearStatusCacheForWorktree(input.worktreePath);
+ clearWorktreeStatusCaches(input.worktreePath);
return { success: true };
}),
});
@@ -236,3 +409,244 @@ async function getCheckedOutBranches(
return checkedOutBranches;
}
+
+function getPersistedWorktree(worktreePath: string) {
+ return localDb
+ .select()
+ .from(worktrees)
+ .where(eq(worktrees.path, worktreePath))
+ .get();
+}
+
+async function pathExists(path: string): Promise {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function detectGitProgressOperation(
+ git: SimpleGit,
+ worktreePath: string,
+): Promise {
+ let gitDirPath: string;
+
+ try {
+ const gitDir = (await git.revparse(["--git-dir"])).trim();
+ gitDirPath = resolve(worktreePath, gitDir);
+ } catch {
+ return null;
+ }
+
+ if (
+ (await pathExists(join(gitDirPath, "rebase-merge"))) ||
+ (await pathExists(join(gitDirPath, "rebase-apply")))
+ ) {
+ return "rebase";
+ }
+
+ for (const candidate of GIT_PROGRESS_OPERATIONS) {
+ if (await pathExists(join(gitDirPath, candidate.path))) {
+ return candidate.kind;
+ }
+ }
+
+ return null;
+}
+
+function persistWorktreeBranch(worktreePath: string, branch: string): void {
+ const persistedWorktree = getPersistedWorktree(worktreePath);
+ if (!persistedWorktree) {
+ return;
+ }
+
+ const gitStatus = persistedWorktree.gitStatus
+ ? { ...persistedWorktree.gitStatus, branch }
+ : null;
+
+ localDb
+ .update(worktrees)
+ .set({
+ branch,
+ baseBranch: null,
+ gitStatus,
+ })
+ .where(eq(worktrees.path, worktreePath))
+ .run();
+}
+
+async function getSearchableRefs(
+ git: SimpleGit,
+ {
+ search,
+ includeTags,
+ }: {
+ search: string;
+ includeTags: boolean;
+ },
+): Promise {
+ const searchLower = search.trim().toLowerCase();
+ const refs: SearchableRef[] = [];
+
+ try {
+ for (const localBranch of await getRefEntries(git, {
+ refPath: "refs/heads/",
+ dateField: "committerdate",
+ authorField: "authorname",
+ })) {
+ if (!matchesSearch(localBranch, searchLower)) continue;
+
+ refs.push({
+ name: localBranch.name,
+ displayName: localBranch.name,
+ ref: localBranch.name,
+ kind: "branch",
+ scope: "local",
+ lastCommitDate: localBranch.lastCommitDate,
+ shortHash: localBranch.shortHash,
+ authorName: localBranch.authorName,
+ subject: localBranch.subject,
+ checkedOutPath: null,
+ });
+ }
+ } catch {}
+
+ try {
+ for (const remoteBranch of await getRefEntries(git, {
+ refPath: "refs/remotes/origin/",
+ dateField: "committerdate",
+ authorField: "authorname",
+ })) {
+ if (remoteBranch.name === "origin/HEAD") continue;
+ const canonicalName = remoteBranch.name.startsWith("origin/")
+ ? remoteBranch.name.replace("origin/", "")
+ : remoteBranch.name;
+ const displayName = remoteBranch.name.startsWith("origin/")
+ ? remoteBranch.name
+ : `origin/${remoteBranch.name}`;
+ if (
+ !matchesSearch(
+ { ...remoteBranch, name: canonicalName, displayName },
+ searchLower,
+ )
+ ) {
+ continue;
+ }
+
+ refs.push({
+ name: canonicalName,
+ displayName,
+ ref: displayName,
+ kind: "branch",
+ scope: "remote",
+ lastCommitDate: remoteBranch.lastCommitDate,
+ shortHash: remoteBranch.shortHash,
+ authorName: remoteBranch.authorName,
+ subject: remoteBranch.subject,
+ checkedOutPath: null,
+ });
+ }
+ } catch {}
+
+ if (includeTags) {
+ try {
+ for (const tag of await getRefEntries(git, {
+ refPath: "refs/tags/",
+ dateField: "creatordate",
+ authorField: "creatorname",
+ })) {
+ if (!matchesSearch(tag, searchLower)) continue;
+
+ refs.push({
+ name: tag.name,
+ displayName: tag.name,
+ ref: `refs/tags/${tag.name}`,
+ kind: "tag",
+ scope: "tag",
+ lastCommitDate: tag.lastCommitDate,
+ shortHash: tag.shortHash,
+ authorName: tag.authorName,
+ subject: tag.subject,
+ checkedOutPath: null,
+ });
+ }
+ } catch {}
+ }
+
+ return refs;
+}
+
+async function getRefEntries(
+ git: SimpleGit,
+ {
+ refPath,
+ dateField,
+ authorField,
+ }: {
+ refPath: string;
+ dateField: "committerdate" | "creatordate";
+ authorField: "authorname" | "creatorname";
+ },
+): Promise {
+ const output = await git.raw([
+ "for-each-ref",
+ `--sort=-${dateField}`,
+ `--format=%(refname:short)${REF_FIELD_SEPARATOR}%(objectname:short)${REF_FIELD_SEPARATOR}%(${authorField})${REF_FIELD_SEPARATOR}%(subject)${REF_FIELD_SEPARATOR}%(${dateField}:unix)${REF_RECORD_SEPARATOR}`,
+ refPath,
+ ]);
+
+ return output
+ .split(REF_RECORD_SEPARATOR)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const [
+ name = "",
+ shortHash = "",
+ authorName = "",
+ subject = "",
+ timestamp = "0",
+ ] = line.split(REF_FIELD_SEPARATOR);
+ const parsedTimestamp = Number.parseInt(timestamp, 10);
+
+ return {
+ name,
+ shortHash: normalizeRefField(shortHash),
+ authorName: normalizeRefField(authorName),
+ subject: normalizeRefField(subject),
+ lastCommitDate: Number.isNaN(parsedTimestamp)
+ ? 0
+ : parsedTimestamp * 1000,
+ };
+ })
+ .filter((entry) => entry.name.length > 0);
+}
+
+function normalizeRefField(value: string): string | null {
+ const normalized = value.trim();
+ return normalized.length > 0 ? normalized : null;
+}
+
+function matchesSearch(
+ ref:
+ | ParsedRefEntry
+ | (ParsedRefEntry & { displayName?: string })
+ | SearchableRef,
+ searchLower: string,
+): boolean {
+ if (!searchLower) {
+ return true;
+ }
+
+ return [
+ ref.name,
+ "displayName" in ref ? ref.displayName : null,
+ ref.shortHash,
+ ref.authorName,
+ ref.subject,
+ ]
+ .filter((value): value is string => Boolean(value))
+ .some((value) => value.toLowerCase().includes(searchLower));
+}
diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
index f625d716294..6c085dcd4af 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
@@ -4,9 +4,13 @@ import type { SimpleGit } from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { toRegisteredWorktreeRelativePath } from "../workspace-fs-service";
-import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
+import {
+ execGitWithShellPathBuffer,
+ getSimpleGitWithShellPath,
+} from "../workspaces/utils/git-client";
const MAX_FILE_SIZE = 2 * 1024 * 1024;
+const MAX_BINARY_FILE_SIZE = 10 * 1024 * 1024;
export const createFileContentsRouter = () => {
return router({
@@ -51,6 +55,46 @@ export const createFileContentsRouter = () => {
};
}),
+ readGitFileBinary: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string(),
+ absolutePath: z.string(),
+ ref: z.string().default("HEAD"),
+ }),
+ )
+ .query(async ({ input }): Promise<{ content: string | null }> => {
+ const relativePath = toRegisteredWorktreeRelativePath(
+ input.worktreePath,
+ input.absolutePath,
+ );
+ const spec = `${input.ref}:${relativePath}`;
+ const git = await getSimpleGitWithShellPath(input.worktreePath);
+
+ try {
+ const sizeOutput = await git.raw(["cat-file", "-s", spec]);
+ const blobSize = Number.parseInt(sizeOutput.trim(), 10);
+ if (!Number.isNaN(blobSize) && blobSize > MAX_BINARY_FILE_SIZE) {
+ return { content: null };
+ }
+ } catch {
+ return { content: null };
+ }
+
+ try {
+ const { stdout } = await execGitWithShellPathBuffer(
+ ["cat-file", "-p", spec],
+ {
+ cwd: input.worktreePath,
+ maxBuffer: MAX_BINARY_FILE_SIZE,
+ },
+ );
+ return { content: stdout.toString("base64") };
+ } catch {
+ return { content: null };
+ }
+ }),
+
getGitOriginalContent: publicProcedure
.input(
z.object({
diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-blame.ts b/apps/desktop/src/lib/trpc/routers/changes/git-blame.ts
new file mode 100644
index 00000000000..2306484c132
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/changes/git-blame.ts
@@ -0,0 +1,249 @@
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+import { toRegisteredWorktreeRelativePath } from "../workspace-fs-service";
+import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
+import {
+ type GitHubCommitAuthor,
+ makeGitHubCommitAuthorCacheKey,
+ readCachedGitHubCommitAuthor,
+} from "../workspaces/utils/github/cache";
+import {
+ extractNwoFromUrl,
+ getRepoContext,
+} from "../workspaces/utils/github/repo-context";
+import { execWithShellEnv } from "../workspaces/utils/shell-env";
+import { assertRegisteredWorktree } from "./security/path-validation";
+
+export interface BlameEntry {
+ line: number;
+ commitHash: string;
+ author: string;
+ timestamp: number;
+ summary: string;
+}
+
+const GitHubCommitResponseSchema = z.object({
+ author: z
+ .object({
+ login: z.string().optional(),
+ avatar_url: z.string().optional(),
+ })
+ .nullable()
+ .optional(),
+});
+
+function isSafeAvatarUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+function parseJsonOrNull(stdout: string): unknown | null {
+ try {
+ return JSON.parse(stdout) as unknown;
+ } catch {
+ return null;
+ }
+}
+
+function getRepoCandidates(
+ repoContext: Awaited>,
+): string[] {
+ if (!repoContext) {
+ return [];
+ }
+
+ return Array.from(
+ new Set(
+ [repoContext.repoUrl, repoContext.upstreamUrl]
+ .map((url) => extractNwoFromUrl(url))
+ .filter((value): value is string => Boolean(value)),
+ ),
+ );
+}
+
+async function fetchGitHubCommitAuthorForRepo({
+ worktreePath,
+ repoNameWithOwner,
+ commitHash,
+}: {
+ worktreePath: string;
+ repoNameWithOwner: string;
+ commitHash: string;
+}): Promise {
+ const cacheKey = makeGitHubCommitAuthorCacheKey({
+ repoNameWithOwner,
+ commitHash,
+ });
+
+ return readCachedGitHubCommitAuthor(cacheKey, async () => {
+ try {
+ const { stdout } = await execWithShellEnv(
+ "gh",
+ ["api", `repos/${repoNameWithOwner}/commits/${commitHash}`],
+ { cwd: worktreePath },
+ );
+ const raw = parseJsonOrNull(stdout);
+ if (raw === null) {
+ return null;
+ }
+
+ const parsed = GitHubCommitResponseSchema.safeParse(raw);
+ if (!parsed.success) {
+ return null;
+ }
+
+ const login = parsed.data.author?.login?.trim() || null;
+ const avatarUrl =
+ parsed.data.author?.avatar_url &&
+ isSafeAvatarUrl(parsed.data.author.avatar_url)
+ ? parsed.data.author.avatar_url
+ : null;
+
+ if (!login && !avatarUrl) {
+ return null;
+ }
+
+ return { login, avatarUrl };
+ } catch {
+ return null;
+ }
+ });
+}
+
+async function getGitHubCommitAuthor({
+ worktreePath,
+ commitHash,
+}: {
+ worktreePath: string;
+ commitHash: string;
+}): Promise {
+ const repoContext = await getRepoContext(worktreePath);
+
+ for (const repoNameWithOwner of getRepoCandidates(repoContext)) {
+ const author = await fetchGitHubCommitAuthorForRepo({
+ worktreePath,
+ repoNameWithOwner,
+ commitHash,
+ });
+ if (author) {
+ return author;
+ }
+ }
+
+ return null;
+}
+
+function parseGitBlamePorcelain(output: string): BlameEntry[] {
+ const lines = output.split("\n");
+ const commitCache = new Map<
+ string,
+ { author: string; timestamp: number; summary: string }
+ >();
+ const result: BlameEntry[] = [];
+
+ let i = 0;
+ while (i < lines.length) {
+ const header = lines[i];
+ if (!header || header.length < 40) {
+ i++;
+ continue;
+ }
+
+ const commitHash = header.substring(0, 40);
+ if (!/^[0-9a-f]{40}$/.test(commitHash)) {
+ i++;
+ continue;
+ }
+
+ const parts = header.split(" ");
+ const finalLine = Number.parseInt(parts[2] ?? "", 10);
+
+ i++;
+
+ let author = "";
+ let timestamp = 0;
+ let summary = "";
+
+ if (!commitCache.has(commitHash)) {
+ while (i < lines.length && !lines[i].startsWith("\t")) {
+ const line = lines[i];
+ if (line.startsWith("author ")) {
+ author = line.substring(7);
+ } else if (line.startsWith("author-time ")) {
+ timestamp = Number.parseInt(line.substring(12), 10);
+ } else if (line.startsWith("summary ")) {
+ summary = line.substring(8);
+ }
+ i++;
+ }
+ commitCache.set(commitHash, { author, timestamp, summary });
+ } else {
+ while (i < lines.length && !lines[i].startsWith("\t")) {
+ i++;
+ }
+ // biome-ignore lint/style/noNonNullAssertion: commitHash is guaranteed to exist in cache at this point
+ const cached = commitCache.get(commitHash)!;
+ author = cached.author;
+ timestamp = cached.timestamp;
+ summary = cached.summary;
+ }
+
+ // skip the tab+content line
+ i++;
+
+ if (!Number.isNaN(finalLine)) {
+ result.push({ line: finalLine, commitHash, author, timestamp, summary });
+ }
+ }
+
+ return result;
+}
+
+export const createGitBlameRouter = () => {
+ return router({
+ getGitBlame: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string(),
+ absolutePath: z.string(),
+ }),
+ )
+ .query(async ({ input }): Promise<{ entries: BlameEntry[] }> => {
+ assertRegisteredWorktree(input.worktreePath);
+
+ const filePath = toRegisteredWorktreeRelativePath(
+ input.worktreePath,
+ input.absolutePath,
+ );
+
+ const git = await getSimpleGitWithShellPath(input.worktreePath);
+
+ try {
+ const output = await git.raw([
+ "blame",
+ "--porcelain",
+ "--",
+ filePath,
+ ]);
+ return { entries: parseGitBlamePorcelain(output) };
+ } catch {
+ return { entries: [] };
+ }
+ }),
+ getGitHubCommitAuthor: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string(),
+ commitHash: z.string().regex(/^[0-9a-f]{40}$/i),
+ }),
+ )
+ .query(async ({ input }): Promise => {
+ assertRegisteredWorktree(input.worktreePath);
+ return getGitHubCommitAuthor(input);
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operation-types.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operation-types.ts
new file mode 100644
index 00000000000..c8287bafdb5
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/changes/git-operation-types.ts
@@ -0,0 +1,45 @@
+/**
+ * Shared types for git operation responses that carry non-fatal warnings and
+ * partial-failure classification. Frontend maps these to the unified
+ * GitOperationDialog for auto-repair notifications and sync-partial reporting.
+ */
+
+export type GitOperationWarning =
+ | {
+ kind: "auto-published-upstream";
+ /** Branch that was auto-published when a pull/sync found no upstream. */
+ branch: string;
+ }
+ | {
+ kind: "post-push-fetch-failed";
+ /** Stderr of the failed fetch after a successful push. */
+ message: string;
+ }
+ | {
+ kind: "push-retargeted";
+ /** Remote name the push was redirected to (usually the fork host for a PR). */
+ remote: string;
+ /** Branch name on that remote. */
+ targetBranch: string;
+ }
+ | {
+ kind: "post-checkout-hook-failed";
+ /** Brief hook stderr. */
+ message: string;
+ };
+
+/**
+ * Thrown by sync() so the frontend can distinguish which stage (pull or push)
+ * failed and show a tailored dialog. Message is the underlying git stderr.
+ */
+export class GitSyncStageError extends Error {
+ readonly stage: "pull" | "push";
+ readonly cause: unknown;
+ constructor(stage: "pull" | "push", cause: unknown) {
+ const message = cause instanceof Error ? cause.message : String(cause);
+ super(`[sync:${stage}] ${message}`);
+ this.name = "GitSyncStageError";
+ this.stage = stage;
+ this.cause = cause;
+ }
+}
diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
index 73826001d8b..baa65db7f8e 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
@@ -1,8 +1,21 @@
+import {
+ generateTitleFromMessage,
+ generateTitleFromMessageWithStreamingModel,
+} from "@superset/chat/server/desktop";
import { TRPCError } from "@trpc/server";
+import { callSmallModel } from "lib/ai/call-small-model";
import { z } from "zod";
import { publicProcedure, router } from "../..";
+import {
+ setBranchPullRequestBaseRepoConfig,
+ unsetBranchPullRequestBaseRepoConfig,
+} from "../workspaces/utils/base-branch-config";
import { getCurrentBranch } from "../workspaces/utils/git";
import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
+import {
+ type GitOperationWarning,
+ GitSyncStageError,
+} from "./git-operation-types";
import {
isNoPullRequestFoundMessage,
isUpstreamMissingError,
@@ -20,7 +33,9 @@ import { mergePullRequest } from "./utils/merge-pull-request";
import {
buildNewPullRequestUrl,
findExistingOpenPRUrl,
+ resolvePullRequestBaseRepoSelection,
} from "./utils/pull-request-discovery";
+import { normalizeGitHubRepoUrl } from "./utils/pull-request-url";
import { clearStatusCacheForWorktree } from "./utils/status-cache";
import { clearWorktreeStatusCaches } from "./utils/worktree-status-caches";
@@ -57,6 +72,8 @@ export const createGitOperationsRouter = () => {
z.object({
worktreePath: z.string(),
message: z.string(),
+ /** Pass --no-verify to bypass pre-commit / commit-msg hooks. */
+ skipHooks: z.boolean().optional(),
}),
)
.mutation(
@@ -64,7 +81,8 @@ export const createGitOperationsRouter = () => {
assertRegisteredWorktree(input.worktreePath);
const git = await getGitWithShellPath(input.worktreePath);
- const result = await git.commit(input.message);
+ const options = input.skipHooks ? ["--no-verify"] : undefined;
+ const result = await git.commit(input.message, options);
clearStatusCacheForWorktree(input.worktreePath);
return { success: true, hash: result.commit };
},
@@ -77,34 +95,59 @@ export const createGitOperationsRouter = () => {
setUpstream: z.boolean().optional(),
}),
)
- .mutation(async ({ input }): Promise<{ success: boolean }> => {
- assertRegisteredWorktree(input.worktreePath);
-
- const git = await getGitWithShellPath(input.worktreePath);
- const hasUpstream = await hasUpstreamBranch(git);
- const localBranch = await getLocalBranchOrThrow({
- worktreePath: input.worktreePath,
- action: "push",
- });
+ .mutation(
+ async ({
+ input,
+ }): Promise<{
+ success: boolean;
+ warnings: GitOperationWarning[];
+ }> => {
+ assertRegisteredWorktree(input.worktreePath);
- if (input.setUpstream && !hasUpstream) {
- await pushWithResolvedUpstream({
- git,
- worktreePath: input.worktreePath,
- localBranch,
- });
- } else {
- await pushCurrentBranch({
- git,
+ const git = await getGitWithShellPath(input.worktreePath);
+ const hasUpstream = await hasUpstreamBranch(git);
+ const localBranch = await getLocalBranchOrThrow({
worktreePath: input.worktreePath,
- localBranch,
+ action: "push",
});
- }
+ const warnings: GitOperationWarning[] = [];
- await fetchCurrentBranch(git, input.worktreePath);
- clearStatusCacheForWorktree(input.worktreePath);
- return { success: true };
- }),
+ if (input.setUpstream && !hasUpstream) {
+ await pushWithResolvedUpstream({
+ git,
+ worktreePath: input.worktreePath,
+ localBranch,
+ });
+ warnings.push({
+ kind: "auto-published-upstream",
+ branch: localBranch,
+ });
+ } else {
+ await pushCurrentBranch({
+ git,
+ worktreePath: input.worktreePath,
+ localBranch,
+ });
+ }
+
+ try {
+ await fetchCurrentBranch(git, input.worktreePath);
+ } catch (fetchError) {
+ const message =
+ fetchError instanceof Error
+ ? fetchError.message
+ : String(fetchError);
+ console.warn(
+ "[git/push] post-push fetch failed (non-fatal):",
+ message,
+ );
+ warnings.push({ kind: "post-push-fetch-failed", message });
+ }
+
+ clearStatusCacheForWorktree(input.worktreePath);
+ return { success: true, warnings };
+ },
+ ),
pull: publicProcedure
.input(
@@ -138,45 +181,84 @@ export const createGitOperationsRouter = () => {
worktreePath: z.string(),
}),
)
- .mutation(async ({ input }): Promise<{ success: boolean }> => {
- assertRegisteredWorktree(input.worktreePath);
+ .mutation(
+ async ({
+ input,
+ }): Promise<{
+ success: boolean;
+ warnings: GitOperationWarning[];
+ }> => {
+ assertRegisteredWorktree(input.worktreePath);
- const git = await getGitWithShellPath(input.worktreePath);
- try {
- await git.pull(["--rebase"]);
- } catch (error) {
- const message =
- error instanceof Error ? error.message : String(error);
- if (isUpstreamMissingError(message)) {
- const localBranch = await getLocalBranchOrThrow({
- worktreePath: input.worktreePath,
- action: "push",
- });
- await pushWithResolvedUpstream({
+ const git = await getGitWithShellPath(input.worktreePath);
+ const warnings: GitOperationWarning[] = [];
+
+ try {
+ await git.pull(["--rebase"]);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : String(error);
+ if (isUpstreamMissingError(message)) {
+ const localBranch = await getLocalBranchOrThrow({
+ worktreePath: input.worktreePath,
+ action: "push",
+ });
+ await pushWithResolvedUpstream({
+ git,
+ worktreePath: input.worktreePath,
+ localBranch,
+ });
+ warnings.push({
+ kind: "auto-published-upstream",
+ branch: localBranch,
+ });
+ try {
+ await fetchCurrentBranch(git, input.worktreePath);
+ } catch (fetchError) {
+ const fetchMessage =
+ fetchError instanceof Error
+ ? fetchError.message
+ : String(fetchError);
+ warnings.push({
+ kind: "post-push-fetch-failed",
+ message: fetchMessage,
+ });
+ }
+ clearStatusCacheForWorktree(input.worktreePath);
+ return { success: true, warnings };
+ }
+ throw new GitSyncStageError("pull", error);
+ }
+
+ const localBranch = await getLocalBranchOrThrow({
+ worktreePath: input.worktreePath,
+ action: "push",
+ });
+ try {
+ await pushCurrentBranch({
git,
worktreePath: input.worktreePath,
localBranch,
});
+ } catch (pushError) {
+ throw new GitSyncStageError("push", pushError);
+ }
+ try {
await fetchCurrentBranch(git, input.worktreePath);
- clearStatusCacheForWorktree(input.worktreePath);
- return { success: true };
+ } catch (fetchError) {
+ const fetchMessage =
+ fetchError instanceof Error
+ ? fetchError.message
+ : String(fetchError);
+ warnings.push({
+ kind: "post-push-fetch-failed",
+ message: fetchMessage,
+ });
}
- throw error;
- }
-
- const localBranch = await getLocalBranchOrThrow({
- worktreePath: input.worktreePath,
- action: "push",
- });
- await pushCurrentBranch({
- git,
- worktreePath: input.worktreePath,
- localBranch,
- });
- await fetchCurrentBranch(git, input.worktreePath);
- clearStatusCacheForWorktree(input.worktreePath);
- return { success: true };
- }),
+ clearStatusCacheForWorktree(input.worktreePath);
+ return { success: true, warnings };
+ },
+ ),
fetch: publicProcedure
.input(z.object({ worktreePath: z.string() }))
@@ -193,10 +275,13 @@ export const createGitOperationsRouter = () => {
z.object({
worktreePath: z.string(),
allowOutOfDate: z.boolean().optional().default(false),
+ baseRepoUrl: z.string().url().optional(),
}),
)
.mutation(
- async ({ input }): Promise<{ success: boolean; url: string }> => {
+ async ({
+ input,
+ }): Promise<{ success: boolean; url: string; isExisting: boolean }> => {
assertRegisteredWorktree(input.worktreePath);
const git = await getGitWithShellPath(input.worktreePath);
@@ -204,6 +289,23 @@ export const createGitOperationsRouter = () => {
worktreePath: input.worktreePath,
action: "create a pull request",
});
+ const normalizedBaseRepoUrl = input.baseRepoUrl
+ ? normalizeGitHubRepoUrl(input.baseRepoUrl)
+ : null;
+ if (normalizedBaseRepoUrl) {
+ const selection = await resolvePullRequestBaseRepoSelection({
+ worktreePath: input.worktreePath,
+ branch,
+ preferredBaseRepoUrl: normalizedBaseRepoUrl,
+ });
+ if (selection.selectedBaseRepoUrl === normalizedBaseRepoUrl) {
+ await setBranchPullRequestBaseRepoConfig({
+ repoPath: input.worktreePath,
+ branch,
+ baseRepoUrl: normalizedBaseRepoUrl,
+ });
+ }
+ }
const trackingStatus = await getTrackingBranchStatus(git);
const hasUpstream = trackingStatus.hasUpstream;
@@ -258,7 +360,7 @@ export const createGitOperationsRouter = () => {
if (existingPRUrl) {
await fetchCurrentBranch(git, input.worktreePath);
clearWorktreeStatusCaches(input.worktreePath);
- return { success: true, url: existingPRUrl };
+ return { success: true, url: existingPRUrl, isExisting: true };
}
try {
@@ -266,11 +368,12 @@ export const createGitOperationsRouter = () => {
input.worktreePath,
git,
branch,
+ normalizedBaseRepoUrl,
);
await fetchCurrentBranch(git, input.worktreePath);
clearWorktreeStatusCaches(input.worktreePath);
- return { success: true, url };
+ return { success: true, url, isExisting: false };
} catch (error) {
// If creation reports branch/tracking mismatch but an open PR exists,
// recover by opening that existing PR instead of failing.
@@ -280,13 +383,100 @@ export const createGitOperationsRouter = () => {
if (recoveredPRUrl) {
await fetchCurrentBranch(git, input.worktreePath);
clearWorktreeStatusCaches(input.worktreePath);
- return { success: true, url: recoveredPRUrl };
+ return {
+ success: true,
+ url: recoveredPRUrl,
+ isExisting: true,
+ };
}
throw error;
}
},
),
+ resolveCreatePRBaseOptions: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string(),
+ }),
+ )
+ .mutation(
+ async ({
+ input,
+ }): Promise<{
+ baseRepoOptions: Awaited<
+ ReturnType
+ >["baseRepoOptions"];
+ selectedBaseRepoUrl: string | null;
+ requiresChoice: boolean;
+ }> => {
+ assertRegisteredWorktree(input.worktreePath);
+
+ const branch = await getLocalBranchOrThrow({
+ worktreePath: input.worktreePath,
+ action: "create a pull request",
+ });
+ const selection = await resolvePullRequestBaseRepoSelection({
+ worktreePath: input.worktreePath,
+ branch,
+ });
+
+ return {
+ ...selection,
+ requiresChoice:
+ selection.selectedBaseRepoUrl === null &&
+ selection.baseRepoOptions.length > 1,
+ };
+ },
+ ),
+
+ updatePullRequestBaseRepo: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string(),
+ baseRepoUrl: z.string().url().nullable(),
+ }),
+ )
+ .mutation(async ({ input }): Promise<{ success: boolean }> => {
+ assertRegisteredWorktree(input.worktreePath);
+
+ const branch = await getLocalBranchOrThrow({
+ worktreePath: input.worktreePath,
+ action: "update the pull request base repository",
+ });
+ const normalizedBaseRepoUrl = input.baseRepoUrl
+ ? normalizeGitHubRepoUrl(input.baseRepoUrl)
+ : null;
+
+ if (normalizedBaseRepoUrl) {
+ const selection = await resolvePullRequestBaseRepoSelection({
+ worktreePath: input.worktreePath,
+ branch,
+ preferredBaseRepoUrl: normalizedBaseRepoUrl,
+ });
+ if (selection.selectedBaseRepoUrl !== normalizedBaseRepoUrl) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid pull request base repository selection.",
+ });
+ }
+
+ await setBranchPullRequestBaseRepoConfig({
+ repoPath: input.worktreePath,
+ branch,
+ baseRepoUrl: normalizedBaseRepoUrl,
+ });
+ } else {
+ await unsetBranchPullRequestBaseRepoConfig({
+ repoPath: input.worktreePath,
+ branch,
+ });
+ }
+
+ clearWorktreeStatusCaches(input.worktreePath);
+ return { success: true };
+ }),
+
mergePR: publicProcedure
.input(
z.object({
@@ -337,5 +527,294 @@ export const createGitOperationsRouter = () => {
}
},
),
+
+ generateCommitMessage: publicProcedure
+ .input(z.object({ worktreePath: z.string() }))
+ .mutation(async ({ input }): Promise<{ message: string | null }> => {
+ assertRegisteredWorktree(input.worktreePath);
+
+ const git = await getGitWithShellPath(input.worktreePath);
+
+ // ---------------------------------------------------------------------------
+ // Hierarchical summarization (gptcommit-style):
+ // Phase 1 — Summarize each changed file independently (parallel)
+ // Phase 2 — Combine all summaries into a single commit message
+ // This avoids token-limit issues with large diffs and produces the
+ // most accurate results because no file content is truncated.
+ // ---------------------------------------------------------------------------
+
+ // Collect per-file diffs from staged, unstaged, and untracked sources
+ const [stagedStat, unstagedStat, statusSummary] = await Promise.all([
+ git.diff(["--cached", "--stat", "--stat-width=200"]),
+ git.diff(["--stat", "--stat-width=200"]),
+ git.status(),
+ ]);
+
+ interface FileChange {
+ path: string;
+ source: "staged" | "unstaged" | "untracked";
+ diff: string | null; // null for untracked / binary
+ }
+
+ const files: FileChange[] = [];
+
+ // Staged files
+ const stagedFiles = statusSummary.staged;
+ if (stagedFiles.length > 0) {
+ const diffs = await Promise.all(
+ stagedFiles.map((f) =>
+ git
+ .diff(["--cached", "--", f])
+ .then((d) => d.trim() || null)
+ .catch(() => null),
+ ),
+ );
+ for (let i = 0; i < stagedFiles.length; i++) {
+ files.push({
+ path: stagedFiles[i],
+ source: "staged",
+ diff: diffs[i],
+ });
+ }
+ }
+
+ // Unstaged files (modified tracked files)
+ const unstagedFiles = statusSummary.modified.filter(
+ (f) => !stagedFiles.includes(f),
+ );
+ if (unstagedFiles.length > 0) {
+ const diffs = await Promise.all(
+ unstagedFiles.map((f) =>
+ git
+ .diff(["--", f])
+ .then((d) => d.trim() || null)
+ .catch(() => null),
+ ),
+ );
+ for (let i = 0; i < unstagedFiles.length; i++) {
+ files.push({
+ path: unstagedFiles[i],
+ source: "unstaged",
+ diff: diffs[i],
+ });
+ }
+ }
+
+ // Untracked files (new, not yet added)
+ for (const f of statusSummary.not_added) {
+ files.push({ path: f, source: "untracked", diff: null });
+ }
+
+ if (files.length === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No changes to generate a commit message for.",
+ });
+ }
+
+ // Skip patterns — files that waste tokens without useful context
+ const SKIP_PATTERNS = [
+ /\.lock$/,
+ /package-lock\.json$/,
+ /bun\.lock(b)?$/,
+ /yarn\.lock$/,
+ /pnpm-lock\.yaml$/,
+ /\.min\.(js|css)$/,
+ ];
+ const isBinary = (path: string) =>
+ /\.(png|jpe?g|gif|ico|svg|webp|woff2?|ttf|eot|mp[34]|mov|zip|tar|gz|pdf)$/i.test(
+ path,
+ );
+
+ const summarizableFiles: FileChange[] = [];
+ const skippedFileNames: string[] = [];
+
+ for (const f of files) {
+ if (SKIP_PATTERNS.some((p) => p.test(f.path)) || isBinary(f.path)) {
+ skippedFileNames.push(f.path);
+ } else {
+ summarizableFiles.push(f);
+ }
+ }
+
+ // ---- Phase 1: Summarize each file in parallel -------------------------
+
+ const PHASE1_INSTRUCTIONS =
+ "与えられたdiffを1行の日本語で要約してください。何が変わったかを簡潔に。要約のみを返してください。";
+ const PER_FILE_MAX_CHARS = 4000;
+
+ const summarizeFile = async (f: FileChange): Promise => {
+ // Files without diff (untracked) — just report the file name
+ if (!f.diff) {
+ return `${f.path}: 新規ファイル`;
+ }
+
+ // Small diffs — no need to call LLM, include directly
+ if (f.diff.length < 300) {
+ return `${f.path}: ${f.diff}`;
+ }
+
+ const truncatedDiff =
+ f.diff.length > PER_FILE_MAX_CHARS
+ ? `${f.diff.slice(0, PER_FILE_MAX_CHARS)}\n... (truncated)`
+ : f.diff;
+
+ const { result } = await callSmallModel({
+ invoke: async ({
+ model,
+ credentials,
+ providerId,
+ providerName,
+ }) => {
+ if (providerId === "openai" && credentials.kind === "oauth") {
+ return generateTitleFromMessageWithStreamingModel({
+ message: `File: ${f.path}\n\n${truncatedDiff}`,
+ model: model as never,
+ instructions: PHASE1_INSTRUCTIONS,
+ });
+ }
+ return generateTitleFromMessage({
+ message: `File: ${f.path}\n\n${truncatedDiff}`,
+ agentModel: model,
+ agentId: `commit-file-summary-${providerId}`,
+ agentName: "File Summarizer",
+ instructions: PHASE1_INSTRUCTIONS,
+ tracingContext: {
+ surface: "commit-file-summary",
+ provider: providerName,
+ },
+ });
+ },
+ });
+
+ return `${f.path}: ${result ?? "変更あり"}`;
+ };
+
+ const fileSummaries = await Promise.all(
+ summarizableFiles.map(summarizeFile),
+ );
+
+ // ---- Phase 2: Generate final commit message from summaries ------------
+
+ let phase2Input = "変更されたファイルの要約:\n";
+ phase2Input += fileSummaries.join("\n");
+ if (skippedFileNames.length > 0) {
+ phase2Input += `\n\nその他の変更ファイル(依存関係・バイナリ):\n${skippedFileNames.join("\n")}`;
+ }
+ phase2Input += `\n\n変更の統計:\n${stagedStat || unstagedStat || "(統計なし)"}`;
+
+ const PHASE2_PROMPT = `以下のファイル変更要約に基づいて、簡潔なconventional commitメッセージを日本語で生成してください。\nフォーマット: type(scope): 日本語の説明\ntypeは feat, fix, refactor, chore, docs, test, style, perf のいずれか。\n72文字以内。コミットメッセージのみを返してください。\n\n${phase2Input}`;
+ const PHASE2_INSTRUCTIONS =
+ "日本語で簡潔なconventional commitメッセージを生成してください。コミットメッセージの行のみを返してください。";
+
+ const { result, attempts } = await callSmallModel({
+ invoke: async ({ model, credentials, providerId, providerName }) => {
+ if (providerId === "openai" && credentials.kind === "oauth") {
+ return generateTitleFromMessageWithStreamingModel({
+ message: PHASE2_PROMPT,
+ model: model as never,
+ instructions: PHASE2_INSTRUCTIONS,
+ });
+ }
+
+ return generateTitleFromMessage({
+ message: PHASE2_PROMPT,
+ agentModel: model,
+ agentId: `commit-message-${providerId}`,
+ agentName: "Commit Message Generator",
+ instructions: PHASE2_INSTRUCTIONS,
+ tracingContext: {
+ surface: "commit-message-generation",
+ provider: providerName,
+ },
+ });
+ },
+ });
+
+ if (!result) {
+ console.warn(
+ "[generateCommitMessage] All providers failed:",
+ JSON.stringify(attempts, null, 2),
+ );
+ }
+
+ return { message: result };
+ }),
+
+ forceUnlockIndex: publicProcedure
+ .input(z.object({ worktreePath: z.string() }))
+ .mutation(
+ async ({
+ input,
+ }): Promise<{ removed: boolean; path: string | null }> => {
+ assertRegisteredWorktree(input.worktreePath);
+ const { isAbsolute, resolve } = await import("node:path");
+ const { stat, unlink } = await import("node:fs/promises");
+
+ // Resolve the *real* git-dir. For linked worktrees ".git" is a
+ // file that points at ".git/worktrees/", where the actual
+ // index.lock lives. Falling back to "/.git" is fine for
+ // the non-linked case.
+ const git = await getGitWithShellPath(input.worktreePath);
+ let gitDir: string;
+ try {
+ const raw = (await git.raw(["rev-parse", "--git-dir"])).trim();
+ gitDir = isAbsolute(raw) ? raw : resolve(input.worktreePath, raw);
+ } catch {
+ gitDir = resolve(input.worktreePath, ".git");
+ }
+
+ const candidates = [
+ resolve(gitDir, "index.lock"),
+ resolve(gitDir, "HEAD.lock"),
+ resolve(gitDir, "shallow.lock"),
+ ];
+ // Walk every candidate so that index.lock and HEAD.lock
+ // co-existing (e.g. after a crash during a branch switch) can
+ // both be cleared in a single call. `path` in the response
+ // is the first lock removed so the UI has something concrete
+ // to show; `removed` is true if at least one file was deleted.
+ let firstRemoved: string | null = null;
+ for (const candidate of candidates) {
+ // stat: only swallow ENOENT (file not present). Other stat
+ // errors (EACCES, EPERM, EIO) are real failures and should
+ // surface so the user learns why the unlock did not run.
+ try {
+ await stat(candidate);
+ } catch (statError) {
+ const code = (statError as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") continue;
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to inspect lock file ${candidate}: ${
+ statError instanceof Error
+ ? statError.message
+ : String(statError)
+ }`,
+ });
+ }
+ // unlink failures (EACCES/EPERM when the file exists but can
+ // not be removed) are propagated verbatim — never swallowed.
+ try {
+ await unlink(candidate);
+ } catch (unlinkError) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to remove lock file ${candidate}: ${
+ unlinkError instanceof Error
+ ? unlinkError.message
+ : String(unlinkError)
+ }`,
+ });
+ }
+ if (firstRemoved === null) firstRemoved = candidate;
+ }
+ if (firstRemoved !== null) {
+ clearStatusCacheForWorktree(input.worktreePath);
+ return { removed: true, path: firstRemoved };
+ }
+ return { removed: false, path: null };
+ },
+ ),
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/changes/index.ts b/apps/desktop/src/lib/trpc/routers/changes/index.ts
index e931f8f54af..eea4f1cfc1e 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/index.ts
@@ -1,6 +1,7 @@
import { router } from "../..";
import { createBranchesRouter } from "./branches";
import { createFileContentsRouter } from "./file-contents";
+import { createGitBlameRouter } from "./git-blame";
import { createGitOperationsRouter } from "./git-operations";
import { createStagingRouter } from "./staging";
import { createStatusRouter } from "./status";
@@ -11,6 +12,7 @@ export const createChangesRouter = () => {
const fileContentsRouter = createFileContentsRouter();
const stagingRouter = createStagingRouter();
const gitOperationsRouter = createGitOperationsRouter();
+ const gitBlameRouter = createGitBlameRouter();
return router({
// Branch operations
@@ -27,5 +29,8 @@ export const createChangesRouter = () => {
// Git operations (commit, push, pull, sync, createPR)
...gitOperationsRouter._def.procedures,
+
+ // Git blame
+ ...gitBlameRouter._def.procedures,
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts
index 230ea918154..3699bf221de 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts
@@ -22,6 +22,45 @@ async function getGitWithShellPath(worktreePath: string) {
return getSimpleGitWithShellPath(worktreePath);
}
+function normalizeBranchName(branch: string): string {
+ const trimmed = branch.trim();
+ if (trimmed.startsWith("refs/heads/")) {
+ return trimmed.slice("refs/heads/".length);
+ }
+ if (trimmed.startsWith("refs/remotes/origin/")) {
+ return trimmed.slice("refs/remotes/origin/".length);
+ }
+ if (trimmed.startsWith("remotes/origin/")) {
+ return trimmed.slice("remotes/origin/".length);
+ }
+ if (trimmed.startsWith("origin/")) {
+ return trimmed.slice("origin/".length);
+ }
+ return trimmed;
+}
+
+function assertValidBranchName(branch: string): void {
+ // Validate: reject anything that looks like a flag
+ if (branch.startsWith("-")) {
+ throw new Error("Invalid branch name: cannot start with -");
+ }
+
+ // Validate: reject empty branch names
+ if (!branch.trim()) {
+ throw new Error("Invalid branch name: cannot be empty");
+ }
+}
+
+function assertValidStartPoint(startPoint: string): void {
+ if (startPoint.startsWith("-")) {
+ throw new Error("Invalid start point: cannot start with -");
+ }
+
+ if (!startPoint.trim()) {
+ throw new Error("Invalid start point: cannot be empty");
+ }
+}
+
async function isCurrentBranch({
worktreePath,
expectedBranch,
@@ -50,25 +89,59 @@ export async function gitSwitchBranch(
branch: string,
): Promise {
assertRegisteredWorktree(worktreePath);
-
- // Validate: reject anything that looks like a flag
- if (branch.startsWith("-")) {
- throw new Error("Invalid branch name: cannot start with -");
- }
-
- // Validate: reject empty branch names
- if (!branch.trim()) {
- throw new Error("Invalid branch name: cannot be empty");
- }
+ const normalizedBranch = normalizeBranchName(branch);
+ assertValidBranchName(normalizedBranch);
const git = await getGitWithShellPath(worktreePath);
await runWithPostCheckoutHookTolerance({
- context: `Switched branch to "${branch}" in ${worktreePath}`,
+ context: `Switched branch to "${normalizedBranch}" in ${worktreePath}`,
run: async () => {
+ const localBranches = await git.branchLocal();
+ if (localBranches.all.includes(normalizedBranch)) {
+ try {
+ await git.raw(["switch", normalizedBranch]);
+ return;
+ } catch (switchError) {
+ const errorMessage = String(switchError);
+ if (errorMessage.includes("is not a git command")) {
+ await git.checkout(normalizedBranch);
+ return;
+ }
+ throw switchError;
+ }
+ }
+
+ const remoteBranches = await git.branch(["-r"]);
+ const remoteBranch = `origin/${normalizedBranch}`;
+ if (remoteBranches.all.includes(remoteBranch)) {
+ try {
+ await git.raw([
+ "switch",
+ "--track",
+ "-c",
+ normalizedBranch,
+ remoteBranch,
+ ]);
+ return;
+ } catch (switchError) {
+ const errorMessage = String(switchError);
+ if (errorMessage.includes("is not a git command")) {
+ await git.checkout([
+ "-b",
+ normalizedBranch,
+ "--track",
+ remoteBranch,
+ ]);
+ return;
+ }
+ throw switchError;
+ }
+ }
+
try {
// Prefer `git switch` - unambiguous branch operation (git 2.23+)
- await git.raw(["switch", branch]);
+ await git.raw(["switch", normalizedBranch]);
} catch (switchError) {
// Check if it's because `switch` command doesn't exist (old git < 2.23)
// Git outputs: "git: 'switch' is not a git command. See 'git --help'."
@@ -76,12 +149,55 @@ export async function gitSwitchBranch(
if (errorMessage.includes("is not a git command")) {
// Fallback for older git versions
// Note: checkout WITHOUT -- is correct for branches
- await git.checkout(branch);
+ await git.checkout(normalizedBranch);
} else {
throw switchError;
}
}
},
+ didSucceed: async () =>
+ isCurrentBranch({ worktreePath, expectedBranch: normalizedBranch }),
+ });
+}
+
+/**
+ * Create and switch to a new branch, optionally from a specific ref.
+ *
+ * Uses `git switch -c` (or `git checkout -b` as a fallback).
+ */
+export async function gitCreateBranch(
+ worktreePath: string,
+ branch: string,
+ startPoint?: string,
+): Promise {
+ assertRegisteredWorktree(worktreePath);
+ assertValidBranchName(branch);
+ if (startPoint) {
+ assertValidStartPoint(startPoint);
+ }
+
+ const git = await getGitWithShellPath(worktreePath);
+
+ await runWithPostCheckoutHookTolerance({
+ context: `Created branch "${branch}" in ${worktreePath}`,
+ run: async () => {
+ try {
+ await git.raw(
+ startPoint
+ ? ["switch", "-c", branch, startPoint]
+ : ["switch", "-c", branch],
+ );
+ } catch (switchError) {
+ const errorMessage = String(switchError);
+ if (errorMessage.includes("is not a git command")) {
+ await git.checkout(
+ startPoint ? ["-b", branch, startPoint] : ["-b", branch],
+ );
+ return;
+ }
+ throw switchError;
+ }
+ },
didSucceed: async () =>
isCurrentBranch({ worktreePath, expectedBranch: branch }),
});
@@ -178,6 +294,20 @@ export async function gitStageAll(worktreePath: string): Promise {
await git.add("-A");
}
+/**
+ * Stage all changes to tracked files only.
+ *
+ * Uses `git add -u` so modifications and deletions of tracked files
+ * are staged, but untracked files are left alone. Matches the
+ * VS Code `git.smartCommitChanges: "tracked"` behavior.
+ */
+export async function gitStageTracked(worktreePath: string): Promise {
+ assertRegisteredWorktree(worktreePath);
+
+ const git = await getGitWithShellPath(worktreePath);
+ await git.add(["-u"]);
+}
+
/**
* Unstage a file (remove from staging area).
*
diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts
index cbf7598eb4f..b3d7bc28bed 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts
@@ -10,6 +10,7 @@ import {
gitStageAll,
gitStageFile,
gitStageFiles,
+ gitStageTracked,
gitStash,
gitStashIncludeUntracked,
gitStashPop,
@@ -127,6 +128,14 @@ export const createStagingRouter = () => {
return { success: true };
}),
+ stageTracked: publicProcedure
+ .input(z.object({ worktreePath: z.string() }))
+ .mutation(async ({ input }): Promise<{ success: boolean }> => {
+ await gitStageTracked(input.worktreePath);
+ clearStatusCacheForWorktree(input.worktreePath);
+ return { success: true };
+ }),
+
unstageAll: publicProcedure
.input(z.object({ worktreePath: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean }> => {
diff --git a/apps/desktop/src/lib/trpc/routers/changes/status.ts b/apps/desktop/src/lib/trpc/routers/changes/status.ts
index 89b570bd6e9..fd0dd54f61b 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/status.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts
@@ -1,5 +1,9 @@
import { TRPCError } from "@trpc/server";
-import type { ChangedFile, GitChangesStatus } from "shared/changes-types";
+import type {
+ ChangedFile,
+ CommitGraphData,
+ GitChangesStatus,
+} from "shared/changes-types";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { assertRegisteredWorktree } from "./security/path-validation";
@@ -49,7 +53,7 @@ export const createStatusRouter = () => {
{
dedupeKey: cacheKey,
strategy: "coalesce",
- timeoutMs: 45_000,
+ timeoutMs: 90_000,
},
);
@@ -112,5 +116,39 @@ export const createStatusRouter = () => {
throw error;
}
}),
+ getCommitGraph: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string(),
+ maxCount: z.number().int().min(1).max(5_000).optional(),
+ }),
+ )
+ .query(async ({ input }): Promise => {
+ assertRegisteredWorktree(input.worktreePath);
+ const effectiveMaxCount = input.maxCount ?? 500;
+
+ try {
+ return await runGitTask(
+ "getCommitGraph",
+ {
+ worktreePath: input.worktreePath,
+ maxCount: effectiveMaxCount,
+ },
+ {
+ dedupeKey: `graph:${input.worktreePath}:${effectiveMaxCount}`,
+ strategy: "coalesce",
+ timeoutMs: 30_000,
+ },
+ );
+ } catch (error) {
+ if (error instanceof Error && error.name === "NotGitRepoError") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ }
+ throw error;
+ }
+ }),
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts
index 5c6b6c81334..12c679614ab 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts
@@ -1,6 +1,9 @@
import type { ChangedFile } from "shared/changes-types";
import type { SimpleGit } from "simple-git";
import { parseDiffNumstat } from "./parse-status";
+import { withTimeout } from "./with-timeout";
+
+const NUMSTAT_TIMEOUT_MS = 15_000;
export async function applyNumstatToFiles(
git: SimpleGit,
@@ -10,7 +13,11 @@ export async function applyNumstatToFiles(
if (files.length === 0) return;
try {
- const numstat = await git.raw(diffArgs);
+ const numstat = await withTimeout(
+ git.raw(diffArgs),
+ NUMSTAT_TIMEOUT_MS,
+ "diff numstat",
+ );
const stats = parseDiffNumstat(numstat);
for (const file of files) {
diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts
index 4f79251f98a..924ec02d4f0 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts
@@ -1,29 +1,18 @@
import type { GitHubStatus } from "@superset/local-db";
-import { normalizeGitHubRepoUrl } from "./pull-request-url";
+import {
+ type GitRemoteInfo,
+ type GitTrackingRefInfo,
+ getPullRequestHeadRepoUrl,
+ isOpenPullRequestState,
+ type PullRequestPushTargetInfo,
+ resolveRemoteNameForPullRequestHead,
+} from "../../workspaces/utils/github/pr-attachment";
-type ExistingPullRequest = NonNullable;
-
-export interface GitRemoteInfo {
- name: string;
- fetchUrl?: string;
- pushUrl?: string;
-}
-
-export interface GitTrackingRefInfo {
- remoteName: string;
- branchName: string;
-}
+export type { GitRemoteInfo };
-export interface ExistingPullRequestPushTargetInfo {
- remote: string;
- targetBranch: string;
-}
-
-export function isOpenPullRequestState(
- state: ExistingPullRequest["state"],
-): boolean {
- return state === "open" || state === "draft";
-}
+type ExistingPullRequest = NonNullable;
+export type ExistingPullRequestPushTargetInfo = PullRequestPushTargetInfo;
+export { isOpenPullRequestState };
export function getExistingPRHeadRepoUrl(
pr: Pick<
@@ -31,15 +20,7 @@ export function getExistingPRHeadRepoUrl(
"headRepositoryOwner" | "headRepositoryName" | "isCrossRepository"
>,
): string | null {
- if (
- !pr.isCrossRepository ||
- !pr.headRepositoryOwner ||
- !pr.headRepositoryName
- ) {
- return null;
- }
-
- return `https://github.com/${pr.headRepositoryOwner}/${pr.headRepositoryName}`;
+ return getPullRequestHeadRepoUrl(pr);
}
export function resolveRemoteNameForExistingPRHead({
@@ -54,36 +35,11 @@ export function resolveRemoteNameForExistingPRHead({
>;
fallbackRemote: string;
}): string | null {
- if (!pr.isCrossRepository) {
- return fallbackRemote;
- }
-
- const headRepoUrl = getExistingPRHeadRepoUrl(pr);
- if (!headRepoUrl) {
- return null;
- }
-
- const normalizedHeadRepoUrl = normalizeGitHubRepoUrl(headRepoUrl);
- if (!normalizedHeadRepoUrl) {
- return null;
- }
-
- for (const remote of remotes) {
- const fetchUrl = remote.fetchUrl
- ? normalizeGitHubRepoUrl(remote.fetchUrl)
- : null;
- const pushUrl = remote.pushUrl
- ? normalizeGitHubRepoUrl(remote.pushUrl)
- : null;
- if (
- fetchUrl === normalizedHeadRepoUrl ||
- pushUrl === normalizedHeadRepoUrl
- ) {
- return remote.name;
- }
- }
-
- return null;
+ return resolveRemoteNameForPullRequestHead({
+ remotes,
+ pr,
+ fallbackRemote,
+ });
}
export function shouldRetargetPushToExistingPRHead({
diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts
index 68c4506db62..ef5e3688c31 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts
@@ -201,7 +201,7 @@ describe("mergePullRequest", () => {
expect(execWithShellEnvMock).toHaveBeenCalledWith(
"gh",
- ["pr", "merge", "42", "--rebase"],
+ ["pr", "merge", "42", "--rebase", "--repo", "superset-sh/superset"],
{ cwd: "/tmp/unborn-worktree" },
);
expect(getPRForBranchMock).toHaveBeenCalledWith(
diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.ts
index 0fd984db33c..c1b35c0db87 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.ts
@@ -4,6 +4,7 @@ import {
} from "../../workspaces/utils/git";
import { execGitWithShellPath } from "../../workspaces/utils/git-client";
import {
+ extractNwoFromUrl,
getPRForBranch,
getPullRequestRepoArgs,
getRepoContext,
@@ -77,12 +78,15 @@ export async function mergePullRequest({
throw new Error(PR_CLOSED_MESSAGE);
}
+ const prRepoNameWithOwner = extractNwoFromUrl(pr.url);
const args = [
"pr",
"merge",
String(pr.number),
`--${strategy}`,
- ...getPullRequestRepoArgs(repoContext),
+ ...(prRepoNameWithOwner
+ ? ["--repo", prRepoNameWithOwner]
+ : getPullRequestRepoArgs(repoContext)),
];
try {
diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts
index e9a481ca4cc..0b36ef67af8 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts
@@ -263,6 +263,9 @@ describe("detectLanguage", () => {
test("detects TypeScript files", () => {
expect(detectLanguage("file.ts")).toBe("typescript");
expect(detectLanguage("file.tsx")).toBe("typescript");
+ expect(detectLanguage("file.mts")).toBe("typescript");
+ expect(detectLanguage("file.d.mts")).toBe("typescript");
+ expect(detectLanguage("file.cts")).toBe("typescript");
});
test("detects JavaScript files", () => {
diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts
index 598f6676252..b408718a715 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts
@@ -28,12 +28,28 @@ function toChangedFile(
};
}
+const CONFLICT_PAIRS = new Set([
+ // Only states that produce conflict markers in the file content.
+ // Non-marker states (DD, AU, UD, UA, DU) have no markers and are
+ // handled as unstaged changes via git add/rm instead.
+ "AA", // both added
+ "UU", // both modified
+]);
+
+function isConflicted(index: string, working: string): boolean {
+ return CONFLICT_PAIRS.has(`${index}${working}`);
+}
+
export function parseGitStatus(
status: StatusResult,
-): Pick {
+): Pick<
+ GitChangesStatus,
+ "branch" | "staged" | "unstaged" | "untracked" | "conflicted"
+> {
const staged: ChangedFile[] = [];
const unstaged: ChangedFile[] = [];
const untracked: ChangedFile[] = [];
+ const conflicted: ChangedFile[] = [];
for (const file of status.files) {
const path = file.path;
@@ -45,6 +61,16 @@ export function parseGitStatus(
continue;
}
+ if (isConflicted(index, working)) {
+ conflicted.push({
+ path,
+ status: "modified",
+ additions: 0,
+ deletions: 0,
+ });
+ continue;
+ }
+
if (index && index !== " " && index !== "?") {
staged.push({
path,
@@ -70,6 +96,7 @@ export function parseGitStatus(
staged,
unstaged,
untracked,
+ conflicted,
};
}
diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts
index 1e3d5ab00dd..22abb83c06b 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts
@@ -1,101 +1,32 @@
import { TRPCError } from "@trpc/server";
import type { SimpleGit } from "simple-git";
import { z } from "zod";
-import { execGitWithShellPath } from "../../workspaces/utils/git-client";
-import { getRepoContext } from "../../workspaces/utils/github";
-import { getPullRequestRepoArgs } from "../../workspaces/utils/github/repo-context";
+import { getBranchPullRequestBaseRepoConfig } from "../../workspaces/utils/base-branch-config";
+import { fetchGitHubPRStatus } from "../../workspaces/utils/github";
+import {
+ extractNwoFromUrl,
+ getRepoContext,
+ getTrackingRepoUrl,
+} from "../../workspaces/utils/github/repo-context";
import { execWithShellEnv } from "../../workspaces/utils/shell-env";
import {
buildPullRequestCompareUrl,
normalizeGitHubRepoUrl,
parseUpstreamRef,
} from "./pull-request-url";
-
-async function findOpenPRByHeadCommit(
- worktreePath: string,
-): Promise {
- try {
- const { stdout: headOutput } = await execGitWithShellPath(
- ["rev-parse", "HEAD"],
- { cwd: worktreePath },
- );
- const headSha = headOutput.trim();
- if (!headSha) {
- return null;
- }
-
- const repoArgs = getPullRequestRepoArgs(await getRepoContext(worktreePath));
-
- const { stdout } = await execWithShellEnv(
- "gh",
- [
- "pr",
- "list",
- ...repoArgs,
- "--state",
- "open",
- "--search",
- `${headSha} is:pr`,
- "--limit",
- "20",
- "--json",
- "url,headRefOid",
- ],
- { cwd: worktreePath },
- );
-
- const parsed = JSON.parse(stdout) as Array<{
- url?: string;
- headRefOid?: string;
- }>;
- const match = parsed.find((candidate) => candidate.headRefOid === headSha);
- return match?.url?.trim() || null;
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- console.warn(
- "[git/findExistingOpenPRUrl] Failed commit-based PR lookup:",
- message,
- );
- return null;
- }
-}
+import { clearWorktreeStatusCaches } from "./worktree-status-caches";
export async function findExistingOpenPRUrl(
worktreePath: string,
): Promise {
- // Prefer tracking-based lookup first for fork/branch-name mismatch scenarios.
- try {
- const { stdout } = await execWithShellEnv(
- "gh",
- [
- "pr",
- "view",
- "--json",
- "url,state",
- "--jq",
- 'if .state == "OPEN" then .url else "" end',
- ],
- { cwd: worktreePath },
- );
- const url = stdout.trim();
- if (url) {
- return url;
- }
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- const isNoPROpenError = message
- .toLowerCase()
- .includes("no pull requests found");
- if (!isNoPROpenError) {
- console.warn(
- "[git/findExistingOpenPRUrl] Failed tracking-branch PR lookup:",
- message,
- );
- }
- // Fallback to commit-SHA search below.
+ clearWorktreeStatusCaches(worktreePath);
+ const githubStatus = await fetchGitHubPRStatus(worktreePath);
+ const pullRequest = githubStatus?.pr;
+ if (pullRequest?.state !== "open" && pullRequest?.state !== "draft") {
+ return null;
}
- return findOpenPRByHeadCommit(worktreePath);
+ return pullRequest.url.trim() || null;
}
const ghRepoMetadataSchema = z.object({
@@ -127,10 +58,142 @@ async function getMergeBaseBranch(
}
}
+export interface PullRequestBaseRepoOption {
+ label: string;
+ repoNameWithOwner: string;
+ repoUrl: string;
+ source: "current" | "tracking" | "upstream";
+}
+
+function getPullRequestBaseRepoLabel(
+ repoNameWithOwner: string,
+ source: PullRequestBaseRepoOption["source"],
+): string {
+ switch (source) {
+ case "tracking":
+ return `${repoNameWithOwner} (tracking remote)`;
+ case "upstream":
+ return `${repoNameWithOwner} (upstream repository)`;
+ default:
+ return `${repoNameWithOwner} (current repository)`;
+ }
+}
+
+export async function getPullRequestBaseRepoOptions(
+ worktreePath: string,
+): Promise {
+ const [repoContext, trackingRepoUrl] = await Promise.all([
+ getRepoContext(worktreePath),
+ getTrackingRepoUrl(worktreePath),
+ ]);
+
+ if (!repoContext) {
+ return [];
+ }
+
+ const candidates: Array<{
+ repoUrl: string | null;
+ source: PullRequestBaseRepoOption["source"];
+ }> = [
+ { repoUrl: trackingRepoUrl, source: "tracking" },
+ { repoUrl: repoContext.repoUrl, source: "current" },
+ {
+ repoUrl: repoContext.isFork ? repoContext.upstreamUrl : null,
+ source: "upstream",
+ },
+ ];
+
+ const options = new Map();
+ for (const candidate of candidates) {
+ const normalizedRepoUrl = normalizeGitHubRepoUrl(candidate.repoUrl ?? "");
+ if (!normalizedRepoUrl || options.has(normalizedRepoUrl)) {
+ continue;
+ }
+
+ const repoNameWithOwner = extractNwoFromUrl(normalizedRepoUrl);
+ if (!repoNameWithOwner) {
+ continue;
+ }
+
+ options.set(normalizedRepoUrl, {
+ label: getPullRequestBaseRepoLabel(repoNameWithOwner, candidate.source),
+ repoNameWithOwner,
+ repoUrl: normalizedRepoUrl,
+ source: candidate.source,
+ });
+ }
+
+ return [...options.values()];
+}
+
+export async function resolvePullRequestBaseRepoSelection({
+ worktreePath,
+ branch,
+ preferredBaseRepoUrl,
+}: {
+ worktreePath: string;
+ branch: string;
+ preferredBaseRepoUrl?: string | null;
+}): Promise<{
+ baseRepoOptions: PullRequestBaseRepoOption[];
+ selectedBaseRepoUrl: string | null;
+}> {
+ const [baseRepoOptions, configuredBaseRepo] = await Promise.all([
+ getPullRequestBaseRepoOptions(worktreePath),
+ getBranchPullRequestBaseRepoConfig({
+ repoPath: worktreePath,
+ branch,
+ }),
+ ]);
+
+ const normalizedPreferredBaseRepoUrl = preferredBaseRepoUrl
+ ? normalizeGitHubRepoUrl(preferredBaseRepoUrl)
+ : null;
+ if (
+ normalizedPreferredBaseRepoUrl &&
+ baseRepoOptions.some(
+ (option) => option.repoUrl === normalizedPreferredBaseRepoUrl,
+ )
+ ) {
+ return {
+ baseRepoOptions,
+ selectedBaseRepoUrl: normalizedPreferredBaseRepoUrl,
+ };
+ }
+
+ const normalizedConfiguredBaseRepoUrl = configuredBaseRepo.baseRepoUrl
+ ? normalizeGitHubRepoUrl(configuredBaseRepo.baseRepoUrl)
+ : null;
+ if (
+ normalizedConfiguredBaseRepoUrl &&
+ baseRepoOptions.some(
+ (option) => option.repoUrl === normalizedConfiguredBaseRepoUrl,
+ )
+ ) {
+ return {
+ baseRepoOptions,
+ selectedBaseRepoUrl: normalizedConfiguredBaseRepoUrl,
+ };
+ }
+
+ if (baseRepoOptions.length === 1) {
+ return {
+ baseRepoOptions,
+ selectedBaseRepoUrl: baseRepoOptions[0]?.repoUrl ?? null,
+ };
+ }
+
+ return {
+ baseRepoOptions,
+ selectedBaseRepoUrl: null,
+ };
+}
+
export async function buildNewPullRequestUrl(
worktreePath: string,
git: SimpleGit,
branch: string,
+ preferredBaseRepoUrl?: string | null,
): Promise {
const { stdout } = await execWithShellEnv(
"gh",
@@ -139,19 +202,32 @@ export async function buildNewPullRequestUrl(
);
const repoMetadata = ghRepoMetadataSchema.parse(JSON.parse(stdout));
const currentRepoUrl = normalizeGitHubRepoUrl(repoMetadata.url);
- const baseRepoUrl = normalizeGitHubRepoUrl(
- repoMetadata.isFork && repoMetadata.parent?.url
- ? repoMetadata.parent.url
- : repoMetadata.url,
- );
+ const { baseRepoOptions, selectedBaseRepoUrl } =
+ await resolvePullRequestBaseRepoSelection({
+ worktreePath,
+ branch,
+ preferredBaseRepoUrl,
+ });
+ const baseRepoUrl = selectedBaseRepoUrl;
- if (!currentRepoUrl || !baseRepoUrl) {
+ if (!currentRepoUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "GitHub is not available for this workspace.",
});
}
+ if (!baseRepoUrl) {
+ throw new TRPCError({
+ code:
+ baseRepoOptions.length === 0 ? "BAD_REQUEST" : "PRECONDITION_FAILED",
+ message:
+ baseRepoOptions.length === 0
+ ? "No GitHub pull request base repository is available for this workspace."
+ : "Multiple base repositories are available. Choose a base repository before creating a pull request.",
+ });
+ }
+
const configuredBaseBranch = await getMergeBaseBranch(git, branch);
const baseBranch = configuredBaseBranch ?? repoMetadata.defaultBranchRef.name;
let headRepoOwner = currentRepoUrl.split("/").at(-2) ?? "";
diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/with-timeout.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/with-timeout.ts
new file mode 100644
index 00000000000..6e3f498f227
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/changes/utils/with-timeout.ts
@@ -0,0 +1,25 @@
+/**
+ * Race a promise against a timeout. Clears the timer on both resolve and reject
+ * to avoid leaked timers.
+ */
+export function withTimeout(
+ promise: Promise,
+ ms: number,
+ label: string,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ reject(new Error(`git operation timed out after ${ms}ms: ${label}`));
+ }, ms);
+ promise.then(
+ (value) => {
+ clearTimeout(timer);
+ resolve(value);
+ },
+ (error) => {
+ clearTimeout(timer);
+ reject(error);
+ },
+ );
+ });
+}
diff --git a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts
index 1b8ca618cd8..192788f830c 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts
@@ -1,6 +1,11 @@
import { readFile, realpath, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
-import type { ChangedFile, GitChangesStatus } from "shared/changes-types";
+import type {
+ ChangedFile,
+ CommitGraphData,
+ CommitGraphNode,
+ GitChangesStatus,
+} from "shared/changes-types";
import type { SimpleGit, StatusResult } from "simple-git";
import { getStatusNoLock } from "../../workspaces/utils/git";
import { getSimpleGitWithShellPath } from "../../workspaces/utils/git-client";
@@ -10,6 +15,7 @@ import {
parseGitStatus,
parseNameStatus,
} from "../utils/parse-status";
+import { withTimeout } from "../utils/with-timeout";
import type {
GitTaskPayloadMap,
GitTaskResultMap,
@@ -30,8 +36,15 @@ interface TrackingStatus {
}
const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024;
+const MAX_UNTRACKED_LINE_COUNT_FILES = 200;
const WORKER_DEBUG = process.env.SUPERSET_WORKER_DEBUG === "1";
+/**
+ * Per-operation timeout for individual git commands.
+ * Prevents a single slow operation from consuming the entire task budget.
+ */
+const GIT_OP_TIMEOUT_MS = 15_000;
+
function logWorkerWarning(message: string, error: unknown): void {
console.warn(`[changes-git-worker] ${message}`, error);
}
@@ -69,6 +82,8 @@ async function applyUntrackedLineCount(
worktreePath: string,
untracked: ChangedFile[],
): Promise {
+ if (untracked.length > MAX_UNTRACKED_LINE_COUNT_FILES) return;
+
let worktreeReal: string;
try {
worktreeReal = await realpath(worktreePath);
@@ -119,30 +134,38 @@ async function getBranchComparison(
let behind = 0;
try {
- const tracking = await git.raw([
- "rev-list",
- "--left-right",
- "--count",
- `origin/${defaultBranch}...HEAD`,
- ]);
+ const tracking = await withTimeout(
+ git.raw([
+ "rev-list",
+ "--left-right",
+ "--count",
+ `origin/${defaultBranch}...HEAD`,
+ ]),
+ GIT_OP_TIMEOUT_MS,
+ "rev-list count",
+ );
const [behindStr, aheadStr] = tracking.trim().split(/\s+/);
behind = Number.parseInt(behindStr || "0", 10);
ahead = Number.parseInt(aheadStr || "0", 10);
- const logOutput = await git.raw([
- "log",
- `origin/${defaultBranch}..HEAD`,
- "--max-count=500",
- "--format=%H|%h|%s|%an|%aI",
- ]);
+ const logOutput = await withTimeout(
+ git.raw([
+ "log",
+ `origin/${defaultBranch}..HEAD`,
+ "--max-count=500",
+ "--format=%H|%h|%s|%an|%aI",
+ ]),
+ GIT_OP_TIMEOUT_MS,
+ "log commits",
+ );
commits = parseGitLog(logOutput);
if (ahead > 0) {
- const nameStatus = await git.raw([
- "diff",
- "--name-status",
- `origin/${defaultBranch}...HEAD`,
- ]);
+ const nameStatus = await withTimeout(
+ git.raw(["diff", "--name-status", `origin/${defaultBranch}...HEAD`]),
+ GIT_OP_TIMEOUT_MS,
+ "diff name-status",
+ );
againstBase = parseNameStatus(nameStatus);
await applyNumstatToFiles(git, againstBase, [
@@ -165,21 +188,20 @@ async function getTrackingBranchStatus(
git: SimpleGit,
): Promise {
try {
- const upstream = await git.raw([
- "rev-parse",
- "--abbrev-ref",
- "@{upstream}",
- ]);
+ const upstream = await withTimeout(
+ git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]),
+ GIT_OP_TIMEOUT_MS,
+ "rev-parse upstream",
+ );
if (!upstream.trim()) {
return { pushCount: 0, pullCount: 0, hasUpstream: false };
}
- const tracking = await git.raw([
- "rev-list",
- "--left-right",
- "--count",
- "@{upstream}...HEAD",
- ]);
+ const tracking = await withTimeout(
+ git.raw(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]),
+ GIT_OP_TIMEOUT_MS,
+ "rev-list tracking",
+ );
const [pullStr, pushStr] = tracking.trim().split(/\s+/);
return {
pushCount: Number.parseInt(pushStr || "0", 10),
@@ -217,6 +239,7 @@ async function computeStatus({
staged: parsed.staged,
unstaged: parsed.unstaged,
untracked: parsed.untracked,
+ conflicted: parsed.conflicted,
ahead: branchComparison.ahead,
behind: branchComparison.behind,
pushCount: trackingStatus.pushCount,
@@ -251,6 +274,70 @@ async function computeCommitFiles({
return files;
}
+function parseGitGraphLog(logOutput: string): CommitGraphNode[] {
+ if (!logOutput.trim()) return [];
+
+ const nodes: CommitGraphNode[] = [];
+ const parts = logOutput.split("\x00");
+
+ for (let index = 0; index + 10 < parts.length; index += 11) {
+ const [
+ hash,
+ shortHash,
+ message,
+ fullMessageRaw,
+ author,
+ authorEmail,
+ committer,
+ committerEmail,
+ dateStr,
+ parentsStr,
+ refsStr,
+ ] = parts.slice(index, index + 11);
+ if (!hash || !shortHash) continue;
+
+ const date = dateStr ? new Date(dateStr) : new Date();
+ const parentHashes = parentsStr?.trim() ? parentsStr.trim().split(" ") : [];
+ const refs = refsStr?.trim()
+ ? refsStr.trim().split(", ").filter(Boolean)
+ : [];
+
+ nodes.push({
+ hash,
+ shortHash,
+ message: message ?? "",
+ fullMessage: fullMessageRaw?.trimEnd() || message || "",
+ author: author ?? "",
+ authorEmail: authorEmail ?? "",
+ committer: committer ?? "",
+ committerEmail: committerEmail ?? "",
+ date,
+ parentHashes,
+ refs,
+ });
+ }
+ return nodes;
+}
+
+async function computeCommitGraph({
+ worktreePath,
+ maxCount = 500,
+}: GitTaskPayloadMap["getCommitGraph"]): Promise {
+ const git = await getSimpleGitWithShellPath(worktreePath);
+ const logOutput = await git.raw([
+ "log",
+ "--all",
+ "--topo-order",
+ "--date-order",
+ "--decorate=short",
+ `--max-count=${maxCount}`,
+ "-z",
+ "--format=%H%x00%h%x00%s%x00%B%x00%an%x00%ae%x00%cn%x00%ce%x00%aI%x00%P%x00%D",
+ ]);
+ const nodes = parseGitGraphLog(logOutput);
+ return { nodes };
+}
+
export async function executeGitTask(
taskType: TTask,
payload: GitTaskPayloadMap[TTask],
@@ -264,6 +351,10 @@ export async function executeGitTask(
return computeCommitFiles(
payload as GitTaskPayloadMap["getCommitFiles"],
) as Promise;
+ case "getCommitGraph":
+ return computeCommitGraph(
+ payload as GitTaskPayloadMap["getCommitGraph"],
+ ) as Promise;
default: {
const exhaustive: never = taskType;
throw new Error(`Unknown git task: ${exhaustive}`);
diff --git a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-types.ts b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-types.ts
index 2a9bbff5da6..c07017017aa 100644
--- a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-types.ts
+++ b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-types.ts
@@ -1,4 +1,8 @@
-import type { ChangedFile, GitChangesStatus } from "shared/changes-types";
+import type {
+ ChangedFile,
+ CommitGraphData,
+ GitChangesStatus,
+} from "shared/changes-types";
export interface GitTaskPayloadMap {
getStatus: {
@@ -9,11 +13,16 @@ export interface GitTaskPayloadMap {
worktreePath: string;
commitHash: string;
};
+ getCommitGraph: {
+ worktreePath: string;
+ maxCount?: number;
+ };
}
export interface GitTaskResultMap {
getStatus: GitChangesStatus;
getCommitFiles: ChangedFile[];
+ getCommitGraph: CommitGraphData;
}
export type GitTaskType = keyof GitTaskPayloadMap;
diff --git a/apps/desktop/src/lib/trpc/routers/databases/index.ts b/apps/desktop/src/lib/trpc/routers/databases/index.ts
new file mode 100644
index 00000000000..7b62d6b3253
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/databases/index.ts
@@ -0,0 +1,1043 @@
+import type { Stats } from "node:fs";
+import { stat } from "node:fs/promises";
+import path from "node:path";
+import { TRPCError } from "@trpc/server";
+import Database from "better-sqlite3";
+import fg from "fast-glob";
+import { Client } from "pg";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+import {
+ deleteManualPostgresConnectionString,
+ discoverWorkspaceConfiguredDatabases,
+ getManualPostgresConnectionString,
+ postgresConnectionSourceSchema,
+ resolvePostgresConnectionStringFromSource,
+ saveManualPostgresConnectionString,
+ saveWorkspaceDatabaseCredentials,
+ updateWorkspaceDatabaseDefinition,
+} from "./workspace-config";
+
+const SQLITE_FILE_GLOBS = [
+ "**/*.db",
+ "**/*.sqlite",
+ "**/*.sqlite3",
+ "**/*.db3",
+ "**/*.duckdb",
+];
+
+const SQLITE_ROW_ID_COLUMN = "__superset_rowid";
+const SQLITE_PRIMARY_KEY_COLUMN = "__superset_primary_key";
+const POSTGRES_ROW_ID_COLUMN = "__superset_ctid";
+const PREVIEW_TEXT_LIMIT = 180;
+
+function isAbsoluteFilesystemPath(inputPath: string): boolean {
+ return path.isAbsolute(inputPath) || /^[A-Za-z]:[\\/]/.test(inputPath);
+}
+
+function ensureAbsoluteFilesystemPath(inputPath: string): void {
+ if (!isAbsoluteFilesystemPath(inputPath)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Database path must be absolute.",
+ });
+ }
+}
+
+async function ensureExistingFile(inputPath: string): Promise {
+ let metadata: Stats;
+ try {
+ metadata = await stat(inputPath);
+ } catch {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Database file not found: ${inputPath}`,
+ });
+ }
+
+ if (!metadata.isFile()) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Path is not a file: ${inputPath}`,
+ });
+ }
+}
+
+async function ensureExistingDirectory(inputPath: string): Promise {
+ let metadata: Stats;
+ try {
+ metadata = await stat(inputPath);
+ } catch {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Workspace path not found: ${inputPath}`,
+ });
+ }
+
+ if (!metadata.isDirectory()) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Path is not a directory: ${inputPath}`,
+ });
+ }
+}
+
+function quoteSqliteIdentifier(identifier: string): string {
+ return `"${identifier.replaceAll('"', '""')}"`;
+}
+
+function quotePostgresIdentifier(identifier: string): string {
+ return `"${identifier.replaceAll('"', '""')}"`;
+}
+
+function quoteSqlStringLiteral(value: string): string {
+ return `'${value.replaceAll("'", "''")}'`;
+}
+
+function buildSqlitePreviewExpression(
+ columnName: string,
+ declaredType: string | null | undefined,
+): string {
+ const quotedColumn = quoteSqliteIdentifier(columnName);
+ const normalizedType = (declaredType ?? "").toLowerCase();
+
+ if (normalizedType.includes("blob")) {
+ return `CASE WHEN ${quotedColumn} IS NULL THEN NULL ELSE '' END AS ${quoteSqliteIdentifier(columnName)}`;
+ }
+
+ if (
+ normalizedType.includes("text") ||
+ normalizedType.includes("char") ||
+ normalizedType.includes("clob") ||
+ normalizedType.includes("json") ||
+ normalizedType.length === 0
+ ) {
+ return `CASE
+ WHEN ${quotedColumn} IS NULL THEN NULL
+ WHEN typeof(${quotedColumn}) = 'text' AND length(CAST(${quotedColumn} AS TEXT)) > ${PREVIEW_TEXT_LIMIT}
+ THEN substr(CAST(${quotedColumn} AS TEXT), 1, ${PREVIEW_TEXT_LIMIT}) || '…'
+ ELSE ${quotedColumn}
+ END AS ${quoteSqliteIdentifier(columnName)}`;
+ }
+
+ return `${quotedColumn} AS ${quoteSqliteIdentifier(columnName)}`;
+}
+
+function buildPostgresPreviewExpression(input: {
+ columnName: string;
+ dataType: string;
+ udtName: string;
+}): string {
+ const quotedColumn = quotePostgresIdentifier(input.columnName);
+ const outputAlias = quotePostgresIdentifier(input.columnName);
+ const normalizedType = input.dataType.toLowerCase();
+ const normalizedUdtName = input.udtName.toLowerCase();
+
+ if (normalizedType === "bytea") {
+ return `CASE WHEN ${quotedColumn} IS NULL THEN NULL ELSE '' END AS ${outputAlias}`;
+ }
+
+ if (normalizedType === "json" || normalizedType === "jsonb") {
+ return `CASE
+ WHEN ${quotedColumn} IS NULL THEN NULL
+ ELSE '<${normalizedType}> ' || left(${quotedColumn}::text, ${PREVIEW_TEXT_LIMIT}) ||
+ CASE WHEN length(${quotedColumn}::text) > ${PREVIEW_TEXT_LIMIT} THEN '…' ELSE '' END
+ END AS ${outputAlias}`;
+ }
+
+ if (normalizedType === "array") {
+ return `CASE
+ WHEN ${quotedColumn} IS NULL THEN NULL
+ ELSE 'Array(' || coalesce(cardinality(${quotedColumn}), 0)::text || ') ' ||
+ left(${quotedColumn}::text, ${PREVIEW_TEXT_LIMIT}) ||
+ CASE WHEN length(${quotedColumn}::text) > ${PREVIEW_TEXT_LIMIT} THEN '…' ELSE '' END
+ END AS ${outputAlias}`;
+ }
+
+ if (
+ normalizedType === "text" ||
+ normalizedType === "character varying" ||
+ normalizedType === "character" ||
+ normalizedType === "xml" ||
+ normalizedType === "citext" ||
+ normalizedType === "tsvector" ||
+ normalizedType === "tsquery" ||
+ normalizedUdtName === "vector" ||
+ normalizedUdtName === "halfvec" ||
+ normalizedUdtName === "sparsevec" ||
+ normalizedUdtName === "geometry" ||
+ normalizedUdtName === "geography" ||
+ normalizedUdtName === "hstore"
+ ) {
+ return `CASE
+ WHEN ${quotedColumn} IS NULL THEN NULL
+ WHEN length(${quotedColumn}::text) > ${PREVIEW_TEXT_LIMIT}
+ THEN left(${quotedColumn}::text, ${PREVIEW_TEXT_LIMIT}) || '…'
+ ELSE ${quotedColumn}::text
+ END AS ${outputAlias}`;
+ }
+
+ return `${quotedColumn} AS ${outputAlias}`;
+}
+
+function getSqliteTableMetadata(
+ db: Database.Database,
+ tableName: string,
+): {
+ columns: Array<{
+ cid: number;
+ name: string;
+ type: string | null;
+ notnull: 0 | 1;
+ dflt_value: string | null;
+ pk: number;
+ }>;
+ primaryKeyColumns: Array<{
+ cid: number;
+ name: string;
+ type: string | null;
+ notnull: 0 | 1;
+ dflt_value: string | null;
+ pk: number;
+ }>;
+ hasRowId: boolean;
+} {
+ const columns = db
+ .prepare(`PRAGMA table_info(${quoteSqliteIdentifier(tableName)})`)
+ .all() as Array<{
+ cid: number;
+ name: string;
+ type: string | null;
+ notnull: 0 | 1;
+ dflt_value: string | null;
+ pk: number;
+ }>;
+ const primaryKeyColumns = columns
+ .filter((column) => column.pk > 0)
+ .sort((left, right) => left.pk - right.pk);
+ const tableDefinition = db
+ .prepare(
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
+ )
+ .get(tableName) as { sql?: string | null } | undefined;
+
+ return {
+ columns,
+ primaryKeyColumns,
+ hasRowId: !/without\s+rowid/i.test(tableDefinition?.sql ?? ""),
+ };
+}
+
+function buildSqlitePrimaryKeyPreviewExpression(
+ primaryKeyColumns: Array<{ name: string }>,
+): string {
+ if (primaryKeyColumns.length === 0) {
+ return `NULL AS ${quoteSqliteIdentifier(SQLITE_PRIMARY_KEY_COLUMN)}`;
+ }
+
+ const jsonEntries = primaryKeyColumns.flatMap((column) => [
+ quoteSqlStringLiteral(column.name),
+ quoteSqliteIdentifier(column.name),
+ ]);
+
+ return `json_object(${jsonEntries.join(", ")}) AS ${quoteSqliteIdentifier(SQLITE_PRIMARY_KEY_COLUMN)}`;
+}
+
+function openSqliteDatabase(databasePath: string): Database.Database {
+ try {
+ return new Database(databasePath, {
+ fileMustExist: true,
+ });
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to open SQLite database.",
+ });
+ }
+}
+
+async function withPostgresClient(
+ connectionString: string,
+ callback: (client: Client) => Promise,
+): Promise {
+ const client = new Client({ connectionString });
+
+ try {
+ await client.connect();
+ return await callback(client);
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to connect to PostgreSQL.",
+ });
+ } finally {
+ await client.end().catch(() => undefined);
+ }
+}
+
+function stripTrailingSemicolon(sql: string): string {
+ return sql.replace(/;\s*$/, "");
+}
+
+function canApplyPostgresReadLimit(sql: string): boolean {
+ return /^(select|with|values|table)\b/i.test(sql.trim());
+}
+
+export const createDatabasesRouter = () => {
+ return router({
+ discoverSqliteFiles: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string().min(1),
+ limit: z.number().int().positive().max(200).optional(),
+ }),
+ )
+ .query(async ({ input }) => {
+ ensureAbsoluteFilesystemPath(input.worktreePath);
+ await ensureExistingDirectory(input.worktreePath);
+
+ const limit = input.limit ?? 50;
+ const files = await fg(SQLITE_FILE_GLOBS, {
+ absolute: true,
+ cwd: input.worktreePath,
+ onlyFiles: true,
+ unique: true,
+ suppressErrors: true,
+ ignore: [
+ "**/.git/**",
+ "**/.next/**",
+ "**/.turbo/**",
+ "**/dist/**",
+ "**/node_modules/**",
+ ],
+ });
+
+ return {
+ files: files
+ .sort((left, right) => left.localeCompare(right))
+ .slice(0, limit)
+ .map((absolutePath) => ({
+ absolutePath,
+ relativePath: path.relative(input.worktreePath, absolutePath),
+ })),
+ };
+ }),
+
+ discoverWorkspaceDatabases: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string().min(1),
+ limit: z.number().int().positive().max(200).optional(),
+ }),
+ )
+ .query(async ({ input }) => {
+ ensureAbsoluteFilesystemPath(input.worktreePath);
+ await ensureExistingDirectory(input.worktreePath);
+
+ const limit = input.limit ?? 50;
+ const files = await fg(SQLITE_FILE_GLOBS, {
+ absolute: true,
+ cwd: input.worktreePath,
+ onlyFiles: true,
+ unique: true,
+ suppressErrors: true,
+ ignore: [
+ "**/.git/**",
+ "**/.next/**",
+ "**/.turbo/**",
+ "**/dist/**",
+ "**/node_modules/**",
+ ],
+ });
+
+ const configuredDatabases = await discoverWorkspaceConfiguredDatabases(
+ input.worktreePath,
+ );
+ const configuredSqlitePaths = new Set(
+ configuredDatabases
+ .filter((item) => item.dialect === "sqlite")
+ .map((item) => item.absolutePath),
+ );
+
+ const fileItems = files
+ .filter((absolutePath) => !configuredSqlitePaths.has(absolutePath))
+ .map((absolutePath) => ({
+ source: "file" as const,
+ dialect: "sqlite" as const,
+ absolutePath,
+ relativePath: path.relative(input.worktreePath, absolutePath),
+ }));
+
+ const items = [
+ ...fileItems.slice(
+ 0,
+ Math.max(0, limit - configuredDatabases.length),
+ ),
+ ...configuredDatabases,
+ ].sort((left, right) =>
+ left.relativePath.localeCompare(right.relativePath),
+ );
+
+ return { items };
+ }),
+
+ saveWorkspaceDatabaseCredentials: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string().min(1),
+ definitionId: z.string().min(1),
+ username: z.string().min(1),
+ password: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ ensureAbsoluteFilesystemPath(input.worktreePath);
+ await ensureExistingDirectory(input.worktreePath);
+ await saveWorkspaceDatabaseCredentials({
+ workspacePath: input.worktreePath,
+ definitionId: input.definitionId,
+ username: input.username,
+ password: input.password,
+ });
+ return { ok: true };
+ }),
+
+ updateWorkspaceDatabaseDefinition: publicProcedure
+ .input(
+ z.object({
+ worktreePath: z.string().min(1),
+ definitionId: z.string().min(1),
+ definition: z.discriminatedUnion("dialect", [
+ z.object({
+ dialect: z.literal("sqlite"),
+ label: z.string().min(1),
+ group: z.string().trim().min(1).optional(),
+ databasePath: z.string().min(1),
+ }),
+ z.object({
+ dialect: z.literal("postgres"),
+ label: z.string().min(1),
+ group: z.string().trim().min(1).optional(),
+ host: z.string().min(1),
+ port: z.number().int().positive().max(65535),
+ database: z.string().optional(),
+ ssl: z.boolean(),
+ username: z.string().min(1).optional(),
+ }),
+ ]),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ ensureAbsoluteFilesystemPath(input.worktreePath);
+ await ensureExistingDirectory(input.worktreePath);
+ const definition = await updateWorkspaceDatabaseDefinition({
+ workspacePath: input.worktreePath,
+ definitionId: input.definitionId,
+ definition: input.definition,
+ });
+ return { definition };
+ }),
+
+ saveManualPostgresConnectionString: publicProcedure
+ .input(
+ z.object({
+ connectionId: z.string().min(1),
+ connectionString: z.string().min(1),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ await saveManualPostgresConnectionString(
+ input.connectionId,
+ input.connectionString,
+ );
+ return { ok: true };
+ }),
+
+ getManualPostgresConnectionString: publicProcedure
+ .input(
+ z.object({
+ connectionId: z.string().min(1),
+ }),
+ )
+ .query(async ({ input }) => {
+ const connectionString = await getManualPostgresConnectionString(
+ input.connectionId,
+ );
+ if (connectionString === null) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Manual Postgres connection string not found.",
+ });
+ }
+ return { connectionString };
+ }),
+
+ deleteManualPostgresConnectionString: publicProcedure
+ .input(
+ z.object({
+ connectionId: z.string().min(1),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ await deleteManualPostgresConnectionString(input.connectionId);
+ return { ok: true };
+ }),
+
+ inspectSqlite: publicProcedure
+ .input(
+ z.object({
+ databasePath: z.string().min(1),
+ }),
+ )
+ .query(async ({ input }) => {
+ ensureAbsoluteFilesystemPath(input.databasePath);
+ await ensureExistingFile(input.databasePath);
+
+ const db = openSqliteDatabase(input.databasePath);
+
+ try {
+ const tables = db
+ .prepare(
+ `
+ SELECT name, type
+ FROM sqlite_master
+ WHERE type IN ('table', 'view')
+ AND name NOT LIKE 'sqlite_%'
+ ORDER BY type, name
+ `,
+ )
+ .all() as Array<{
+ name: string;
+ type: "table" | "view";
+ }>;
+
+ return {
+ tables: tables.map((table) => ({
+ schema: null,
+ name: table.name,
+ type: table.type,
+ columns: db
+ .prepare(
+ `PRAGMA table_info(${quoteSqliteIdentifier(table.name)})`,
+ )
+ .all() as Array<{
+ cid: number;
+ name: string;
+ type: string;
+ notnull: 0 | 1;
+ dflt_value: string | null;
+ pk: 0 | 1;
+ }>,
+ })),
+ };
+ } finally {
+ db.close();
+ }
+ }),
+
+ inspectPostgres: publicProcedure
+ .input(
+ z.object({
+ connection: postgresConnectionSourceSchema,
+ }),
+ )
+ .query(async ({ input }) => {
+ const connectionString =
+ await resolvePostgresConnectionStringFromSource({
+ source: input.connection,
+ });
+ return await withPostgresClient(connectionString, async (client) => {
+ const result = await client.query<{
+ table_schema: string;
+ table_name: string;
+ table_type: string;
+ column_name: string;
+ data_type: string;
+ is_nullable: "YES" | "NO";
+ ordinal_position: number;
+ }>(`
+ SELECT
+ t.table_schema,
+ t.table_name,
+ t.table_type,
+ c.column_name,
+ c.data_type,
+ c.is_nullable,
+ c.ordinal_position
+ FROM information_schema.tables t
+ JOIN information_schema.columns c
+ ON t.table_schema = c.table_schema
+ AND t.table_name = c.table_name
+ WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
+ ORDER BY t.table_schema, t.table_name, c.ordinal_position
+ `);
+
+ const tables = new Map<
+ string,
+ {
+ schema: string;
+ name: string;
+ type: string;
+ columns: {
+ cid: number;
+ name: string;
+ type: string;
+ notnull: 0 | 1;
+ dflt_value: string | null;
+ pk: 0 | 1;
+ }[];
+ }
+ >();
+
+ for (const row of result.rows) {
+ const key = `${row.table_schema}.${row.table_name}`;
+ const current:
+ | {
+ schema: string;
+ name: string;
+ type: string;
+ columns: {
+ cid: number;
+ name: string;
+ type: string;
+ notnull: 0 | 1;
+ dflt_value: string | null;
+ pk: 0 | 1;
+ }[];
+ }
+ | undefined = tables.get(key);
+ const nextTable = current ?? {
+ schema: row.table_schema,
+ name: row.table_name,
+ type: row.table_type.toLowerCase(),
+ columns: [] as {
+ cid: number;
+ name: string;
+ type: string;
+ notnull: 0 | 1;
+ dflt_value: string | null;
+ pk: 0 | 1;
+ }[],
+ };
+ nextTable.columns.push({
+ cid: row.ordinal_position,
+ name: row.column_name,
+ type: row.data_type,
+ notnull: row.is_nullable === "NO" ? 1 : 0,
+ dflt_value: null,
+ pk: 0,
+ });
+ tables.set(key, nextTable);
+ }
+
+ return {
+ tables: Array.from(tables.values()),
+ };
+ });
+ }),
+
+ previewSqliteTable: publicProcedure
+ .input(
+ z.object({
+ databasePath: z.string().min(1),
+ tableName: z.string().min(1),
+ limit: z.number().int().positive().max(200).optional(),
+ offset: z.number().int().min(0).optional(),
+ }),
+ )
+ .query(async ({ input }) => {
+ try {
+ ensureAbsoluteFilesystemPath(input.databasePath);
+ await ensureExistingFile(input.databasePath);
+
+ const db = openSqliteDatabase(input.databasePath);
+ const limit = input.limit ?? 50;
+ const offset = input.offset ?? 0;
+ const startedAt = performance.now();
+ try {
+ const metadata = getSqliteTableMetadata(db, input.tableName);
+ const previewSelect = metadata.columns
+ .map((column) =>
+ buildSqlitePreviewExpression(column.name, column.type),
+ )
+ .join(", ");
+ const selectColumns = [
+ metadata.hasRowId
+ ? `rowid AS ${quoteSqliteIdentifier(SQLITE_ROW_ID_COLUMN)}`
+ : null,
+ buildSqlitePrimaryKeyPreviewExpression(
+ metadata.primaryKeyColumns,
+ ),
+ previewSelect,
+ ].filter(Boolean);
+ const statement = db.prepare(
+ `SELECT ${selectColumns.join(", ")} FROM ${quoteSqliteIdentifier(input.tableName)} LIMIT ? OFFSET ?`,
+ );
+ const previewRows = statement.all(limit + 1, offset) as Array<
+ Record
+ >;
+ const hasMore = previewRows.length > limit;
+ const rows = hasMore ? previewRows.slice(0, limit) : previewRows;
+
+ return {
+ columns: statement
+ .columns()
+ .map((column) => column.name)
+ .filter(
+ (column) =>
+ column !== SQLITE_ROW_ID_COLUMN &&
+ column !== SQLITE_PRIMARY_KEY_COLUMN,
+ ),
+ rows,
+ rowCount: rows.length,
+ totalRows: null,
+ hasMore,
+ offset,
+ limit,
+ elapsedMs: Math.round(performance.now() - startedAt),
+ };
+ } finally {
+ db.close();
+ }
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to preview SQLite table.",
+ });
+ }
+ }),
+
+ getSqliteRowDetail: publicProcedure
+ .input(
+ z.object({
+ databasePath: z.string().min(1),
+ tableName: z.string().min(1),
+ rowId: z.union([z.string(), z.number()]).optional(),
+ primaryKey: z.string().optional(),
+ }),
+ )
+ .query(async ({ input }) => {
+ ensureAbsoluteFilesystemPath(input.databasePath);
+ await ensureExistingFile(input.databasePath);
+
+ const db = openSqliteDatabase(input.databasePath);
+ try {
+ const metadata = getSqliteTableMetadata(db, input.tableName);
+ let whereClause = "";
+ const parameters: Array = [];
+
+ if (metadata.primaryKeyColumns.length > 0) {
+ if (!input.primaryKey) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Primary key payload is required for this SQLite table.",
+ });
+ }
+
+ let parsedPrimaryKey: Record;
+ try {
+ parsedPrimaryKey = JSON.parse(input.primaryKey) as Record<
+ string,
+ unknown
+ >;
+ } catch {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid SQLite primary key payload.",
+ });
+ }
+ whereClause = metadata.primaryKeyColumns
+ .map((column) => {
+ const value = parsedPrimaryKey[column.name];
+ if (value === null) {
+ return `${quoteSqliteIdentifier(column.name)} IS NULL`;
+ }
+ parameters.push((value ?? null) as string | number | null);
+ return `${quoteSqliteIdentifier(column.name)} = ?`;
+ })
+ .join(" AND ");
+ } else if (metadata.hasRowId) {
+ if (input.rowId === undefined) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "rowid is required for this SQLite table.",
+ });
+ }
+ whereClause = "rowid = ?";
+ parameters.push(input.rowId);
+ } else {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "This SQLite table has neither a rowid nor a primary key.",
+ });
+ }
+
+ const row = db
+ .prepare(
+ `SELECT * FROM ${quoteSqliteIdentifier(input.tableName)} WHERE ${whereClause} LIMIT 1`,
+ )
+ .get(...parameters) as Record | undefined;
+
+ if (!row) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Row not found.",
+ });
+ }
+
+ return { row };
+ } finally {
+ db.close();
+ }
+ }),
+
+ previewPostgresTable: publicProcedure
+ .input(
+ z.object({
+ connection: postgresConnectionSourceSchema,
+ schema: z.string().min(1),
+ tableName: z.string().min(1),
+ limit: z.number().int().positive().max(200).optional(),
+ offset: z.number().int().min(0).optional(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const limit = input.limit ?? 50;
+ const offset = input.offset ?? 0;
+ const startedAt = performance.now();
+ const connectionString =
+ await resolvePostgresConnectionStringFromSource({
+ source: input.connection,
+ });
+
+ return await withPostgresClient(connectionString, async (client) => {
+ const columnInfo = await client.query<{
+ column_name: string;
+ data_type: string;
+ udt_name: string;
+ ordinal_position: number;
+ }>(
+ `
+ SELECT column_name, data_type, udt_name, ordinal_position
+ FROM information_schema.columns
+ WHERE table_schema = $1 AND table_name = $2
+ ORDER BY ordinal_position
+ `,
+ [input.schema, input.tableName],
+ );
+ const qualifiedTableName = `${quotePostgresIdentifier(input.schema)}.${quotePostgresIdentifier(input.tableName)}`;
+ const previewSelect = columnInfo.rows
+ .map((column) =>
+ buildPostgresPreviewExpression({
+ columnName: column.column_name,
+ dataType: column.data_type,
+ udtName: column.udt_name,
+ }),
+ )
+ .join(", ");
+ const dataResult = await client.query(
+ `SELECT ctid::text AS ${quotePostgresIdentifier(POSTGRES_ROW_ID_COLUMN)}, ${previewSelect} FROM ${qualifiedTableName} LIMIT $1 OFFSET $2`,
+ [limit + 1, offset],
+ );
+ const hasMore = dataResult.rows.length > limit;
+ const rows = hasMore
+ ? dataResult.rows.slice(0, limit)
+ : dataResult.rows;
+
+ return {
+ columns: dataResult.fields
+ .map((field: { name: string }) => field.name)
+ .filter((column) => column !== POSTGRES_ROW_ID_COLUMN),
+ rows,
+ rowCount: rows.length,
+ totalRows: null,
+ hasMore,
+ offset,
+ limit,
+ elapsedMs: Math.round(performance.now() - startedAt),
+ };
+ });
+ }),
+
+ getPostgresRowDetail: publicProcedure
+ .input(
+ z.object({
+ connection: postgresConnectionSourceSchema,
+ schema: z.string().min(1),
+ tableName: z.string().min(1),
+ ctid: z.string().min(1),
+ }),
+ )
+ .query(async ({ input }) => {
+ const connectionString =
+ await resolvePostgresConnectionStringFromSource({
+ source: input.connection,
+ });
+ return await withPostgresClient(connectionString, async (client) => {
+ const qualifiedTableName = `${quotePostgresIdentifier(input.schema)}.${quotePostgresIdentifier(input.tableName)}`;
+ const result = await client.query(
+ `SELECT ctid::text AS ${quotePostgresIdentifier(POSTGRES_ROW_ID_COLUMN)}, * FROM ${qualifiedTableName} WHERE ctid = $1::tid LIMIT 1`,
+ [input.ctid],
+ );
+
+ const row = result.rows[0] as Record | undefined;
+ if (!row) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Row not found.",
+ });
+ }
+
+ return { row };
+ });
+ }),
+
+ executeSqlite: publicProcedure
+ .input(
+ z.object({
+ databasePath: z.string().min(1),
+ sql: z.string().min(1),
+ limit: z.number().int().positive().max(1000).optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ ensureAbsoluteFilesystemPath(input.databasePath);
+ await ensureExistingFile(input.databasePath);
+
+ const sql = input.sql.trim();
+ if (!sql) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "SQL is required.",
+ });
+ }
+
+ const db = openSqliteDatabase(input.databasePath);
+ const startedAt = performance.now();
+
+ try {
+ const statement = db.prepare(sql);
+ const limit = input.limit ?? 200;
+
+ if (!statement.reader) {
+ const result = statement.run();
+ return {
+ columns: [] as string[],
+ rows: [] as Array>,
+ rowCount: result.changes,
+ truncated: false,
+ elapsedMs: Math.round(performance.now() - startedAt),
+ command: "write",
+ lastInsertRowid:
+ typeof result.lastInsertRowid === "bigint"
+ ? result.lastInsertRowid.toString()
+ : result.lastInsertRowid,
+ };
+ }
+
+ const rows: Array> = [];
+ let truncated = false;
+ for (const row of statement.iterate() as Iterable<
+ Record
+ >) {
+ if (rows.length >= limit) {
+ truncated = true;
+ break;
+ }
+ rows.push(row);
+ }
+
+ return {
+ columns: statement.columns().map((column) => column.name),
+ rows,
+ rowCount: rows.length,
+ truncated,
+ elapsedMs: Math.round(performance.now() - startedAt),
+ command: "read",
+ };
+ } catch (error) {
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ error instanceof Error ? error.message : "Failed to execute SQL.",
+ });
+ } finally {
+ db.close();
+ }
+ }),
+
+ executePostgres: publicProcedure
+ .input(
+ z.object({
+ connection: postgresConnectionSourceSchema,
+ sql: z.string().min(1),
+ limit: z.number().int().positive().max(1000).optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const sql = input.sql.trim();
+ if (!sql) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "SQL is required.",
+ });
+ }
+
+ const startedAt = performance.now();
+ const connectionString =
+ await resolvePostgresConnectionStringFromSource({
+ source: input.connection,
+ });
+ return await withPostgresClient(connectionString, async (client) => {
+ const limit = input.limit ?? 200;
+ if (canApplyPostgresReadLimit(sql)) {
+ const limitedSql = `SELECT * FROM (${stripTrailingSemicolon(
+ sql,
+ )}) AS __superset_query LIMIT ${limit + 1}`;
+ const limitedResult = await client.query(limitedSql);
+ const truncated = limitedResult.rows.length > limit;
+ const rows = truncated
+ ? limitedResult.rows.slice(0, limit)
+ : limitedResult.rows;
+
+ return {
+ columns: limitedResult.fields.map(
+ (field: { name: string }) => field.name,
+ ),
+ rows,
+ rowCount: rows.length,
+ truncated,
+ elapsedMs: Math.round(performance.now() - startedAt),
+ command: "SELECT",
+ };
+ }
+
+ const result = await client.query(sql);
+
+ return {
+ columns: result.fields.map((field: { name: string }) => field.name),
+ rows: result.rows.slice(0, limit),
+ rowCount: result.rowCount ?? result.rows.length,
+ truncated: result.rows.length > limit,
+ elapsedMs: Math.round(performance.now() - startedAt),
+ command: result.command,
+ };
+ });
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/databases/workspace-config.ts b/apps/desktop/src/lib/trpc/routers/databases/workspace-config.ts
new file mode 100644
index 00000000000..888a31a9797
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/databases/workspace-config.ts
@@ -0,0 +1,604 @@
+import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { TRPCError } from "@trpc/server";
+
+// Simple per-file async mutex to prevent concurrent read/modify/write races.
+function createFileMutex() {
+ let queue = Promise.resolve();
+ return function withLock(fn: () => Promise): Promise {
+ const next = queue.then(fn, fn);
+ queue = next.then(
+ () => undefined,
+ () => undefined,
+ );
+ return next;
+ };
+}
+const withCredentialStoreLock = createFileMutex();
+const withManualConnectionStoreLock = createFileMutex();
+
+import {
+ SUPERSET_HOME_DIR,
+ SUPERSET_SENSITIVE_FILE_MODE,
+} from "main/lib/app-environment";
+import { z } from "zod";
+import { decrypt, encrypt } from "../auth/utils/crypto-storage";
+
+const WORKSPACE_DATABASES_CONFIG_FILE = path.join(
+ ".superset",
+ "databases.json",
+);
+const WORKSPACE_DATABASE_CREDENTIALS_FILE = path.join(
+ SUPERSET_HOME_DIR,
+ "workspace-database-credentials.enc",
+);
+
+const workspaceDatabaseBaseSchema = z.object({
+ id: z.string().min(1),
+ label: z.string().min(1),
+ group: z.string().trim().min(1).optional(),
+});
+
+const sqliteWorkspaceDatabaseSchema = workspaceDatabaseBaseSchema.extend({
+ dialect: z.literal("sqlite"),
+ path: z.string().min(1),
+});
+
+const postgresWorkspaceDatabaseSchema = workspaceDatabaseBaseSchema.extend({
+ dialect: z.literal("postgres"),
+ host: z.string().min(1),
+ port: z.number().int().positive().max(65535).optional(),
+ database: z.preprocess(
+ (value) =>
+ typeof value === "string" && value.trim().length === 0
+ ? undefined
+ : value,
+ z.string().min(1).default("postgres"),
+ ),
+ ssl: z.boolean().optional(),
+ username: z.string().min(1).optional(),
+});
+
+export const workspaceDatabaseDefinitionSchema = z.discriminatedUnion(
+ "dialect",
+ [sqliteWorkspaceDatabaseSchema, postgresWorkspaceDatabaseSchema],
+);
+
+const workspaceDatabaseConfigSchema = z.object({
+ databases: z.array(workspaceDatabaseDefinitionSchema).default([]),
+});
+
+export const postgresConnectionSourceSchema = z.discriminatedUnion("kind", [
+ z.object({
+ kind: z.literal("connectionString"),
+ connectionStringId: z.string().min(1),
+ }),
+ z.object({
+ kind: z.literal("workspaceConfig"),
+ workspacePath: z.string().min(1),
+ definitionId: z.string().min(1),
+ }),
+]);
+
+const workspaceDatabaseCredentialEntrySchema = z.object({
+ username: z.string().min(1),
+ password: z.string(),
+ updatedAt: z.number().int().nonnegative(),
+});
+
+const workspaceDatabaseCredentialStoreSchema = z.object({
+ entries: z
+ .record(z.string(), workspaceDatabaseCredentialEntrySchema)
+ .default({}),
+});
+
+export type WorkspaceDatabaseDefinition = z.infer<
+ typeof workspaceDatabaseDefinitionSchema
+>;
+export type WorkspaceConfiguredDatabaseDiscoveryItem =
+ | {
+ source: "config";
+ dialect: "sqlite";
+ definitionId: string;
+ label: string;
+ group?: string;
+ absolutePath: string;
+ relativePath: string;
+ }
+ | {
+ source: "config";
+ dialect: "postgres";
+ definitionId: string;
+ label: string;
+ group?: string;
+ host: string;
+ port: number;
+ database: string;
+ ssl: boolean;
+ usernameHint?: string;
+ relativePath: string;
+ hasSavedCredentials: boolean;
+ };
+
+function workspaceCredentialKey(
+ workspacePath: string,
+ definitionId: string,
+): string {
+ return `${workspacePath}::${definitionId}`;
+}
+
+function buildPostgresConnectionString(input: {
+ host: string;
+ port: number;
+ username: string;
+ password: string;
+ database: string;
+ ssl: boolean;
+}): string {
+ const auth =
+ input.password.trim().length > 0
+ ? `${encodeURIComponent(input.username)}:${encodeURIComponent(input.password)}`
+ : encodeURIComponent(input.username);
+ const query = input.ssl ? "?sslmode=require" : "";
+ const trimmedHost = input.host.trim();
+ const host =
+ trimmedHost.startsWith("[") && trimmedHost.endsWith("]")
+ ? trimmedHost
+ : trimmedHost.includes(":")
+ ? `[${trimmedHost}]`
+ : trimmedHost;
+ return `postgres://${auth}@${host}:${input.port}/${input.database}${query}`;
+}
+
+function getPostgresDatabaseName(
+ definition: Extract,
+): string {
+ return definition.database;
+}
+
+async function loadWorkspaceDatabaseCredentialStore(): Promise<
+ z.infer
+> {
+ try {
+ const decrypted = decrypt(
+ await readFile(WORKSPACE_DATABASE_CREDENTIALS_FILE),
+ );
+ return workspaceDatabaseCredentialStoreSchema.parse(JSON.parse(decrypted));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
+ return { entries: {} };
+ }
+ throw error;
+ }
+}
+
+async function saveWorkspaceDatabaseCredentialStore(
+ store: z.infer,
+): Promise {
+ await mkdir(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 });
+ await writeFile(
+ WORKSPACE_DATABASE_CREDENTIALS_FILE,
+ encrypt(JSON.stringify(store)),
+ { mode: SUPERSET_SENSITIVE_FILE_MODE },
+ );
+ await chmod(
+ WORKSPACE_DATABASE_CREDENTIALS_FILE,
+ SUPERSET_SENSITIVE_FILE_MODE,
+ ).catch(() => undefined);
+}
+
+export async function loadWorkspaceDatabaseDefinitions(
+ workspacePath: string,
+): Promise<{
+ configPath: string;
+ definitions: WorkspaceDatabaseDefinition[];
+}> {
+ const configPath = path.join(workspacePath, WORKSPACE_DATABASES_CONFIG_FILE);
+
+ try {
+ const raw = await readFile(configPath, "utf8");
+ const parsed = workspaceDatabaseConfigSchema.parse(JSON.parse(raw));
+ return {
+ configPath,
+ definitions: parsed.databases,
+ };
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
+ return { configPath, definitions: [] };
+ }
+
+ if (error instanceof z.ZodError) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Invalid workspace database config: ${error.issues[0]?.message ?? "Unknown schema error"}`,
+ });
+ }
+
+ if (error instanceof SyntaxError) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid JSON in .superset/databases.json",
+ });
+ }
+
+ throw error;
+ }
+}
+
+function toWorkspaceConfigSqlitePath(
+ workspacePath: string,
+ databasePath: string,
+): string {
+ const absoluteDatabasePath = path.resolve(workspacePath, databasePath);
+ const relativePath = path.relative(workspacePath, absoluteDatabasePath);
+
+ if (
+ relativePath.length > 0 &&
+ !relativePath.startsWith("..") &&
+ !path.isAbsolute(relativePath)
+ ) {
+ return relativePath;
+ }
+
+ return absoluteDatabasePath;
+}
+
+async function writeWorkspaceDatabaseDefinitions(input: {
+ configPath: string;
+ config: Record;
+}): Promise {
+ await mkdir(path.dirname(input.configPath), { recursive: true });
+ await writeFile(
+ input.configPath,
+ `${JSON.stringify(input.config, null, 2)}\n`,
+ "utf8",
+ );
+}
+
+export async function discoverWorkspaceConfiguredDatabases(
+ workspacePath: string,
+): Promise {
+ const { definitions } = await loadWorkspaceDatabaseDefinitions(workspacePath);
+ if (definitions.length === 0) {
+ return [];
+ }
+
+ const credentialStore = await loadWorkspaceDatabaseCredentialStore();
+
+ return definitions.map((definition) => {
+ if (definition.dialect === "sqlite") {
+ return {
+ source: "config",
+ dialect: "sqlite",
+ definitionId: definition.id,
+ label: definition.label,
+ group: definition.group,
+ absolutePath: path.resolve(workspacePath, definition.path),
+ relativePath: path.join(
+ WORKSPACE_DATABASES_CONFIG_FILE,
+ `#${definition.id}`,
+ ),
+ };
+ }
+
+ const key = workspaceCredentialKey(workspacePath, definition.id);
+ return {
+ source: "config",
+ dialect: "postgres",
+ definitionId: definition.id,
+ label: definition.label,
+ group: definition.group,
+ host: definition.host,
+ port: definition.port ?? 5432,
+ database: getPostgresDatabaseName(definition),
+ ssl: definition.ssl ?? false,
+ usernameHint:
+ definition.username ?? credentialStore.entries[key]?.username,
+ relativePath: path.join(
+ WORKSPACE_DATABASES_CONFIG_FILE,
+ `#${definition.id}`,
+ ),
+ hasSavedCredentials: Boolean(credentialStore.entries[key]),
+ };
+ });
+}
+
+export async function saveWorkspaceDatabaseCredentials(input: {
+ workspacePath: string;
+ definitionId: string;
+ username: string;
+ password: string;
+}): Promise {
+ await withCredentialStoreLock(async () => {
+ const store = await loadWorkspaceDatabaseCredentialStore();
+ store.entries[
+ workspaceCredentialKey(input.workspacePath, input.definitionId)
+ ] = {
+ username: input.username.trim(),
+ password: input.password,
+ updatedAt: Date.now(),
+ };
+ await saveWorkspaceDatabaseCredentialStore(store);
+ });
+}
+
+export async function updateWorkspaceDatabaseDefinition(input: {
+ workspacePath: string;
+ definitionId: string;
+ definition:
+ | {
+ dialect: "sqlite";
+ label: string;
+ group?: string;
+ databasePath: string;
+ }
+ | {
+ dialect: "postgres";
+ label: string;
+ group?: string;
+ host: string;
+ port: number;
+ database?: string;
+ ssl: boolean;
+ username?: string;
+ };
+}): Promise {
+ const { configPath, definitions } = await loadWorkspaceDatabaseDefinitions(
+ input.workspacePath,
+ );
+ const definitionIndex = definitions.findIndex(
+ (candidate) => candidate.id === input.definitionId,
+ );
+
+ if (definitionIndex === -1) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Workspace database definition not found.",
+ });
+ }
+
+ const currentDefinition = definitions[definitionIndex];
+ const nextDefinition = workspaceDatabaseDefinitionSchema.parse(
+ input.definition.dialect === "sqlite"
+ ? {
+ id: input.definitionId,
+ dialect: "sqlite",
+ label: input.definition.label,
+ group: input.definition.group,
+ path: toWorkspaceConfigSqlitePath(
+ input.workspacePath,
+ input.definition.databasePath,
+ ),
+ }
+ : {
+ id: input.definitionId,
+ dialect: "postgres",
+ label: input.definition.label,
+ group: input.definition.group,
+ host: input.definition.host,
+ port: input.definition.port,
+ database: input.definition.database,
+ ssl: input.definition.ssl,
+ username: input.definition.username,
+ },
+ );
+
+ const rawConfig = JSON.parse(await readFile(configPath, "utf8")) as {
+ databases?: unknown[];
+ [key: string]: unknown;
+ };
+ const rawDefinitions = Array.isArray(rawConfig.databases)
+ ? [...rawConfig.databases]
+ : [];
+ const currentRawDefinition =
+ typeof rawDefinitions[definitionIndex] === "object" &&
+ rawDefinitions[definitionIndex] !== null
+ ? (rawDefinitions[definitionIndex] as Record)
+ : {};
+
+ const nextRawDefinition: Record =
+ nextDefinition.dialect === "sqlite"
+ ? {
+ ...currentRawDefinition,
+ id: nextDefinition.id,
+ label: nextDefinition.label,
+ dialect: "sqlite",
+ path: nextDefinition.path,
+ }
+ : {
+ ...currentRawDefinition,
+ id: nextDefinition.id,
+ label: nextDefinition.label,
+ dialect: "postgres",
+ host: nextDefinition.host,
+ port: nextDefinition.port,
+ database: nextDefinition.database,
+ ssl: nextDefinition.ssl,
+ username: nextDefinition.username,
+ };
+
+ if (nextDefinition.group) {
+ nextRawDefinition.group = nextDefinition.group;
+ } else {
+ delete nextRawDefinition.group;
+ }
+
+ if (nextDefinition.dialect === "postgres") {
+ delete nextRawDefinition.path;
+ if (!nextDefinition.username) {
+ delete nextRawDefinition.username;
+ }
+ } else {
+ delete nextRawDefinition.host;
+ delete nextRawDefinition.port;
+ delete nextRawDefinition.database;
+ delete nextRawDefinition.ssl;
+ delete nextRawDefinition.username;
+ }
+
+ rawDefinitions[definitionIndex] = nextRawDefinition;
+ await writeWorkspaceDatabaseDefinitions({
+ configPath,
+ config: {
+ ...rawConfig,
+ databases: rawDefinitions,
+ },
+ });
+
+ if (
+ currentDefinition.dialect === "postgres" &&
+ nextDefinition.dialect === "postgres" &&
+ nextDefinition.username
+ ) {
+ await withCredentialStoreLock(async () => {
+ const store = await loadWorkspaceDatabaseCredentialStore();
+ const credentialKey = workspaceCredentialKey(
+ input.workspacePath,
+ input.definitionId,
+ );
+ const existingCredentials = store.entries[credentialKey];
+ if (existingCredentials) {
+ store.entries[credentialKey] = {
+ ...existingCredentials,
+ username: nextDefinition.username ?? existingCredentials.username,
+ updatedAt: Date.now(),
+ };
+ await saveWorkspaceDatabaseCredentialStore(store);
+ }
+ });
+ }
+
+ return nextDefinition;
+}
+
+const manualPostgresConnectionStoreSchema = z.object({
+ entries: z
+ .record(
+ z.string(),
+ z.object({
+ connectionString: z.string().min(1),
+ updatedAt: z.number().int().nonnegative(),
+ }),
+ )
+ .default({}),
+});
+
+const MANUAL_POSTGRES_CONNECTIONS_FILE = path.join(
+ SUPERSET_HOME_DIR,
+ "manual-postgres-connections.enc",
+);
+
+async function loadManualPostgresConnectionStore(): Promise<
+ z.infer
+> {
+ try {
+ const decrypted = decrypt(await readFile(MANUAL_POSTGRES_CONNECTIONS_FILE));
+ return manualPostgresConnectionStoreSchema.parse(JSON.parse(decrypted));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
+ return { entries: {} };
+ }
+ throw error;
+ }
+}
+
+async function saveManualPostgresConnectionStore(
+ store: z.infer,
+): Promise {
+ await mkdir(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 });
+ await writeFile(
+ MANUAL_POSTGRES_CONNECTIONS_FILE,
+ encrypt(JSON.stringify(store)),
+ { mode: SUPERSET_SENSITIVE_FILE_MODE },
+ );
+ await chmod(
+ MANUAL_POSTGRES_CONNECTIONS_FILE,
+ SUPERSET_SENSITIVE_FILE_MODE,
+ ).catch(() => undefined);
+}
+
+export async function saveManualPostgresConnectionString(
+ connectionId: string,
+ connectionString: string,
+): Promise {
+ await withManualConnectionStoreLock(async () => {
+ const store = await loadManualPostgresConnectionStore();
+ store.entries[connectionId] = {
+ connectionString,
+ updatedAt: Date.now(),
+ };
+ await saveManualPostgresConnectionStore(store);
+ });
+}
+
+export async function getManualPostgresConnectionString(
+ connectionId: string,
+): Promise {
+ const store = await loadManualPostgresConnectionStore();
+ return store.entries[connectionId]?.connectionString ?? null;
+}
+
+export async function deleteManualPostgresConnectionString(
+ connectionId: string,
+): Promise {
+ await withManualConnectionStoreLock(async () => {
+ const store = await loadManualPostgresConnectionStore();
+ delete store.entries[connectionId];
+ await saveManualPostgresConnectionStore(store);
+ });
+}
+
+export async function resolvePostgresConnectionStringFromSource(input: {
+ source: z.infer;
+}): Promise {
+ const source = input.source;
+ if (source.kind === "connectionString") {
+ const connectionString = await getManualPostgresConnectionString(
+ source.connectionStringId,
+ );
+ if (!connectionString) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Manual Postgres connection string not found.",
+ });
+ }
+ return connectionString;
+ }
+
+ const { definitions } = await loadWorkspaceDatabaseDefinitions(
+ source.workspacePath,
+ );
+ const definition = definitions.find(
+ (candidate) => candidate.id === source.definitionId,
+ );
+
+ if (!definition || definition.dialect !== "postgres") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Workspace database definition not found.",
+ });
+ }
+
+ const credentialStore = await loadWorkspaceDatabaseCredentialStore();
+ const credentials =
+ credentialStore.entries[
+ workspaceCredentialKey(source.workspacePath, source.definitionId)
+ ];
+
+ if (!credentials) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Credentials for this workspace database have not been saved yet.",
+ });
+ }
+
+ return buildPostgresConnectionString({
+ host: definition.host,
+ port: definition.port ?? 5432,
+ username: credentials.username,
+ password: credentials.password,
+ database: getPostgresDatabaseName(definition),
+ ssl: definition.ssl ?? false,
+ });
+}
diff --git a/apps/desktop/src/lib/trpc/routers/diagnostics/index.ts b/apps/desktop/src/lib/trpc/routers/diagnostics/index.ts
new file mode 100644
index 00000000000..e68297cdcf1
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/diagnostics/index.ts
@@ -0,0 +1,562 @@
+import path from "node:path";
+import { TRPCError } from "@trpc/server";
+import * as ts from "typescript";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+import { getWorkspace } from "../workspaces/utils/db-helpers";
+import { getWorkspacePath } from "../workspaces/utils/worktree";
+
+const MAX_PROBLEMS = 500;
+
+const openDocumentSchema = z.object({
+ relativePath: z.string(),
+ content: z.string().nullable(),
+});
+
+const typeScriptProblemSchema = z.object({
+ relativePath: z.string().nullable(),
+ line: z.number().nullable(),
+ column: z.number().nullable(),
+ endLine: z.number().nullable(),
+ endColumn: z.number().nullable(),
+ message: z.string(),
+ code: z.union([z.string(), z.number()]).nullable(),
+ severity: z.enum(["error", "warning", "info", "hint"]),
+ source: z.string(),
+});
+
+function resolveConfigPath(workspacePath: string): string | null {
+ const tsconfigPath = path.join(workspacePath, "tsconfig.json");
+ if (ts.sys.fileExists(tsconfigPath)) {
+ return tsconfigPath;
+ }
+
+ const jsconfigPath = path.join(workspacePath, "jsconfig.json");
+ if (ts.sys.fileExists(jsconfigPath)) {
+ return jsconfigPath;
+ }
+
+ return null;
+}
+
+function findNearestConfigPath(
+ workspacePath: string,
+ relativePath: string,
+): string | null {
+ let currentDirectory = path.resolve(
+ workspacePath,
+ path.dirname(relativePath),
+ );
+ const normalizedWorkspacePath = path.resolve(workspacePath);
+
+ while (true) {
+ const tsconfigPath = path.join(currentDirectory, "tsconfig.json");
+ if (ts.sys.fileExists(tsconfigPath)) {
+ return tsconfigPath;
+ }
+
+ const jsconfigPath = path.join(currentDirectory, "jsconfig.json");
+ if (ts.sys.fileExists(jsconfigPath)) {
+ return jsconfigPath;
+ }
+
+ if (currentDirectory === normalizedWorkspacePath) {
+ return null;
+ }
+
+ const parentDirectory = path.dirname(currentDirectory);
+ if (parentDirectory === currentDirectory) {
+ return null;
+ }
+
+ currentDirectory = parentDirectory;
+ }
+}
+
+function mapSeverity(
+ category: ts.DiagnosticCategory,
+): "error" | "warning" | "info" | "hint" {
+ switch (category) {
+ case ts.DiagnosticCategory.Error:
+ return "error";
+ case ts.DiagnosticCategory.Warning:
+ return "warning";
+ case ts.DiagnosticCategory.Suggestion:
+ return "hint";
+ default:
+ return "info";
+ }
+}
+
+function normalizeRelativePath(
+ workspacePath: string,
+ fileName: string,
+): string | null {
+ const relativePath = path.relative(workspacePath, fileName);
+ if (
+ !relativePath ||
+ relativePath.startsWith("..") ||
+ path.isAbsolute(relativePath)
+ ) {
+ return null;
+ }
+
+ return relativePath.split(path.sep).join("/");
+}
+
+function diagnosticSortValue(severity: string): number {
+ switch (severity) {
+ case "error":
+ return 0;
+ case "warning":
+ return 1;
+ case "info":
+ return 2;
+ default:
+ return 3;
+ }
+}
+
+function createOpenDocumentMap(
+ workspacePath: string,
+ openDocuments: Array<{ relativePath: string; content: string | null }>,
+): Map {
+ return openDocuments.reduce((map, document) => {
+ if (document.content === null) {
+ return map;
+ }
+
+ map.set(
+ path.resolve(workspacePath, document.relativePath),
+ document.content,
+ );
+ return map;
+ }, new Map());
+}
+
+function createCompilerHostWithOpenDocuments(
+ options: ts.CompilerOptions,
+ openDocumentMap: Map,
+): ts.CompilerHost {
+ const compilerHost = ts.createCompilerHost(options, true);
+ const originalReadFile = compilerHost.readFile.bind(compilerHost);
+ const originalFileExists = compilerHost.fileExists.bind(compilerHost);
+ const originalGetSourceFile = compilerHost.getSourceFile.bind(compilerHost);
+
+ compilerHost.readFile = (fileName) => {
+ const override = openDocumentMap.get(path.resolve(fileName));
+ if (override !== undefined) {
+ return override;
+ }
+
+ return originalReadFile(fileName);
+ };
+
+ compilerHost.fileExists = (fileName) => {
+ if (openDocumentMap.has(path.resolve(fileName))) {
+ return true;
+ }
+
+ return originalFileExists(fileName);
+ };
+
+ compilerHost.getSourceFile = (
+ fileName,
+ languageVersionOrOptions,
+ onError,
+ shouldCreateNewSourceFile,
+ ) => {
+ const override = openDocumentMap.get(path.resolve(fileName));
+ if (override !== undefined) {
+ return ts.createSourceFile(
+ fileName,
+ override,
+ languageVersionOrOptions,
+ true,
+ );
+ }
+
+ return originalGetSourceFile(
+ fileName,
+ languageVersionOrOptions,
+ onError,
+ shouldCreateNewSourceFile,
+ );
+ };
+
+ return compilerHost;
+}
+
+function getStandaloneCompilerOptions(filePath: string): ts.CompilerOptions {
+ const extension = path.extname(filePath).toLowerCase();
+ return {
+ noEmit: true,
+ allowJs: [".js", ".jsx", ".mjs", ".cjs"].includes(extension),
+ checkJs: [".js", ".jsx", ".mjs", ".cjs"].includes(extension),
+ jsx: [".jsx", ".tsx"].includes(extension) ? ts.JsxEmit.Preserve : undefined,
+ target: ts.ScriptTarget.ESNext,
+ module: ts.ModuleKind.ESNext,
+ skipLibCheck: true,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ };
+}
+
+function createProblemKey(problem: {
+ relativePath: string | null;
+ line: number | null;
+ column: number | null;
+ message: string;
+ code: string | number | null;
+ severity: string;
+ source: string;
+}): string {
+ return [
+ problem.relativePath ?? "workspace",
+ problem.line ?? 0,
+ problem.column ?? 0,
+ problem.code ?? "no-code",
+ problem.severity,
+ problem.source,
+ problem.message,
+ ].join("::");
+}
+
+function mapDiagnosticsToProblems(
+ diagnostics: readonly ts.Diagnostic[],
+ workspacePath: string,
+) {
+ return diagnostics
+ .map((diagnostic) => {
+ const message = ts.flattenDiagnosticMessageText(
+ diagnostic.messageText,
+ "\n",
+ );
+ const severity = mapSeverity(diagnostic.category);
+ const relativePath = diagnostic.file?.fileName
+ ? normalizeRelativePath(workspacePath, diagnostic.file.fileName)
+ : null;
+
+ if (diagnostic.file?.fileName && relativePath === null) {
+ return null;
+ }
+
+ const start =
+ diagnostic.file && typeof diagnostic.start === "number"
+ ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
+ : null;
+ const end =
+ diagnostic.file &&
+ typeof diagnostic.start === "number" &&
+ typeof diagnostic.length === "number"
+ ? diagnostic.file.getLineAndCharacterOfPosition(
+ diagnostic.start + diagnostic.length,
+ )
+ : null;
+
+ return {
+ relativePath,
+ line: start ? start.line + 1 : null,
+ column: start ? start.character + 1 : null,
+ endLine: end ? end.line + 1 : null,
+ endColumn: end ? end.character + 1 : null,
+ message,
+ code: diagnostic.code ?? null,
+ severity,
+ source: "typescript",
+ };
+ })
+ .filter(
+ (problem): problem is NonNullable => problem !== null,
+ );
+}
+
+function filterProblemsForOpenDocuments(
+ problems: Array>,
+ openDocuments: Array<{ relativePath: string; content: string | null }>,
+) {
+ if (openDocuments.length === 0) {
+ return problems;
+ }
+
+ const openDocumentPaths = new Set(
+ openDocuments.map((document) => document.relativePath),
+ );
+
+ return problems.filter((problem) => {
+ if (problem.relativePath === null) {
+ return false;
+ }
+
+ return openDocumentPaths.has(problem.relativePath);
+ });
+}
+
+export const createDiagnosticsRouter = () => {
+ return router({
+ getTypeScriptProblems: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ openDocuments: z.array(openDocumentSchema).default([]),
+ }),
+ )
+ .output(
+ z.object({
+ status: z.enum(["ready", "no-config"]),
+ workspacePath: z.string(),
+ configPath: z.string().nullable(),
+ problems: z.array(typeScriptProblemSchema),
+ totalCount: z.number(),
+ truncated: z.boolean(),
+ summary: z.object({
+ errorCount: z.number(),
+ warningCount: z.number(),
+ infoCount: z.number(),
+ hintCount: z.number(),
+ }),
+ }),
+ )
+ .query(({ input }) => {
+ const workspace = getWorkspace(input.workspaceId);
+ if (!workspace) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Workspace ${input.workspaceId} not found`,
+ });
+ }
+
+ const workspacePath = getWorkspacePath(workspace);
+ if (!workspacePath) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: `Workspace ${input.workspaceId} has no filesystem path`,
+ });
+ }
+
+ const rootConfigPath = resolveConfigPath(workspacePath);
+ const configPaths = new Set();
+ const standaloneFiles: string[] = [];
+ const openDocumentMap = createOpenDocumentMap(
+ workspacePath,
+ input.openDocuments,
+ );
+
+ if (input.openDocuments.length > 0) {
+ for (const document of input.openDocuments) {
+ const configPath = findNearestConfigPath(
+ workspacePath,
+ document.relativePath,
+ );
+ if (configPath) {
+ configPaths.add(configPath);
+ } else {
+ standaloneFiles.push(
+ path.resolve(workspacePath, document.relativePath),
+ );
+ }
+ }
+ } else if (rootConfigPath) {
+ configPaths.add(rootConfigPath);
+ }
+
+ if (configPaths.size === 0 && standaloneFiles.length === 0) {
+ console.log("[diagnostics] no config found", {
+ workspaceId: input.workspaceId,
+ workspacePath,
+ openDocuments: input.openDocuments.map(
+ (document) => document.relativePath,
+ ),
+ });
+ return {
+ status: "no-config" as const,
+ workspacePath,
+ configPath: null,
+ problems: [],
+ totalCount: 0,
+ truncated: false,
+ summary: {
+ errorCount: 0,
+ warningCount: 0,
+ infoCount: 0,
+ hintCount: 0,
+ },
+ };
+ }
+
+ const collectedProblems = new Map<
+ string,
+ z.infer
+ >();
+ const configPathList = Array.from(configPaths);
+
+ console.log("[diagnostics] target documents", {
+ workspaceId: input.workspaceId,
+ workspacePath,
+ openDocuments: input.openDocuments.map((document) => ({
+ relativePath: document.relativePath,
+ hasOverride: document.content !== null,
+ })),
+ configPaths: configPathList,
+ standaloneFiles: standaloneFiles.map((filePath) =>
+ normalizeRelativePath(workspacePath, filePath),
+ ),
+ });
+
+ for (const configPath of configPathList) {
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
+ if (configFile.error) {
+ const problem = {
+ relativePath: normalizeRelativePath(workspacePath, configPath),
+ line: null,
+ column: null,
+ endLine: null,
+ endColumn: null,
+ message: ts.flattenDiagnosticMessageText(
+ configFile.error.messageText,
+ "\n",
+ ),
+ code: configFile.error.code,
+ severity: mapSeverity(configFile.error.category),
+ source: "typescript",
+ };
+ collectedProblems.set(createProblemKey(problem), problem);
+ continue;
+ }
+
+ const parsedConfig = ts.parseJsonConfigFileContent(
+ configFile.config,
+ ts.sys,
+ path.dirname(configPath),
+ { noEmit: true },
+ configPath,
+ );
+ const configOpenFiles = input.openDocuments
+ .filter(
+ (document) =>
+ findNearestConfigPath(workspacePath, document.relativePath) ===
+ configPath,
+ )
+ .map((document) =>
+ path.resolve(workspacePath, document.relativePath),
+ );
+ const rootNames = Array.from(
+ new Set([...parsedConfig.fileNames, ...configOpenFiles]),
+ );
+ console.log("[diagnostics] parsed config", {
+ workspaceId: input.workspaceId,
+ workspacePath,
+ configPath,
+ openDocumentCount: input.openDocuments.length,
+ rootFileCount: rootNames.length,
+ sampleRootFiles: rootNames
+ .slice(0, 20)
+ .map((fileName) =>
+ normalizeRelativePath(workspacePath, fileName),
+ ),
+ });
+
+ const compilerHost = createCompilerHostWithOpenDocuments(
+ parsedConfig.options,
+ openDocumentMap,
+ );
+ const program = ts.createProgram({
+ rootNames,
+ options: parsedConfig.options,
+ projectReferences: parsedConfig.projectReferences,
+ host: compilerHost,
+ });
+ const diagnostics = [
+ ...parsedConfig.errors,
+ ...ts.getPreEmitDiagnostics(program),
+ ];
+ for (const problem of mapDiagnosticsToProblems(
+ diagnostics,
+ workspacePath,
+ )) {
+ collectedProblems.set(createProblemKey(problem), problem);
+ }
+ }
+
+ for (const standaloneFilePath of standaloneFiles) {
+ const compilerOptions =
+ getStandaloneCompilerOptions(standaloneFilePath);
+ const compilerHost = createCompilerHostWithOpenDocuments(
+ compilerOptions,
+ openDocumentMap,
+ );
+ const program = ts.createProgram({
+ rootNames: [standaloneFilePath],
+ options: compilerOptions,
+ host: compilerHost,
+ });
+ for (const problem of mapDiagnosticsToProblems(
+ ts.getPreEmitDiagnostics(program),
+ workspacePath,
+ )) {
+ collectedProblems.set(createProblemKey(problem), problem);
+ }
+ }
+
+ const mappedProblems = filterProblemsForOpenDocuments(
+ Array.from(collectedProblems.values()),
+ input.openDocuments,
+ ).sort((left, right) => {
+ const severityDiff =
+ diagnosticSortValue(left.severity) -
+ diagnosticSortValue(right.severity);
+ if (severityDiff !== 0) {
+ return severityDiff;
+ }
+
+ const pathDiff = (left.relativePath ?? "").localeCompare(
+ right.relativePath ?? "",
+ );
+ if (pathDiff !== 0) {
+ return pathDiff;
+ }
+ return (left.line ?? 0) - (right.line ?? 0);
+ });
+
+ const summary = mappedProblems.reduce(
+ (acc, problem) => {
+ if (problem.severity === "error") acc.errorCount += 1;
+ if (problem.severity === "warning") acc.warningCount += 1;
+ if (problem.severity === "info") acc.infoCount += 1;
+ if (problem.severity === "hint") acc.hintCount += 1;
+ return acc;
+ },
+ {
+ errorCount: 0,
+ warningCount: 0,
+ infoCount: 0,
+ hintCount: 0,
+ },
+ );
+
+ console.log("[diagnostics] result", {
+ workspaceId: input.workspaceId,
+ configPaths: configPathList,
+ totalCount: mappedProblems.length,
+ problemFiles: Array.from(
+ new Set(
+ mappedProblems.map(
+ (problem) => problem.relativePath ?? "Workspace",
+ ),
+ ),
+ ),
+ });
+
+ return {
+ status: "ready" as const,
+ workspacePath,
+ configPath: configPathList.length === 1 ? configPathList[0] : null,
+ problems: mappedProblems.slice(0, MAX_PROBLEMS),
+ totalCount: mappedProblems.length,
+ truncated: mappedProblems.length > MAX_PROBLEMS,
+ summary,
+ };
+ }),
+ });
+};
+
+export type DiagnosticsRouter = ReturnType;
diff --git a/apps/desktop/src/lib/trpc/routers/docker/index.ts b/apps/desktop/src/lib/trpc/routers/docker/index.ts
new file mode 100644
index 00000000000..143e623c90c
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/docker/index.ts
@@ -0,0 +1,677 @@
+import {
+ type ExecFileOptionsWithStringEncoding,
+ execFile,
+} from "node:child_process";
+import type { Dirent } from "node:fs";
+import { readdir } from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import { workspaces } from "@superset/local-db";
+import { TRPCError } from "@trpc/server";
+import { eq } from "drizzle-orm";
+import { localDb } from "main/lib/local-db";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+import { getProcessEnvWithShellPath } from "../workspaces/utils/shell-env";
+import { getWorkspacePath } from "../workspaces/utils/worktree";
+
+const execFileAsync = promisify(execFile);
+
+const COMPOSE_FILE_NAMES = new Set([
+ "docker-compose.yml",
+ "docker-compose.yaml",
+ "compose.yml",
+ "compose.yaml",
+]);
+
+const DOCKERFILE_EXACT_NAMES = new Set(["Dockerfile", "Containerfile"]);
+
+function isDockerfileName(name: string): boolean {
+ if (DOCKERFILE_EXACT_NAMES.has(name)) {
+ return true;
+ }
+ // Dockerfile.dev, Dockerfile.prod, etc.
+ if (name.startsWith("Dockerfile.") || name.startsWith("Containerfile.")) {
+ return true;
+ }
+ // foo.dockerfile
+ if (name.endsWith(".dockerfile")) {
+ return true;
+ }
+ return false;
+}
+
+const IGNORED_DIRECTORIES = new Set([
+ ".git",
+ ".next",
+ ".superset",
+ ".turbo",
+ "build",
+ "coverage",
+ "dist",
+ "node_modules",
+ "out",
+ "target",
+]);
+
+const SAFE_CONTAINER_ID = z
+ .string()
+ .min(1)
+ .max(256)
+ .regex(/^[A-Za-z0-9_.-]+$/u, "Invalid container identifier");
+
+const composeActionInput = z.object({
+ workspaceId: z.string(),
+ composeFilePath: z.string().min(1),
+});
+
+const containerActionInput = z.object({
+ workspaceId: z.string(),
+ containerId: SAFE_CONTAINER_ID,
+});
+
+interface ComposeFileSummary {
+ absolutePath: string;
+ directoryPath: string;
+ projectName: string;
+ relativePath: string;
+}
+
+interface DockerfileSummary {
+ absolutePath: string;
+ directoryPath: string;
+ name: string;
+ relativePath: string;
+}
+
+interface DockerPsContainerRow {
+ Command?: string;
+ ID?: string;
+ Image?: string;
+ Labels?: string;
+ Names?: string;
+ Ports?: string;
+ State?: string;
+ Status?: string;
+}
+
+interface DockerContainerSummary {
+ command: string;
+ composeFilePaths: string[];
+ id: string;
+ image: string;
+ name: string;
+ ports: string;
+ service: string | null;
+ state: string;
+ status: string;
+}
+
+function normalizeExecError(error: unknown): never {
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "code" in error &&
+ error.code === "ENOENT"
+ ) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Docker CLI が見つかりません。Docker Desktop または docker CLI をインストールしてください。",
+ });
+ }
+
+ const stderr =
+ typeof error === "object" &&
+ error !== null &&
+ "stderr" in error &&
+ typeof error.stderr === "string"
+ ? error.stderr.trim()
+ : "";
+
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ stderr.length > 0
+ ? stderr
+ : error instanceof Error
+ ? error.message
+ : "Docker command failed",
+ });
+}
+
+async function execDocker(
+ args: string[],
+ options?: Omit,
+): Promise {
+ const env = await getProcessEnvWithShellPath(
+ options?.env ? { ...process.env, ...options.env } : process.env,
+ );
+
+ const { stdout } = await execFileAsync("docker", args, {
+ ...options,
+ encoding: "utf8",
+ env,
+ maxBuffer: 8 * 1024 * 1024,
+ });
+
+ return stdout;
+}
+
+function parseLabelString(labelString: string): Record {
+ if (!labelString.trim()) {
+ return {};
+ }
+
+ const labels: Record = {};
+ for (const part of labelString.split(",")) {
+ const separatorIndex = part.indexOf("=");
+ if (separatorIndex <= 0) {
+ continue;
+ }
+
+ const key = part.slice(0, separatorIndex).trim();
+ const value = part.slice(separatorIndex + 1).trim();
+ if (key.length > 0) {
+ labels[key] = value;
+ }
+ }
+
+ return labels;
+}
+
+function parseDockerPsJsonLines(stdout: string): DockerPsContainerRow[] {
+ return stdout
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line) as DockerPsContainerRow);
+}
+
+function mapContainerSummary(
+ row: DockerPsContainerRow,
+): DockerContainerSummary {
+ const labels = parseLabelString(row.Labels ?? "");
+ const composeFilePaths = (
+ labels["com.docker.compose.project.config_files"] ?? ""
+ )
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+
+ return {
+ command: row.Command ?? "",
+ composeFilePaths,
+ id: row.ID ?? "",
+ image: row.Image ?? "",
+ name: row.Names ?? "",
+ ports: row.Ports ?? "",
+ service: labels["com.docker.compose.service"] ?? null,
+ state: row.State ?? "unknown",
+ status: row.Status ?? "",
+ };
+}
+
+function isIgnoredDirectory(name: string): boolean {
+ return IGNORED_DIRECTORIES.has(name);
+}
+
+async function findComposeFiles(
+ rootPath: string,
+): Promise {
+ const queue: string[] = [rootPath];
+ const composeFiles: ComposeFileSummary[] = [];
+
+ while (queue.length > 0) {
+ const currentDir = queue.shift();
+ if (!currentDir) {
+ continue;
+ }
+
+ let entries: Dirent[];
+ try {
+ entries = await readdir(currentDir, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+
+ for (const entry of entries) {
+ if (entry.isSymbolicLink()) {
+ continue;
+ }
+
+ const absolutePath = path.join(currentDir, entry.name);
+ if (entry.isDirectory()) {
+ if (isIgnoredDirectory(entry.name)) {
+ continue;
+ }
+ queue.push(absolutePath);
+ continue;
+ }
+
+ if (!entry.isFile() || !COMPOSE_FILE_NAMES.has(entry.name)) {
+ continue;
+ }
+
+ const directoryPath = path.dirname(absolutePath);
+ const relativePath = path.relative(rootPath, absolutePath) || entry.name;
+
+ composeFiles.push({
+ absolutePath,
+ directoryPath,
+ projectName: path.basename(directoryPath),
+ relativePath,
+ });
+ }
+ }
+
+ return composeFiles.sort((left, right) =>
+ left.relativePath.localeCompare(right.relativePath),
+ );
+}
+
+async function findDockerfiles(rootPath: string): Promise {
+ const queue: string[] = [rootPath];
+ const dockerfiles: DockerfileSummary[] = [];
+
+ while (queue.length > 0) {
+ const currentDir = queue.shift();
+ if (!currentDir) {
+ continue;
+ }
+
+ let entries: Dirent[];
+ try {
+ entries = await readdir(currentDir, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+
+ for (const entry of entries) {
+ if (entry.isSymbolicLink()) {
+ continue;
+ }
+
+ const absolutePath = path.join(currentDir, entry.name);
+ if (entry.isDirectory()) {
+ if (isIgnoredDirectory(entry.name)) {
+ continue;
+ }
+ queue.push(absolutePath);
+ continue;
+ }
+
+ if (!entry.isFile() || !isDockerfileName(entry.name)) {
+ continue;
+ }
+
+ const directoryPath = path.dirname(absolutePath);
+ const relativePath = path.relative(rootPath, absolutePath) || entry.name;
+
+ dockerfiles.push({
+ absolutePath,
+ directoryPath,
+ name: entry.name,
+ relativePath,
+ });
+ }
+ }
+
+ return dockerfiles.sort((left, right) =>
+ left.relativePath.localeCompare(right.relativePath),
+ );
+}
+
+function getWorkspaceRootPath(workspaceId: string): string {
+ const workspace = localDb
+ .select()
+ .from(workspaces)
+ .where(eq(workspaces.id, workspaceId))
+ .get();
+
+ if (!workspace) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Workspace ${workspaceId} not found`,
+ });
+ }
+
+ const workspaceRoot = getWorkspacePath(workspace);
+ if (!workspaceRoot) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Workspace path is unavailable",
+ });
+ }
+
+ return workspaceRoot;
+}
+
+async function resolveComposeFileForWorkspace(
+ workspaceId: string,
+ composeFilePath: string,
+): Promise {
+ const workspaceRoot = getWorkspaceRootPath(workspaceId);
+ const composeFiles = await findComposeFiles(workspaceRoot);
+ const composeFile = composeFiles.find(
+ (entry) => entry.absolutePath === composeFilePath,
+ );
+
+ if (!composeFile) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Selected compose file does not belong to this workspace",
+ });
+ }
+
+ return composeFile;
+}
+
+async function assertContainerBelongsToWorkspace(
+ workspaceId: string,
+ containerId: string,
+): Promise {
+ const workspaceRoot = getWorkspaceRootPath(workspaceId);
+ const composeFiles = await findComposeFiles(workspaceRoot);
+ const composeFilePaths = new Set(
+ composeFiles.map((composeFile) => composeFile.absolutePath),
+ );
+
+ const stdout = await execDocker(["ps", "-a", "--format", "json"], {
+ cwd: workspaceRoot,
+ });
+ const container = parseDockerPsJsonLines(stdout)
+ .map(mapContainerSummary)
+ .find((entry) => entry.id === containerId);
+
+ if (
+ !container ||
+ !container.composeFilePaths.some((composeFilePath) =>
+ composeFilePaths.has(composeFilePath),
+ )
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Selected container does not belong to this workspace",
+ });
+ }
+}
+
+export const createDockerRouter = () => {
+ return router({
+ getComposeFiles: publicProcedure
+ .input(z.object({ workspaceId: z.string() }))
+ .query(async ({ input }) => {
+ const workspaceRoot = getWorkspaceRootPath(input.workspaceId);
+ const [composeFiles, dockerfiles] = await Promise.all([
+ findComposeFiles(workspaceRoot),
+ findDockerfiles(workspaceRoot),
+ ]);
+ return {
+ workspaceRoot,
+ composeFiles,
+ dockerfiles,
+ };
+ }),
+
+ list: publicProcedure
+ .input(z.object({ workspaceId: z.string() }))
+ .query(async ({ input }) => {
+ const workspaceRoot = getWorkspaceRootPath(input.workspaceId);
+ const [composeFiles, dockerfiles] = await Promise.all([
+ findComposeFiles(workspaceRoot),
+ findDockerfiles(workspaceRoot),
+ ]);
+
+ if (composeFiles.length === 0 && dockerfiles.length === 0) {
+ return {
+ composeFiles: [],
+ dockerfiles: [],
+ dockerAvailable: true,
+ dockerError: null,
+ workspaceRoot,
+ };
+ }
+
+ let containers: DockerContainerSummary[] = [];
+ let dockerAvailable = true;
+ let dockerError: string | null = null;
+
+ try {
+ const stdout = await execDocker(["ps", "-a", "--format", "json"]);
+ containers = parseDockerPsJsonLines(stdout).map(mapContainerSummary);
+ } catch (error) {
+ dockerAvailable = false;
+ dockerError =
+ typeof error === "object" &&
+ error !== null &&
+ "stderr" in error &&
+ typeof error.stderr === "string" &&
+ error.stderr.trim().length > 0
+ ? error.stderr.trim()
+ : error instanceof Error
+ ? error.message
+ : "Failed to read Docker containers";
+ }
+
+ return {
+ composeFiles: composeFiles.map((composeFile) => {
+ const matchingContainers = containers
+ .filter((container) =>
+ container.composeFilePaths.includes(composeFile.absolutePath),
+ )
+ .sort((left, right) => {
+ const leftRunning = left.state === "running" ? 0 : 1;
+ const rightRunning = right.state === "running" ? 0 : 1;
+ if (leftRunning !== rightRunning)
+ return leftRunning - rightRunning;
+ const leftKey = `${left.service ?? ""}:${left.name}`;
+ const rightKey = `${right.service ?? ""}:${right.name}`;
+ return leftKey.localeCompare(rightKey);
+ });
+
+ return {
+ ...composeFile,
+ containers: matchingContainers,
+ runningContainers: matchingContainers.filter(
+ (container) => container.state === "running",
+ ).length,
+ totalContainers: matchingContainers.length,
+ };
+ }),
+ dockerfiles,
+ dockerAvailable,
+ dockerError,
+ workspaceRoot,
+ };
+ }),
+
+ startProject: publicProcedure
+ .input(
+ composeActionInput.extend({
+ rebuild: z.boolean().optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const composeFile = await resolveComposeFileForWorkspace(
+ input.workspaceId,
+ input.composeFilePath,
+ );
+
+ try {
+ const args = ["compose", "-f", composeFile.absolutePath, "up", "-d"];
+ if (input.rebuild) {
+ args.push("--build", "--force-recreate");
+ }
+ await execDocker(args, { cwd: composeFile.directoryPath });
+ return { success: true };
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+
+ stopProject: publicProcedure
+ .input(composeActionInput)
+ .mutation(async ({ input }) => {
+ const composeFile = await resolveComposeFileForWorkspace(
+ input.workspaceId,
+ input.composeFilePath,
+ );
+
+ try {
+ await execDocker(
+ ["compose", "-f", composeFile.absolutePath, "stop"],
+ {
+ cwd: composeFile.directoryPath,
+ },
+ );
+ return { success: true };
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+
+ removeProject: publicProcedure
+ .input(composeActionInput)
+ .mutation(async ({ input }) => {
+ const composeFile = await resolveComposeFileForWorkspace(
+ input.workspaceId,
+ input.composeFilePath,
+ );
+
+ try {
+ await execDocker(
+ ["compose", "-f", composeFile.absolutePath, "down"],
+ {
+ cwd: composeFile.directoryPath,
+ },
+ );
+ return { success: true };
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+
+ startContainer: publicProcedure
+ .input(containerActionInput)
+ .mutation(async ({ input }) => {
+ try {
+ await assertContainerBelongsToWorkspace(
+ input.workspaceId,
+ input.containerId,
+ );
+ await execDocker(["container", "start", input.containerId], {
+ cwd: getWorkspaceRootPath(input.workspaceId),
+ });
+ return { success: true };
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+
+ stopContainer: publicProcedure
+ .input(containerActionInput)
+ .mutation(async ({ input }) => {
+ try {
+ await assertContainerBelongsToWorkspace(
+ input.workspaceId,
+ input.containerId,
+ );
+ await execDocker(["container", "stop", input.containerId], {
+ cwd: getWorkspaceRootPath(input.workspaceId),
+ });
+ return { success: true };
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+
+ restartContainer: publicProcedure
+ .input(containerActionInput)
+ .mutation(async ({ input }) => {
+ try {
+ await assertContainerBelongsToWorkspace(
+ input.workspaceId,
+ input.containerId,
+ );
+ await execDocker(["container", "restart", input.containerId], {
+ cwd: getWorkspaceRootPath(input.workspaceId),
+ });
+ return { success: true };
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+
+ inspectContainer: publicProcedure
+ .input(containerActionInput)
+ .query(async ({ input }) => {
+ try {
+ await assertContainerBelongsToWorkspace(
+ input.workspaceId,
+ input.containerId,
+ );
+ const stdout = await execDocker(
+ ["container", "inspect", "--format", "json", input.containerId],
+ { cwd: getWorkspaceRootPath(input.workspaceId) },
+ );
+ return JSON.parse(stdout) as unknown;
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+
+ buildDockerfile: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ dockerfilePath: z.string().min(1),
+ tag: z.string().min(1),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const workspaceRoot = getWorkspaceRootPath(input.workspaceId);
+ const dockerfiles = await findDockerfiles(workspaceRoot);
+ const dockerfile = dockerfiles.find(
+ (entry) => entry.absolutePath === input.dockerfilePath,
+ );
+
+ if (!dockerfile) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Selected Dockerfile does not belong to this workspace",
+ });
+ }
+
+ try {
+ await execDocker(
+ ["build", "-f", dockerfile.absolutePath, "-t", input.tag, "."],
+ { cwd: dockerfile.directoryPath },
+ );
+ return { success: true };
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+
+ removeDockerImage: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ imageTag: z.string().min(1),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ getWorkspaceRootPath(input.workspaceId);
+
+ try {
+ await execDocker(["rmi", input.imageTag]);
+ return { success: true };
+ } catch (error) {
+ normalizeExecError(error);
+ }
+ }),
+ });
+};
+
+export type DockerRouter = ReturnType;
diff --git a/apps/desktop/src/lib/trpc/routers/extensions/index.ts b/apps/desktop/src/lib/trpc/routers/extensions/index.ts
new file mode 100644
index 00000000000..8eb190e6a38
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/extensions/index.ts
@@ -0,0 +1,73 @@
+import type { BrowserWindow } from "electron";
+import {
+ getExtensionsWithToolbarInfo,
+ installExtension,
+ listExtensions,
+ toggleExtension,
+ uninstallExtension,
+} from "main/lib/extensions/extension-manager";
+import { extensionPopupManager } from "main/lib/extensions/extension-popup-manager";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+
+export const createExtensionsRouter = (
+ getWindow: () => BrowserWindow | null,
+) => {
+ return router({
+ list: publicProcedure.query(async () => {
+ return listExtensions();
+ }),
+
+ install: publicProcedure
+ .input(z.object({ input: z.string() }))
+ .mutation(async ({ input }) => {
+ return installExtension(input.input);
+ }),
+
+ uninstall: publicProcedure
+ .input(z.object({ extensionId: z.string() }))
+ .mutation(async ({ input }) => {
+ await uninstallExtension(input.extensionId);
+ }),
+
+ toggle: publicProcedure
+ .input(z.object({ extensionId: z.string(), enabled: z.boolean() }))
+ .mutation(async ({ input }) => {
+ return toggleExtension(input.extensionId, input.enabled);
+ }),
+
+ listToolbarExtensions: publicProcedure.query(async () => {
+ return getExtensionsWithToolbarInfo();
+ }),
+
+ openPopup: publicProcedure
+ .input(
+ z.object({
+ extensionId: z.string(),
+ popupPath: z.string(),
+ anchorRect: z.object({
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+ }),
+ }),
+ )
+ .mutation(({ input }) => {
+ const window = getWindow();
+ if (!window) return { success: false };
+ extensionPopupManager.openPopup(
+ window,
+ input.extensionId,
+ input.popupPath,
+ input.anchorRect,
+ );
+ return { success: true };
+ }),
+
+ closePopup: publicProcedure.mutation(() => {
+ extensionPopupManager.closePopup();
+ return { success: true };
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts
index afdc4d7f59a..431ae4a3b4c 100644
--- a/apps/desktop/src/lib/trpc/routers/external/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/external/index.ts
@@ -1,4 +1,5 @@
import fs from "node:fs";
+import { access, readFile, writeFile } from "node:fs/promises";
import {
EXTERNAL_APPS,
NON_EDITOR_APPS,
@@ -7,7 +8,13 @@ import {
} from "@superset/local-db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
-import { clipboard, shell } from "electron";
+import {
+ BrowserWindow,
+ clipboard,
+ dialog,
+ type OpenDialogOptions,
+ shell,
+} from "electron";
import { localDb } from "main/lib/local-db";
import { z } from "zod";
import { publicProcedure, router } from "../..";
@@ -21,9 +28,59 @@ import {
} from "./helpers";
const ExternalAppSchema = z.enum(EXTERNAL_APPS);
+const FileFilterSchema = z.object({
+ name: z.string(),
+ extensions: z.array(z.string()),
+});
const nonEditorSet = new Set(NON_EDITOR_APPS);
+function isMissingExternalAppError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false;
+ return (
+ error.message.includes("Unable to find application named") ||
+ error.message.includes("Ensure the application is installed.")
+ );
+}
+
+function isMissingPathError(error: unknown): boolean {
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
+}
+
+async function assertPathExists(filePath: string): Promise {
+ try {
+ await access(filePath);
+ } catch (error) {
+ // Missing paths are expected in stale UI selections and should not hit Sentry.
+ if (isMissingPathError(error)) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `The file ${filePath} does not exist.`,
+ });
+ }
+ throw error;
+ }
+}
+
+function normalizeOpenInAppError(error: unknown): never {
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+ if (isMissingExternalAppError(error)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Requested application is not available",
+ });
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error instanceof Error ? error.message : "Unknown error",
+ });
+}
+
/** Sets the global default editor if one hasn't been set yet. Skips non-editor apps. */
function ensureGlobalDefaultEditor(app: ExternalApp) {
if (nonEditorSet.has(app)) return;
@@ -83,7 +140,10 @@ async function openPathInApp(
throw lastError;
}
- await shell.openPath(filePath);
+ const openError = await shell.openPath(filePath);
+ if (openError) {
+ throw new Error(openError);
+ }
}
/**
@@ -112,6 +172,20 @@ export const createExternalRouter = () => {
shell.showItemInFolder(input);
}),
+ openInDefaultApp: publicProcedure
+ .input(z.string())
+ .mutation(async ({ input }) => {
+ // Surface missing files as a typed user-facing error before invoking the shell.
+ await assertPathExists(input);
+ const openError = await shell.openPath(input);
+ if (openError) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: openError,
+ });
+ }
+ }),
+
openInApp: publicProcedure
.input(
z.object({
@@ -121,7 +195,13 @@ export const createExternalRouter = () => {
}),
)
.mutation(async ({ input }) => {
- await openPathInApp(input.path, input.app);
+ // Avoid turning deleted/moved files into INTERNAL_SERVER_ERROR during app launch.
+ await assertPathExists(input.path);
+ try {
+ await openPathInApp(input.path, input.app);
+ } catch (error) {
+ normalizeOpenInAppError(error);
+ }
// Persist defaults only after successful launch
if (input.projectId) {
@@ -151,6 +231,91 @@ export const createExternalRouter = () => {
clipboard.writeText(input);
}),
+ openTextFile: publicProcedure
+ .input(
+ z.object({
+ title: z.string().optional(),
+ buttonLabel: z.string().optional(),
+ filters: z.array(FileFilterSchema).optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const window = BrowserWindow.getFocusedWindow();
+ const options: OpenDialogOptions = {
+ title: input.title,
+ buttonLabel: input.buttonLabel,
+ filters: input.filters,
+ properties: ["openFile"],
+ };
+ const result = window
+ ? await dialog.showOpenDialog(window, options)
+ : await dialog.showOpenDialog(options);
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return null;
+ }
+
+ const filePath = result.filePaths[0];
+ if (!filePath) {
+ return null;
+ }
+
+ try {
+ const content = await readFile(filePath, "utf-8");
+ return {
+ path: filePath,
+ content,
+ };
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to read file: ${filePath}`,
+ cause: error,
+ });
+ }
+ }),
+
+ saveTextFile: publicProcedure
+ .input(
+ z.object({
+ title: z.string().optional(),
+ defaultPath: z.string().optional(),
+ buttonLabel: z.string().optional(),
+ filters: z.array(FileFilterSchema).optional(),
+ content: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const window = BrowserWindow.getFocusedWindow();
+ const options = {
+ title: input.title,
+ defaultPath: input.defaultPath,
+ buttonLabel: input.buttonLabel,
+ filters: input.filters,
+ };
+ const result = window
+ ? await dialog.showSaveDialog(window, options)
+ : await dialog.showSaveDialog(options);
+
+ if (result.canceled || !result.filePath) {
+ return null;
+ }
+
+ try {
+ await writeFile(result.filePath, input.content, "utf-8");
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to write file: ${result.filePath}`,
+ cause: error,
+ });
+ }
+
+ return {
+ path: result.filePath,
+ };
+ }),
+
resolvePath: publicProcedure
.input(
z.object({
@@ -171,6 +336,9 @@ export const createExternalRouter = () => {
const workspace = input.workspaceId
? getWorkspace(input.workspaceId)
: null;
+ // If a workspaceId was provided but we couldn't find the workspace,
+ // return null rather than resolving relative to process.cwd().
+ if (input.workspaceId && !workspace) return null;
const cwd = workspace
? (getWorkspacePath(workspace) ?? undefined)
: undefined;
@@ -198,17 +366,26 @@ export const createExternalRouter = () => {
)
.mutation(async ({ input }) => {
const filePath = resolvePath(input.path, input.cwd);
+ // Editor open is also triggered from stale paths in the UI, so normalize ENOENT here too.
+ await assertPathExists(filePath);
const app = resolveDefaultEditor(input.projectId);
if (!app) {
// No preferred editor configured yet.
// Fall back to OS default file handler so Cmd/Ctrl+click still works
// even when Cursor (or any specific editor) isn't installed.
- await shell.openPath(filePath);
+ const openError = await shell.openPath(filePath);
+ if (openError) {
+ throw new Error(openError);
+ }
return;
}
- await openPathInApp(filePath, app);
+ try {
+ await openPathInApp(filePath, app);
+ } catch (error) {
+ normalizeOpenInAppError(error);
+ }
}),
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts
index 933b4dfaeeb..f07a51e6b05 100644
--- a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts
@@ -1,4 +1,8 @@
-import { toErrorMessage } from "@superset/workspace-fs/host";
+import {
+ toErrorMessage,
+ WorkspaceFsPathError,
+} from "@superset/workspace-fs/host";
+import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { publicProcedure, router } from "../..";
@@ -12,6 +16,95 @@ function isClosedStreamError(error: unknown): boolean {
);
}
+function getErrorCode(error: unknown): string | null {
+ if (!(error instanceof Error) || !("code" in error)) {
+ return null;
+ }
+
+ return typeof error.code === "string" ? error.code : null;
+}
+
+function throwFilesystemError(error: unknown): never {
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ // Most filesystem failures here are expected user/state conditions, so normalize
+ // them to typed tRPC errors and reserve INTERNAL_SERVER_ERROR for real surprises.
+ if (error instanceof WorkspaceFsPathError) {
+ switch (error.code) {
+ case "OUTSIDE_ROOT":
+ case "INVALID_TARGET":
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ case "SYMLINK_ESCAPE":
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: error.message,
+ });
+ }
+ }
+
+ const errorCode = getErrorCode(error);
+ if (errorCode === "ENOENT") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: toErrorMessage(error),
+ });
+ }
+ if (
+ errorCode === "EISDIR" ||
+ errorCode === "ENOTDIR" ||
+ errorCode === "ERR_FS_EISDIR"
+ ) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: toErrorMessage(error),
+ });
+ }
+ if (errorCode === "EACCES" || errorCode === "EPERM") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: toErrorMessage(error),
+ });
+ }
+ if (errorCode === "EEXIST") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: toErrorMessage(error),
+ });
+ }
+ if (
+ error instanceof Error &&
+ error.message.includes("Path is outside workspace root")
+ ) {
+ // workspace-fs still emits a plain Error for some outside-root checks.
+ // Keep this router-side fallback until that package fully types the error.
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ }
+
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: toErrorMessage(error),
+ });
+}
+
+async function withFilesystemErrorBoundary(
+ operation: () => Promise,
+): Promise {
+ try {
+ return await operation();
+ } catch (error) {
+ // Keep every filesystem procedure aligned on the same error semantics.
+ throwFilesystemError(error);
+ }
+}
+
const writeFileContentSchema = z.union([
z.string(),
z.object({
@@ -28,6 +121,29 @@ type WatchPathEventBatch = {
}>;
};
+const searchContentInputSchema = z.object({
+ workspaceId: z.string(),
+ query: z.string(),
+ includeHidden: z.boolean().optional(),
+ includePattern: z.string().optional(),
+ excludePattern: z.string().optional(),
+ limit: z.number().optional(),
+ isRegex: z.boolean().optional(),
+ caseSensitive: z.boolean().optional(),
+});
+
+const replaceContentInputSchema = z.object({
+ workspaceId: z.string(),
+ query: z.string(),
+ replacement: z.string(),
+ includeHidden: z.boolean().optional(),
+ includePattern: z.string().optional(),
+ excludePattern: z.string().optional(),
+ isRegex: z.boolean().optional(),
+ caseSensitive: z.boolean().optional(),
+ paths: z.array(z.string()).optional(),
+});
+
export const createFilesystemRouter = () => {
return router({
listDirectory: publicProcedure
@@ -38,9 +154,11 @@ export const createFilesystemRouter = () => {
}),
)
.query(async ({ input }) => {
- const service = getServiceForWorkspace(input.workspaceId);
- return await service.listDirectory({
- absolutePath: input.absolutePath,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.listDirectory({
+ absolutePath: input.absolutePath,
+ });
});
}),
@@ -55,22 +173,24 @@ export const createFilesystemRouter = () => {
}),
)
.query(async ({ input }) => {
- const service = getServiceForWorkspace(input.workspaceId);
- const result = await service.readFile({
- absolutePath: input.absolutePath,
- offset: input.offset,
- maxBytes: input.maxBytes,
- encoding: input.encoding,
- });
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ const result = await service.readFile({
+ absolutePath: input.absolutePath,
+ offset: input.offset,
+ maxBytes: input.maxBytes,
+ encoding: input.encoding,
+ });
- if (result.kind === "bytes") {
- return {
- ...result,
- content: Buffer.from(result.content).toString("base64"),
- };
- }
+ if (result.kind === "bytes") {
+ return {
+ ...result,
+ content: Buffer.from(result.content).toString("base64"),
+ };
+ }
- return result;
+ return result;
+ });
}),
getMetadata: publicProcedure
@@ -81,9 +201,11 @@ export const createFilesystemRouter = () => {
}),
)
.query(async ({ input }) => {
- const service = getServiceForWorkspace(input.workspaceId);
- return await service.getMetadata({
- absolutePath: input.absolutePath,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.getMetadata({
+ absolutePath: input.absolutePath,
+ });
});
}),
@@ -108,18 +230,20 @@ export const createFilesystemRouter = () => {
}),
)
.mutation(async ({ input }) => {
- const service = getServiceForWorkspace(input.workspaceId);
- const content =
- typeof input.content === "string"
- ? input.content
- : new Uint8Array(Buffer.from(input.content.data, "base64"));
-
- return await service.writeFile({
- absolutePath: input.absolutePath,
- content,
- encoding: input.encoding,
- options: input.options,
- precondition: input.precondition,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ const content =
+ typeof input.content === "string"
+ ? input.content
+ : new Uint8Array(Buffer.from(input.content.data, "base64"));
+
+ return await service.writeFile({
+ absolutePath: input.absolutePath,
+ content,
+ encoding: input.encoding,
+ options: input.options,
+ precondition: input.precondition,
+ });
});
}),
@@ -132,10 +256,12 @@ export const createFilesystemRouter = () => {
}),
)
.mutation(async ({ input }) => {
- const service = getServiceForWorkspace(input.workspaceId);
- return await service.createDirectory({
- absolutePath: input.absolutePath,
- recursive: input.recursive,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.createDirectory({
+ absolutePath: input.absolutePath,
+ recursive: input.recursive,
+ });
});
}),
@@ -148,10 +274,12 @@ export const createFilesystemRouter = () => {
}),
)
.mutation(async ({ input }) => {
- const service = getServiceForWorkspace(input.workspaceId);
- return await service.deletePath({
- absolutePath: input.absolutePath,
- permanent: input.permanent,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.deletePath({
+ absolutePath: input.absolutePath,
+ permanent: input.permanent,
+ });
});
}),
@@ -164,10 +292,12 @@ export const createFilesystemRouter = () => {
}),
)
.mutation(async ({ input }) => {
- const service = getServiceForWorkspace(input.workspaceId);
- return await service.movePath({
- sourceAbsolutePath: input.sourceAbsolutePath,
- destinationAbsolutePath: input.destinationAbsolutePath,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.movePath({
+ sourceAbsolutePath: input.sourceAbsolutePath,
+ destinationAbsolutePath: input.destinationAbsolutePath,
+ });
});
}),
@@ -180,10 +310,12 @@ export const createFilesystemRouter = () => {
}),
)
.mutation(async ({ input }) => {
- const service = getServiceForWorkspace(input.workspaceId);
- return await service.copyPath({
- sourceAbsolutePath: input.sourceAbsolutePath,
- destinationAbsolutePath: input.destinationAbsolutePath,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.copyPath({
+ sourceAbsolutePath: input.sourceAbsolutePath,
+ destinationAbsolutePath: input.destinationAbsolutePath,
+ });
});
}),
@@ -204,40 +336,65 @@ export const createFilesystemRouter = () => {
return { matches: [] };
}
- const service = getServiceForWorkspace(input.workspaceId);
- return await service.searchFiles({
- query: trimmedQuery,
- includeHidden: input.includeHidden,
- includePattern: input.includePattern,
- excludePattern: input.excludePattern,
- limit: input.limit,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.searchFiles({
+ query: trimmedQuery,
+ includeHidden: input.includeHidden,
+ includePattern: input.includePattern,
+ excludePattern: input.excludePattern,
+ limit: input.limit,
+ });
});
}),
searchContent: publicProcedure
- .input(
- z.object({
- workspaceId: z.string(),
- query: z.string(),
- includeHidden: z.boolean().optional(),
- includePattern: z.string().optional(),
- excludePattern: z.string().optional(),
- limit: z.number().optional(),
- }),
- )
+ .input(searchContentInputSchema)
.query(async ({ input }) => {
const trimmedQuery = input.query.trim();
if (!trimmedQuery) {
return { matches: [] };
}
- const service = getServiceForWorkspace(input.workspaceId);
- return await service.searchContent({
- query: trimmedQuery,
- includeHidden: input.includeHidden,
- includePattern: input.includePattern,
- excludePattern: input.excludePattern,
- limit: input.limit,
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.searchContent({
+ query: trimmedQuery,
+ includeHidden: input.includeHidden,
+ includePattern: input.includePattern,
+ excludePattern: input.excludePattern,
+ limit: input.limit,
+ isRegex: input.isRegex,
+ caseSensitive: input.caseSensitive,
+ });
+ });
+ }),
+
+ replaceContent: publicProcedure
+ .input(replaceContentInputSchema)
+ .mutation(async ({ input }) => {
+ if (input.query.length === 0) {
+ return {
+ replacements: 0,
+ filesUpdated: 0,
+ updated: [],
+ conflicts: [],
+ failed: [],
+ };
+ }
+
+ return await withFilesystemErrorBoundary(async () => {
+ const service = getServiceForWorkspace(input.workspaceId);
+ return await service.replaceContent({
+ query: input.query,
+ replacement: input.replacement,
+ includeHidden: input.includeHidden,
+ includePattern: input.includePattern,
+ excludePattern: input.excludePattern,
+ isRegex: input.isRegex,
+ caseSensitive: input.caseSensitive,
+ paths: input.paths,
+ });
});
}),
diff --git a/apps/desktop/src/lib/trpc/routers/github-metrics.ts b/apps/desktop/src/lib/trpc/routers/github-metrics.ts
new file mode 100644
index 00000000000..decdf87d24b
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/github-metrics.ts
@@ -0,0 +1,19 @@
+import { publicProcedure, router } from "..";
+import { getGitHubMetricsSnapshot } from "./workspaces/utils/github/github-metrics";
+import { getGitHubRateLimitState } from "./workspaces/utils/github/github-rate-limiter";
+import { githubSyncService } from "./workspaces/utils/github/github-sync-service";
+
+export const createGitHubMetricsRouter = () => {
+ return router({
+ getSnapshot: publicProcedure.query(() => {
+ return {
+ generatedAt: Date.now(),
+ rateLimit: getGitHubRateLimitState(),
+ syncService: githubSyncService.getDebugSnapshot(),
+ metrics: getGitHubMetricsSnapshot(),
+ };
+ }),
+ });
+};
+
+export type GitHubMetricsRouter = ReturnType;
diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts
index 72fbf9ae8e3..41853c6d66e 100644
--- a/apps/desktop/src/lib/trpc/routers/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/index.ts
@@ -1,4 +1,7 @@
import type { BrowserWindow } from "electron";
+import type { WindowManager } from "main/lib/window-manager";
+// Fork-local: TODO autonomous agent feature.
+import { createTodoAgentRouter } from "main/todo-agent";
import { router } from "..";
import { createAnalyticsRouter } from "./analytics";
import { createAuthRouter } from "./auth";
@@ -10,24 +13,38 @@ import { createChangesRouter } from "./changes";
import { createChatRuntimeServiceRouter } from "./chat-runtime-service";
import { createChatServiceRouter } from "./chat-service";
import { createConfigRouter } from "./config";
+import { createDatabasesRouter } from "./databases";
+import { createDiagnosticsRouter } from "./diagnostics";
+import { createDockerRouter } from "./docker";
+import { createExtensionsRouter } from "./extensions";
import { createExternalRouter } from "./external";
import { createFilesystemRouter } from "./filesystem";
+import { createGitHubMetricsRouter } from "./github-metrics";
import { createHostServiceCoordinatorRouter } from "./host-service-coordinator";
+import { createLanguageServicesRouter } from "./language-services";
import { createMenuRouter } from "./menu";
import { createModelProvidersRouter } from "./model-providers";
import { createNotificationsRouter } from "./notifications";
import { createPermissionsRouter } from "./permissions";
import { createPortsRouter } from "./ports";
import { createProjectsRouter } from "./projects";
+import { createReferenceGraphRouter } from "./reference-graph";
import { createResourceMetricsRouter } from "./resource-metrics";
import { createRingtoneRouter } from "./ringtone";
+import { createServiceStatusRouter } from "./service-status";
import { createSettingsRouter } from "./settings";
+import { createTabTearoffRouter } from "./tab-tearoff";
import { createTerminalRouter } from "./terminal";
import { createUiStateRouter } from "./ui-state";
+import { createVibrancyRouter } from "./vibrancy";
+import { createVscodeExtensionsRouter } from "./vscode-extensions";
import { createWindowRouter } from "./window";
import { createWorkspacesRouter } from "./workspaces";
-export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
+export const createAppRouter = (
+ getWindow: () => BrowserWindow | null,
+ wm: WindowManager,
+) => {
return router({
chatRuntimeService: createChatRuntimeServiceRouter(),
chatService: createChatServiceRouter(),
@@ -44,17 +61,29 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
terminal: createTerminalRouter(),
changes: createChangesRouter(),
filesystem: createFilesystemRouter(),
+ githubMetrics: createGitHubMetricsRouter(),
notifications: createNotificationsRouter(),
permissions: createPermissionsRouter(),
ports: createPortsRouter(),
resourceMetrics: createResourceMetricsRouter(),
menu: createMenuRouter(),
+ languageServices: createLanguageServicesRouter(),
+ referenceGraph: createReferenceGraphRouter(),
external: createExternalRouter(),
settings: createSettingsRouter(),
config: createConfigRouter(),
+ databases: createDatabasesRouter(),
+ diagnostics: createDiagnosticsRouter(),
+ docker: createDockerRouter(),
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(getWindow),
+ serviceStatus: createServiceStatusRouter(),
hostServiceCoordinator: createHostServiceCoordinatorRouter(),
+ tabTearoff: createTabTearoffRouter(wm),
+ extensions: createExtensionsRouter(getWindow),
+ vibrancy: createVibrancyRouter(wm),
+ vscodeExtensions: createVscodeExtensionsRouter(),
+ todoAgent: createTodoAgentRouter(),
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/language-services/index.ts b/apps/desktop/src/lib/trpc/routers/language-services/index.ts
new file mode 100644
index 00000000000..0ee6640045e
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/language-services/index.ts
@@ -0,0 +1,211 @@
+import { TRPCError } from "@trpc/server";
+import { observable } from "@trpc/server/observable";
+import { languageServiceManager } from "main/lib/language-services/manager";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+import { getWorkspace } from "../workspaces/utils/db-helpers";
+import { getWorkspacePath } from "../workspaces/utils/worktree";
+
+const languageServiceDocumentSchema = z.object({
+ workspaceId: z.string(),
+ absolutePath: z.string(),
+ languageId: z.string(),
+ content: z.string(),
+ version: z.number().int().nonnegative(),
+});
+
+const languageServicePositionSchema = z.object({
+ workspaceId: z.string(),
+ absolutePath: z.string(),
+ languageId: z.string(),
+ line: z.number().int().positive(),
+ column: z.number().int().positive(),
+ content: z.string().optional(),
+ version: z.number().int().nonnegative().optional(),
+});
+
+function resolveWorkspacePath(workspaceId: string): string {
+ const workspace = getWorkspace(workspaceId);
+ if (!workspace) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Workspace ${workspaceId} not found`,
+ });
+ }
+
+ const workspacePath = getWorkspacePath(workspace);
+ if (!workspacePath) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: `Workspace ${workspaceId} has no filesystem path`,
+ });
+ }
+
+ return workspacePath;
+}
+
+async function syncLookupDocumentIfNeeded(
+ input: z.infer,
+): Promise {
+ const workspacePath = resolveWorkspacePath(input.workspaceId);
+ if (input.content === undefined || input.version === undefined) {
+ return workspacePath;
+ }
+
+ await languageServiceManager.syncDocument({
+ workspaceId: input.workspaceId,
+ workspacePath,
+ absolutePath: input.absolutePath,
+ languageId: input.languageId,
+ content: input.content,
+ version: input.version,
+ });
+ return workspacePath;
+}
+
+export const createLanguageServicesRouter = () => {
+ return router({
+ openDocument: publicProcedure
+ .input(languageServiceDocumentSchema)
+ .mutation(async ({ input }) => {
+ const workspacePath = resolveWorkspacePath(input.workspaceId);
+ await languageServiceManager.openDocument({
+ ...input,
+ workspacePath,
+ });
+ return { ok: true };
+ }),
+
+ changeDocument: publicProcedure
+ .input(languageServiceDocumentSchema)
+ .mutation(async ({ input }) => {
+ const workspacePath = resolveWorkspacePath(input.workspaceId);
+ await languageServiceManager.syncDocument({
+ ...input,
+ workspacePath,
+ });
+ return { ok: true };
+ }),
+
+ getHover: publicProcedure
+ .input(languageServicePositionSchema)
+ .query(async ({ input }) => {
+ const workspacePath = await syncLookupDocumentIfNeeded(input);
+ return await languageServiceManager.getHover({
+ workspaceId: input.workspaceId,
+ workspacePath,
+ absolutePath: input.absolutePath,
+ languageId: input.languageId,
+ line: input.line,
+ column: input.column,
+ });
+ }),
+
+ getDefinition: publicProcedure
+ .input(languageServicePositionSchema)
+ .query(async ({ input }) => {
+ const workspacePath = await syncLookupDocumentIfNeeded(input);
+ return await languageServiceManager.getDefinition({
+ workspaceId: input.workspaceId,
+ workspacePath,
+ absolutePath: input.absolutePath,
+ languageId: input.languageId,
+ line: input.line,
+ column: input.column,
+ });
+ }),
+
+ closeDocument: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ absolutePath: z.string(),
+ languageId: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const workspacePath = resolveWorkspacePath(input.workspaceId);
+ await languageServiceManager.closeDocument({
+ ...input,
+ workspacePath,
+ });
+ return { ok: true };
+ }),
+
+ refreshWorkspace: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const workspacePath = resolveWorkspacePath(input.workspaceId);
+ await languageServiceManager.refreshWorkspace({
+ workspaceId: input.workspaceId,
+ workspacePath,
+ });
+ return { ok: true };
+ }),
+
+ getWorkspaceDiagnostics: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ }),
+ )
+ .query(({ input }) => {
+ const workspacePath = resolveWorkspacePath(input.workspaceId);
+ return languageServiceManager.getWorkspaceSnapshot({
+ workspaceId: input.workspaceId,
+ workspacePath,
+ });
+ }),
+
+ getProviders: publicProcedure.query(() => {
+ return languageServiceManager.getProviders();
+ }),
+
+ setProviderEnabled: publicProcedure
+ .input(
+ z.object({
+ providerId: z.string(),
+ enabled: z.boolean(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const provider = await languageServiceManager.setProviderEnabled(
+ input.providerId,
+ input.enabled,
+ );
+ if (!provider) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Language service provider ${input.providerId} not found`,
+ });
+ }
+
+ return provider;
+ }),
+
+ subscribeDiagnostics: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ }),
+ )
+ .subscription(({ input }) => {
+ return observable<{ version: number }>((emit) => {
+ const unsubscribe = languageServiceManager.subscribeToWorkspace(
+ input.workspaceId,
+ (payload) => {
+ emit.next(payload);
+ },
+ );
+
+ return () => {
+ unsubscribe();
+ };
+ });
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/menu.ts b/apps/desktop/src/lib/trpc/routers/menu.ts
index 7310d5f6a12..d775038d681 100644
--- a/apps/desktop/src/lib/trpc/routers/menu.ts
+++ b/apps/desktop/src/lib/trpc/routers/menu.ts
@@ -1,15 +1,18 @@
import { observable } from "@trpc/server/observable";
import {
+ type BrowserActionEvent,
menuEmitter,
type OpenSettingsEvent,
type OpenWorkspaceEvent,
type SettingsSection,
} from "main/lib/menu-events";
+import type { BrowserShortcutAction } from "shared/browser-shortcuts";
import { publicProcedure, router } from "..";
type MenuEvent =
| { type: "open-settings"; data: OpenSettingsEvent }
- | { type: "open-workspace"; data: OpenWorkspaceEvent };
+ | { type: "open-workspace"; data: OpenWorkspaceEvent }
+ | { type: "browser-action"; data: BrowserActionEvent };
export const createMenuRouter = () => {
return router({
@@ -23,12 +26,18 @@ export const createMenuRouter = () => {
emit.next({ type: "open-workspace", data: { workspaceId } });
};
+ const onBrowserAction = (action: BrowserShortcutAction) => {
+ emit.next({ type: "browser-action", data: { action } });
+ };
+
menuEmitter.on("open-settings", onOpenSettings);
menuEmitter.on("open-workspace", onOpenWorkspace);
+ menuEmitter.on("browser-action", onBrowserAction);
return () => {
menuEmitter.off("open-settings", onOpenSettings);
menuEmitter.off("open-workspace", onOpenWorkspace);
+ menuEmitter.off("browser-action", onBrowserAction);
};
});
}),
diff --git a/apps/desktop/src/lib/trpc/routers/permissions.ts b/apps/desktop/src/lib/trpc/routers/permissions.ts
index 16b9d7d4bc3..e747b327637 100644
--- a/apps/desktop/src/lib/trpc/routers/permissions.ts
+++ b/apps/desktop/src/lib/trpc/routers/permissions.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { shell, systemPreferences } from "electron";
+import { requestMediaAccess } from "lib/electron/request-media-access";
import { publicProcedure, router } from "..";
function checkFullDiskAccess(): boolean {
@@ -30,6 +31,14 @@ function checkMicrophone(): boolean {
}
}
+function checkCamera(): boolean {
+ try {
+ return systemPreferences.getMediaAccessStatus("camera") === "granted";
+ } catch {
+ return false;
+ }
+}
+
export const createPermissionsRouter = () => {
return router({
getStatus: publicProcedure.query(() => {
@@ -37,6 +46,7 @@ export const createPermissionsRouter = () => {
fullDiskAccess: checkFullDiskAccess(),
accessibility: checkAccessibility(),
microphone: checkMicrophone(),
+ camera: checkCamera(),
};
}),
@@ -53,22 +63,11 @@ export const createPermissionsRouter = () => {
}),
requestMicrophone: publicProcedure.mutation(async () => {
- try {
- if (process.platform === "darwin") {
- const granted =
- await systemPreferences.askForMediaAccess("microphone");
- if (granted) {
- return { granted: true };
- }
- }
- } catch {
- // Fall through to opening System Settings.
- }
+ return requestMediaAccess("microphone");
+ }),
- await shell.openExternal(
- "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
- );
- return { granted: false };
+ requestCamera: publicProcedure.mutation(async () => {
+ return requestMediaAccess("camera");
}),
requestAppleEvents: publicProcedure.mutation(async () => {
diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts
index d4432be08a9..3f7b50e0072 100644
--- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts
+++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts
@@ -1,6 +1,5 @@
import { workspaces } from "@superset/local-db";
import { observable } from "@trpc/server/observable";
-import { eq } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import { loadStaticPorts } from "main/lib/static-ports";
import { portManager } from "main/lib/terminal/port-manager";
@@ -24,30 +23,80 @@ function getLabelsForPath(worktreePath: string): Map | null {
return labels;
}
+/** Cache structure for workspace path + labels lookup. */
+interface WorkspaceLabelInfo {
+ labels: Map | null;
+ workspaceId: string;
+}
+
+function buildLabelCache(): Map {
+ const cache = new Map();
+ const allWs = localDb.select().from(workspaces).all();
+
+ for (const ws of allWs) {
+ const wsPath = getWorkspacePath(ws);
+ if (!wsPath) continue;
+ const labels = getLabelsForPath(wsPath);
+ if (labels) {
+ cache.set(ws.id, { labels, workspaceId: ws.id });
+ }
+ }
+
+ return cache;
+}
+
export const createPortsRouter = () => {
return router({
getAll: publicProcedure.query((): EnrichedPort[] => {
const detectedPorts = portManager.getAllPorts();
+ const labelCache = buildLabelCache();
- const labelCache = new Map | null>();
-
- return detectedPorts.map((port) => {
- if (!labelCache.has(port.workspaceId)) {
- const ws = localDb
- .select()
- .from(workspaces)
- .where(eq(workspaces.id, port.workspaceId))
- .get();
- const wsPath = ws ? getWorkspacePath(ws) : null;
- labelCache.set(
- port.workspaceId,
- wsPath ? getLabelsForPath(wsPath) : null,
- );
- }
+ // Track which static ports have been matched with detected ports
+ // key: "workspaceId:port"
+ const matchedStaticPorts = new Set();
- const labels = labelCache.get(port.workspaceId);
- return { ...port, label: labels?.get(port.port) ?? null };
+ // Enrich detected ports with labels
+ const enriched: EnrichedPort[] = detectedPorts.map((port) => {
+ const info = labelCache.get(port.workspaceId);
+ const label = info?.labels?.get(port.port) ?? null;
+ if (label != null) {
+ matchedStaticPorts.add(`${port.workspaceId}:${port.port}`);
+ }
+ return {
+ port: port.port,
+ workspaceId: port.workspaceId,
+ label,
+ detected: true,
+ pid: port.pid,
+ processName: port.processName,
+ paneId: port.paneId,
+ detectedAt: port.detectedAt,
+ address: port.address,
+ };
});
+
+ // Add static ports that were NOT detected
+ for (const [wsId, info] of labelCache) {
+ if (!info.labels) continue;
+ for (const [portNum, label] of info.labels) {
+ const key = `${wsId}:${portNum}`;
+ if (matchedStaticPorts.has(key)) continue;
+
+ enriched.push({
+ port: portNum,
+ workspaceId: wsId,
+ label,
+ detected: false,
+ pid: null,
+ processName: null,
+ paneId: null,
+ detectedAt: null,
+ address: null,
+ });
+ }
+ }
+
+ return enriched;
}),
subscribe: publicProcedure.subscription(() => {
diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts
index 169fa8cc7d4..73d8c6598ab 100644
--- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts
+++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts
@@ -1,3 +1,4 @@
+import { EventEmitter } from "node:events";
import { existsSync, statSync } from "node:fs";
import { access, mkdir, rm } from "node:fs/promises";
import { basename, join } from "node:path";
@@ -12,6 +13,7 @@ import {
worktrees,
} from "@superset/local-db";
import { TRPCError } from "@trpc/server";
+import { observable } from "@trpc/server/observable";
import { and, desc, eq, inArray, isNotNull, isNull, not } from "drizzle-orm";
import type { BrowserWindow } from "electron";
import { dialog } from "electron";
@@ -23,6 +25,7 @@ import {
} from "main/lib/project-icons";
import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime";
import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors";
+import simpleGit, { type SimpleGitProgressEvent } from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { resolveDefaultEditor } from "../external";
@@ -43,7 +46,10 @@ import {
sanitizeAuthorPrefix,
} from "../workspaces/utils/git";
import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
-import { execWithShellEnv } from "../workspaces/utils/shell-env";
+import {
+ execWithShellEnv,
+ getProcessEnvWithShellPath,
+} from "../workspaces/utils/shell-env";
import { getDefaultProjectColor } from "./utils/colors";
import { discoverAndSaveProjectIcon } from "./utils/favicon-discovery";
import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github";
@@ -171,6 +177,28 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project {
return project;
}
+async function ensureProjectGitHubOwner(project: Project): Promise {
+ if (project.githubOwner) {
+ return project;
+ }
+
+ const githubOwner = await fetchGitHubOwner(project.mainRepoPath);
+ if (!githubOwner) {
+ return project;
+ }
+
+ localDb
+ .update(projects)
+ .set({ githubOwner })
+ .where(eq(projects.id, project.id))
+ .run();
+
+ return {
+ ...project,
+ githubOwner,
+ };
+}
+
async function ensureMainWorkspace(project: Project): Promise {
const existingBranchWorkspace = getBranchWorkspace(project.id);
@@ -301,6 +329,119 @@ function extractRepoName(urlInput: string): string | null {
return repoSegment;
}
+interface CloneEventBase {
+ cloneId: string;
+ /** Monotonic sequence number per cloneId, used by subscribers to dedupe. */
+ seq: number;
+ time: number;
+}
+
+export type CloneProgressEvent =
+ | (CloneEventBase & {
+ type: "log";
+ message: string;
+ level: "info" | "warn" | "error";
+ })
+ | (CloneEventBase & {
+ type: "progress";
+ stage: string;
+ progress: number;
+ processed: number;
+ total: number;
+ })
+ | (CloneEventBase & { type: "done" })
+ | (CloneEventBase & { type: "error"; message: string })
+ | (CloneEventBase & { type: "canceled" });
+
+const cloneEventBus = new EventEmitter();
+cloneEventBus.setMaxListeners(0);
+const cloneAbortControllers = new Map();
+
+/**
+ * Per-cloneId replay buffer. The tRPC subscription is established after the
+ * mutation is fired from the client, so events emitted in the window between
+ * `cloneRepo` starting and the subscription connecting would otherwise be
+ * lost. The buffer is flushed to the first subscriber and trimmed on a short
+ * timeout after any terminal event (done / error / canceled).
+ */
+const cloneEventBuffers = new Map();
+const cloneBufferEvictTimers = new Map();
+const cloneSeqCounters = new Map();
+const MAX_BUFFERED_EVENTS = 1000;
+const TERMINAL_BUFFER_EVICT_MS = 30_000;
+
+function isTerminalCloneEvent(event: CloneProgressEvent): boolean {
+ return (
+ event.type === "done" || event.type === "error" || event.type === "canceled"
+ );
+}
+
+function nextCloneSeq(cloneId: string): number {
+ const next = (cloneSeqCounters.get(cloneId) ?? 0) + 1;
+ cloneSeqCounters.set(cloneId, next);
+ return next;
+}
+
+// Distributive omit preserves the discriminated-union shape so callers can
+// still pass type-specific fields (`message`, `stage`, …) without TS
+// collapsing everything to the common intersection.
+type DistributiveOmit = T extends unknown
+ ? Omit
+ : never;
+type CloneEventInput = DistributiveOmit;
+
+function emitCloneEvent(input: CloneEventInput) {
+ const event = {
+ ...input,
+ seq: nextCloneSeq(input.cloneId),
+ } as CloneProgressEvent;
+ let buffer = cloneEventBuffers.get(event.cloneId);
+ if (!buffer) {
+ buffer = [];
+ cloneEventBuffers.set(event.cloneId, buffer);
+ }
+ buffer.push(event);
+ if (buffer.length > MAX_BUFFERED_EVENTS) {
+ buffer.splice(0, buffer.length - MAX_BUFFERED_EVENTS);
+ }
+ cloneEventBus.emit(event.cloneId, event);
+
+ if (isTerminalCloneEvent(event)) {
+ const existing = cloneBufferEvictTimers.get(event.cloneId);
+ if (existing) clearTimeout(existing);
+ const timer = setTimeout(() => {
+ cloneEventBuffers.delete(event.cloneId);
+ cloneBufferEvictTimers.delete(event.cloneId);
+ cloneSeqCounters.delete(event.cloneId);
+ }, TERMINAL_BUFFER_EVICT_MS);
+ cloneBufferEvictTimers.set(event.cloneId, timer);
+ }
+}
+
+function emitCloneLog(
+ cloneId: string,
+ message: string,
+ level: "info" | "warn" | "error" = "info",
+) {
+ emitCloneEvent({
+ type: "log",
+ cloneId,
+ message,
+ level,
+ time: Date.now(),
+ });
+}
+
+/**
+ * Strip `userinfo` (credentials embedded in URLs such as
+ * `https://token@host/...` or `https://user:pass@host/...`) so that PATs and
+ * basic-auth tokens never reach the renderer via progress logs or error
+ * messages. Applied to every string emitted through the clone event bus.
+ */
+function redactGitCredentials(value: string): string {
+ return value.replace(/\/\/([^/\s@]+)(?::[^/\s@]*)?@/g, "//***@");
+}
+
/** Create the tRPC router for project CRUD, branch listing, and git operations. */
export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
return router({
@@ -1070,7 +1211,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
const mainRepoPath = await getGitRoot(selectedPath);
const defaultBranch = await getDefaultBranch(mainRepoPath);
- const project = upsertProject(mainRepoPath, defaultBranch);
+ const project = await ensureProjectGitHubOwner(
+ upsertProject(mainRepoPath, defaultBranch),
+ );
await ensureMainWorkspace(project);
track("project_opened", {
@@ -1142,7 +1285,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
const defaultBranch = await getDefaultBranch(mainRepoPath);
- const project = upsertProject(mainRepoPath, defaultBranch);
+ const project = await ensureProjectGitHubOwner(
+ upsertProject(mainRepoPath, defaultBranch),
+ );
await ensureMainWorkspace(project);
track("project_opened", {
@@ -1161,7 +1306,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.mutation(async ({ input }) => {
const { defaultBranch } = await initGitRepo(input.path);
- const project = upsertProject(input.path, defaultBranch);
+ const project = await ensureProjectGitHubOwner(
+ upsertProject(input.path, defaultBranch),
+ );
await ensureMainWorkspace(project);
track("project_opened", {
@@ -1172,6 +1319,53 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
return { project };
}),
+ cloneProgress: publicProcedure
+ .input(z.object({ cloneId: z.string().min(1) }))
+ .subscription(({ input }) => {
+ return observable((emit) => {
+ // Dedupe by monotonic seq so that we can safely attach the live
+ // listener first and then replay the buffer: any event that
+ // reaches both paths only passes the `> lastSeq` guard once.
+ let lastSeq = 0;
+ const deliver = (event: CloneProgressEvent) => {
+ if (event.seq <= lastSeq) return;
+ lastSeq = event.seq;
+ emit.next(event);
+ };
+ const handler = (event: CloneProgressEvent) => {
+ deliver(event);
+ };
+ cloneEventBus.on(input.cloneId, handler);
+ const buffered = cloneEventBuffers.get(input.cloneId);
+ if (buffered) {
+ for (const event of buffered) {
+ deliver(event);
+ }
+ }
+ return () => {
+ cloneEventBus.off(input.cloneId, handler);
+ };
+ });
+ }),
+
+ cancelClone: publicProcedure
+ .input(z.object({ cloneId: z.string().min(1) }))
+ .mutation(({ input }) => {
+ const controller = cloneAbortControllers.get(input.cloneId);
+ if (!controller) {
+ return { canceled: false as const };
+ }
+ controller.abort();
+ cloneAbortControllers.delete(input.cloneId);
+ emitCloneLog(input.cloneId, "Clone canceled by user", "warn");
+ emitCloneEvent({
+ type: "canceled",
+ cloneId: input.cloneId,
+ time: Date.now(),
+ });
+ return { canceled: true as const };
+ }),
+
cloneRepo: publicProcedure
.input(
z.object({
@@ -1195,6 +1389,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.trim()
.optional()
.transform((v) => (v && v.length > 0 ? v : undefined)),
+ cloneId: z.string().min(1).optional(),
}),
)
.mutation(async ({ input }) => {
@@ -1252,12 +1447,14 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.where(eq(projects.id, existingProject.id))
.run();
- // Auto-create main workspace if it doesn't exist
- await ensureMainWorkspace({
+ const hydratedProject = await ensureProjectGitHubOwner({
...existingProject,
lastOpenedAt: Date.now(),
});
+ // Auto-create main workspace if it doesn't exist
+ await ensureMainWorkspace(hydratedProject);
+
track("project_opened", {
project_id: existingProject.id,
method: "clone",
@@ -1266,7 +1463,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
return {
canceled: false as const,
success: true as const,
- project: { ...existingProject, lastOpenedAt: Date.now() },
+ project: hydratedProject,
};
} catch {
// Directory is missing - remove the stale project record and continue with clone
@@ -1286,23 +1483,88 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
};
}
- // Clone the repository
- const git = await getSimpleGitWithShellPath();
- await git.clone(input.url, clonePath);
+ // Clone the repository (with streaming progress when cloneId given)
+ const cloneId = input.cloneId;
+ if (cloneId) {
+ const abortController = new AbortController();
+ cloneAbortControllers.set(cloneId, abortController);
+ emitCloneLog(
+ cloneId,
+ `Preparing clone into ${basename(clonePath)}`,
+ );
+ try {
+ const gitWithProgress = simpleGit({
+ abort: abortController.signal,
+ progress: (event: SimpleGitProgressEvent) => {
+ emitCloneEvent({
+ type: "progress",
+ cloneId,
+ stage: event.stage,
+ progress: event.progress,
+ processed: event.processed,
+ total: event.total,
+ time: Date.now(),
+ });
+ },
+ });
+ gitWithProgress.env(await getProcessEnvWithShellPath());
+ emitCloneLog(
+ cloneId,
+ `Cloning ${redactGitCredentials(input.url)}`,
+ );
+ await gitWithProgress.clone(input.url, clonePath);
+ emitCloneLog(cloneId, "Clone finished, preparing project");
+ } catch (cloneError) {
+ const message = redactGitCredentials(
+ cloneError instanceof Error
+ ? cloneError.message
+ : String(cloneError),
+ );
+ // `git clone` creates the destination directory eagerly;
+ // leaving a partial checkout behind would block every retry
+ // against the same path via the existing-folder guard above.
+ await rm(clonePath, { recursive: true, force: true }).catch(
+ () => undefined,
+ );
+ if (!abortController.signal.aborted) {
+ emitCloneEvent({
+ type: "error",
+ cloneId,
+ message,
+ time: Date.now(),
+ });
+ }
+ throw cloneError;
+ } finally {
+ cloneAbortControllers.delete(cloneId);
+ }
+ } else {
+ const git = await getSimpleGitWithShellPath();
+ try {
+ await git.clone(input.url, clonePath);
+ } catch (cloneError) {
+ await rm(clonePath, { recursive: true, force: true }).catch(
+ () => undefined,
+ );
+ throw cloneError;
+ }
+ }
// Create new project
const name = basename(clonePath);
const defaultBranch = await getDefaultBranch(clonePath);
- const project = localDb
- .insert(projects)
- .values({
- mainRepoPath: clonePath,
- name,
- color: getDefaultProjectColor(),
- defaultBranch,
- })
- .returning()
- .get();
+ const project = await ensureProjectGitHubOwner(
+ localDb
+ .insert(projects)
+ .values({
+ mainRepoPath: clonePath,
+ name,
+ color: getDefaultProjectColor(),
+ defaultBranch,
+ })
+ .returning()
+ .get(),
+ );
// Auto-create main workspace if it doesn't exist
await ensureMainWorkspace(project);
@@ -1312,14 +1574,43 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
method: "clone",
});
+ if (input.cloneId) {
+ emitCloneEvent({
+ type: "done",
+ cloneId: input.cloneId,
+ time: Date.now(),
+ });
+ }
+
return {
canceled: false as const,
success: true as const,
project,
};
} catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : String(error);
+ const errorMessage = redactGitCredentials(
+ error instanceof Error ? error.message : String(error),
+ );
+ // Surface post-clone failures (getDefaultBranch / DB insert /
+ // ensureMainWorkspace / etc) to any streaming subscriber, unless
+ // the git clone step itself already emitted an error event.
+ if (input.cloneId) {
+ const buffered = cloneEventBuffers.get(input.cloneId);
+ const hasTerminal = buffered?.some(
+ (event) =>
+ event.type === "error" ||
+ event.type === "canceled" ||
+ event.type === "done",
+ );
+ if (!hasTerminal) {
+ emitCloneEvent({
+ type: "error",
+ cloneId: input.cloneId,
+ message: errorMessage,
+ time: Date.now(),
+ });
+ }
+ }
return {
canceled: false as const,
success: false as const,
@@ -1365,7 +1656,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
await rm(repoPath, { recursive: true, force: true });
throw gitErr;
}
- const project = upsertProject(repoPath, defaultBranch);
+ const project = await ensureProjectGitHubOwner(
+ upsertProject(repoPath, defaultBranch),
+ );
await ensureMainWorkspace(project);
track("project_opened", {
diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts
index 506edc50ec0..253306ae0aa 100644
--- a/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts
+++ b/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts
@@ -1,5 +1,24 @@
+import { execGitWithShellPath } from "../../workspaces/utils/git-client";
import { execWithShellEnv } from "../../workspaces/utils/shell-env";
+function parseGitHubOwnerFromRemoteUrl(remoteUrl: string): string | null {
+ const trimmed = remoteUrl.trim();
+ const patterns = [
+ /^git@github\.com:(?[^/]+)\/[^/]+?(?:\.git)?$/,
+ /^ssh:\/\/git@github\.com\/(?[^/]+)\/[^/]+?(?:\.git)?$/,
+ /^https:\/\/github\.com\/(?[^/]+)\/[^/]+?(?:\.git)?\/?$/,
+ ];
+
+ for (const pattern of patterns) {
+ const match = pattern.exec(trimmed);
+ if (match?.groups?.owner) {
+ return match.groups.owner;
+ }
+ }
+
+ return null;
+}
+
/**
* Fetches the GitHub owner (user or org) for a repository using the `gh` CLI.
* Returns null if `gh` is not installed, not authenticated, or on error.
@@ -7,6 +26,21 @@ import { execWithShellEnv } from "../../workspaces/utils/shell-env";
export async function fetchGitHubOwner(
repoPath: string,
): Promise {
+ try {
+ const { stdout } = await execGitWithShellPath(
+ ["remote", "get-url", "origin"],
+ {
+ cwd: repoPath,
+ },
+ );
+ const owner = parseGitHubOwnerFromRemoteUrl(stdout);
+ if (owner) {
+ return owner;
+ }
+ } catch {
+ // Fall back to gh when no origin remote exists or the remote is not GitHub.
+ }
+
try {
const { stdout } = await execWithShellEnv(
"gh",
diff --git a/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts b/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts
new file mode 100644
index 00000000000..4fc5376d5ab
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts
@@ -0,0 +1,74 @@
+import path from "node:path";
+import { TRPCError } from "@trpc/server";
+import { buildReferenceGraph } from "main/lib/reference-graph";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+import { getWorkspace } from "../workspaces/utils/db-helpers";
+import { getWorkspacePath } from "../workspaces/utils/worktree";
+
+function resolveWorkspacePath(workspaceId: string): string {
+ const workspace = getWorkspace(workspaceId);
+ if (!workspace) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Workspace ${workspaceId} not found`,
+ });
+ }
+
+ const workspacePath = getWorkspacePath(workspace);
+ if (!workspacePath) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: `Workspace ${workspaceId} has no filesystem path`,
+ });
+ }
+
+ return workspacePath;
+}
+
+export const createReferenceGraphRouter = () => {
+ return router({
+ buildGraph: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ absolutePath: z.string(),
+ languageId: z.string(),
+ line: z.number().int().positive(),
+ column: z.number().int().positive(),
+ maxDepth: z.number().int().min(1).max(10).optional(),
+ maxNodes: z.number().int().min(1).max(500).optional(),
+ excludePatterns: z.array(z.string()).optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const workspacePath = resolveWorkspacePath(input.workspaceId);
+
+ // Ensure absolutePath is within the workspace (prevent path traversal)
+ const resolved = path.resolve(input.absolutePath);
+ if (
+ !resolved.startsWith(workspacePath + path.sep) &&
+ resolved !== workspacePath
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "absolutePath must be within the workspace",
+ });
+ }
+
+ const graph = await buildReferenceGraph({
+ workspaceId: input.workspaceId,
+ workspacePath,
+ absolutePath: input.absolutePath,
+ languageId: input.languageId,
+ line: input.line,
+ column: input.column,
+ maxDepth: input.maxDepth,
+ maxNodes: input.maxNodes,
+ excludePatterns: input.excludePatterns,
+ });
+
+ return graph;
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts
index 699cba29806..2dcee5896a1 100644
--- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts
@@ -3,12 +3,23 @@ import { TRPCError } from "@trpc/server";
import type { BrowserWindow, OpenDialogOptions } from "electron";
import { dialog } from "electron";
import {
+ deleteCustomRingtone,
getCustomRingtoneInfo,
getCustomRingtonePath,
importCustomRingtoneFromPath,
+ setCustomRingtoneDisplayName,
} from "main/lib/custom-ringtones";
import { playSoundFile } from "main/lib/play-sound";
import { getSoundPath } from "main/lib/sound-paths";
+import { getTempAudioPath } from "main/lib/temp-audio-protocol";
+import {
+ checkMissingBinaries,
+ cleanupTempAudio,
+ downloadFullYouTubeAudio,
+ importRingtoneFromYouTube,
+ installMissingBinaries,
+ YouTubeRingtoneError,
+} from "main/lib/youtube-ringtone";
import {
CUSTOM_RINGTONE_ID,
getRingtoneFilename,
@@ -170,6 +181,169 @@ export const createRingtoneRouter = (getWindow: () => BrowserWindow | null) => {
});
}
}),
+
+ /**
+ * Deletes the imported custom ringtone (audio file + metadata).
+ */
+ deleteCustom: publicProcedure.mutation(() => {
+ stopCurrentSound();
+ deleteCustomRingtone();
+ return { success: true as const };
+ }),
+
+ /**
+ * Renames the custom ringtone's display name.
+ */
+ renameCustom: publicProcedure
+ .input(z.object({ name: z.string().min(1).max(80) }))
+ .mutation(({ input }) => {
+ try {
+ setCustomRingtoneDisplayName(input.name);
+ const info = getCustomRingtoneInfo();
+ if (!info) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No custom ringtone to rename.",
+ });
+ }
+ return { ringtone: info };
+ } catch (error) {
+ if (error instanceof TRPCError) throw error;
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to rename custom ringtone",
+ });
+ }
+ }),
+
+ /**
+ * Check which required binaries (yt-dlp, ffmpeg) are missing.
+ */
+ checkBinaries: publicProcedure.query(async () => {
+ const missing = await checkMissingBinaries();
+ return { missing };
+ }),
+
+ /**
+ * Install yt-dlp and ffmpeg via Homebrew (macOS only).
+ */
+ installBinaries: publicProcedure.mutation(async () => {
+ try {
+ await installMissingBinaries();
+ return { success: true as const };
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to install dependencies",
+ });
+ }
+ }),
+
+ /**
+ * Download the full audio from a YouTube URL to a temp file.
+ * Returns a tempId for use with the superset-temp-audio protocol and video metadata.
+ */
+ downloadYouTubeAudio: publicProcedure
+ .input(z.object({ url: z.string().min(1) }))
+ .mutation(async ({ input }) => {
+ try {
+ const result = await downloadFullYouTubeAudio(input.url);
+ return {
+ tempId: result.tempId,
+ info: result.info,
+ };
+ } catch (error) {
+ if (error instanceof YouTubeRingtoneError) {
+ throw new TRPCError({
+ code:
+ error.code === "BINARY_MISSING" ||
+ error.code === "TIMEOUT" ||
+ error.code === "VIDEO_TOO_LONG"
+ ? "PRECONDITION_FAILED"
+ : "BAD_REQUEST",
+ message: error.message,
+ });
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to download YouTube audio",
+ });
+ }
+ }),
+
+ /**
+ * Clean up a previously downloaded temp audio file.
+ */
+ cleanupTempAudio: publicProcedure
+ .input(z.object({ tempId: z.string() }))
+ .mutation(async ({ input }) => {
+ await cleanupTempAudio(input.tempId);
+ return { success: true as const };
+ }),
+
+ /**
+ * Imports a custom ringtone by clipping a section of a YouTube video.
+ * Requires `yt-dlp` and `ffmpeg` to be installed on the user's machine.
+ */
+ importFromYouTube: publicProcedure
+ .input(
+ z.object({
+ url: z.string().min(1),
+ startSeconds: z
+ .number()
+ .min(0)
+ .max(60 * 60 * 12),
+ endSeconds: z
+ .number()
+ .min(0)
+ .max(60 * 60 * 12),
+ displayName: z.string().max(120).optional(),
+ thumbnailUrl: z.string().max(2048).optional(),
+ fadeInSeconds: z.number().min(0).max(10).optional(),
+ fadeOutSeconds: z.number().min(0).max(10).optional(),
+ playbackRate: z.number().min(0.5).max(2.0).optional(),
+ /** Client-side tempId from downloadYouTubeAudio – resolved to path server-side */
+ tempId: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ try {
+ const tempFilePath = input.tempId
+ ? (getTempAudioPath(input.tempId) ?? undefined)
+ : undefined;
+ const ringtone = await importRingtoneFromYouTube({
+ ...input,
+ tempFilePath,
+ });
+ return { ringtone };
+ } catch (error) {
+ if (error instanceof YouTubeRingtoneError) {
+ throw new TRPCError({
+ code:
+ error.code === "BINARY_MISSING" || error.code === "TIMEOUT"
+ ? "PRECONDITION_FAILED"
+ : "BAD_REQUEST",
+ message: error.message,
+ });
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to import YouTube ringtone",
+ });
+ }
+ }),
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/service-status.ts b/apps/desktop/src/lib/trpc/routers/service-status.ts
new file mode 100644
index 00000000000..442b3253038
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/service-status.ts
@@ -0,0 +1,30 @@
+import { observable } from "@trpc/server/observable";
+import { serviceStatusService } from "main/lib/service-status";
+import type { ServiceStatusSnapshot } from "shared/service-status-types";
+import { publicProcedure, router } from "..";
+
+export const createServiceStatusRouter = () => {
+ return router({
+ // No `getAll` query: the subscription emits the current state for every
+ // snapshot on connect, so the client gets the initial value without a
+ // separate round-trip (and we avoid the staleTime-Infinity / subscription
+ // race where the query would later clobber fresh subscription data).
+ onChange: publicProcedure.subscription(() => {
+ return observable((emit) => {
+ // Register the listener BEFORE emitting the initial snapshots so
+ // that a `change` fired between the two steps (e.g. a polling
+ // cycle completing while we iterate) isn't lost.
+ const onChange = (snapshot: ServiceStatusSnapshot) => {
+ emit.next(snapshot);
+ };
+ serviceStatusService.on("change", onChange);
+ for (const snapshot of serviceStatusService.getAll()) {
+ emit.next(snapshot);
+ }
+ return () => {
+ serviceStatusService.off("change", onChange);
+ };
+ });
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts
index 412a3265a23..ec824b96f3a 100644
--- a/apps/desktop/src/lib/trpc/routers/settings/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts
@@ -2,10 +2,13 @@ import {
type AgentCustomDefinition,
type AgentPresetOverrideEnvelope,
BRANCH_PREFIX_MODES,
+ BRANCH_SORT_ORDERS,
EXECUTION_MODES,
EXTERNAL_APPS,
FILE_OPEN_MODES,
NON_EDITOR_APPS,
+ POST_COMMIT_COMMANDS,
+ SMART_COMMIT_CHANGES_MODES,
settings,
TERMINAL_LINK_BEHAVIORS,
type TerminalPreset,
@@ -28,10 +31,14 @@ import {
DEFAULT_EXPOSE_HOST_SERVICE_VIA_RELAY,
DEFAULT_FILE_OPEN_MODE,
DEFAULT_OPEN_LINKS_IN_APP,
+ DEFAULT_PREVENT_AGENT_SLEEP,
+ DEFAULT_RIGHT_SIDEBAR_OPEN_VIEW_WIDTH,
DEFAULT_SHOW_PRESETS_BAR,
DEFAULT_SHOW_RESOURCE_MONITOR,
DEFAULT_TERMINAL_LINK_BEHAVIOR,
DEFAULT_USE_COMPACT_TERMINAL_ADD_BUTTON,
+ MAX_RIGHT_SIDEBAR_OPEN_VIEW_WIDTH,
+ MIN_RIGHT_SIDEBAR_OPEN_VIEW_WIDTH,
} from "shared/constants";
import { normalizePresetProjectIds } from "shared/preset-project-targeting";
import {
@@ -55,7 +62,12 @@ import {
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { loadToken } from "../auth/utils/auth-functions";
-import { getGitAuthorName, getGitHubUsername } from "../workspaces/utils/git";
+import {
+ getGitAuthorEmail,
+ getGitAuthorName,
+ getGitHubUsername,
+} from "../workspaces/utils/git";
+import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
import {
createCustomAgentInputSchema,
normalizeAgentPresetPatch,
@@ -595,6 +607,26 @@ export const createSettingsRouter = () => {
return { success: true };
}),
+ getPreventAgentSleep: publicProcedure.query(() => {
+ const row = getSettings();
+ return row.preventAgentSleep ?? DEFAULT_PREVENT_AGENT_SLEEP;
+ }),
+
+ setPreventAgentSleep: publicProcedure
+ .input(z.object({ enabled: z.boolean() }))
+ .mutation(({ input }) => {
+ localDb
+ .insert(settings)
+ .values({ id: 1, preventAgentSleep: input.enabled })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set: { preventAgentSleep: input.enabled },
+ })
+ .run();
+
+ return { success: true };
+ }),
+
getExposeHostServiceViaRelay: publicProcedure.query(() => {
const row = getSettings();
return (
@@ -714,6 +746,36 @@ export const createSettingsRouter = () => {
return { success: true };
}),
+ getRightSidebarOpenViewWidth: publicProcedure.query(() => {
+ const row = getSettings();
+ return (
+ row.rightSidebarOpenViewWidth ?? DEFAULT_RIGHT_SIDEBAR_OPEN_VIEW_WIDTH
+ );
+ }),
+
+ setRightSidebarOpenViewWidth: publicProcedure
+ .input(
+ z.object({
+ width: z
+ .number()
+ .int()
+ .min(MIN_RIGHT_SIDEBAR_OPEN_VIEW_WIDTH)
+ .max(MAX_RIGHT_SIDEBAR_OPEN_VIEW_WIDTH),
+ }),
+ )
+ .mutation(({ input }) => {
+ localDb
+ .insert(settings)
+ .values({ id: 1, rightSidebarOpenViewWidth: input.width })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set: { rightSidebarOpenViewWidth: input.width },
+ })
+ .run();
+
+ return { success: true };
+ }),
+
getAutoApplyDefaultPreset: publicProcedure.query(() => {
const row = getSettings();
return row.autoApplyDefaultPreset ?? DEFAULT_AUTO_APPLY_DEFAULT_PRESET;
@@ -778,13 +840,36 @@ export const createSettingsRouter = () => {
getGitInfo: publicProcedure.query(async () => {
const githubUsername = await getGitHubUsername();
const authorName = await getGitAuthorName();
+ const authorEmail = await getGitAuthorEmail();
return {
githubUsername,
authorName,
+ authorEmail,
authorPrefix: authorName?.toLowerCase().replace(/\s+/g, "-") ?? null,
};
}),
+ setGlobalGitUserConfig: publicProcedure
+ .input(
+ z.object({
+ name: z.string().trim().min(1, "Name is required"),
+ email: z
+ .string()
+ .trim()
+ .min(1, "Email is required")
+ .email("Must be a valid email"),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ // Write to the user's global git config so the identity is
+ // picked up by every future repository. `simple-git` resolves
+ // the same path as `git config --global`.
+ const git = await getSimpleGitWithShellPath();
+ await git.addConfig("user.name", input.name, false, "global");
+ await git.addConfig("user.email", input.email, false, "global");
+ return { success: true };
+ }),
+
getDeleteLocalBranch: publicProcedure.query(() => {
const row = getSettings();
return row.deleteLocalBranch ?? false;
@@ -805,6 +890,116 @@ export const createSettingsRouter = () => {
return { success: true };
}),
+ getSmartCommit: publicProcedure.query(() => {
+ const row = getSettings();
+ return {
+ enabled: row.enableSmartCommit ?? false,
+ changes: row.smartCommitChanges ?? "all",
+ };
+ }),
+
+ setSmartCommit: publicProcedure
+ .input(
+ z.object({
+ enabled: z.boolean(),
+ changes: z.enum(SMART_COMMIT_CHANGES_MODES),
+ }),
+ )
+ .mutation(({ input }) => {
+ localDb
+ .insert(settings)
+ .values({
+ id: 1,
+ enableSmartCommit: input.enabled,
+ smartCommitChanges: input.changes,
+ })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set: {
+ enableSmartCommit: input.enabled,
+ smartCommitChanges: input.changes,
+ },
+ })
+ .run();
+
+ return { success: true };
+ }),
+
+ getAutoStash: publicProcedure.query(() => {
+ const row = getSettings();
+ return row.autoStash ?? false;
+ }),
+
+ setAutoStash: publicProcedure
+ .input(z.object({ enabled: z.boolean() }))
+ .mutation(({ input }) => {
+ localDb
+ .insert(settings)
+ .values({ id: 1, autoStash: input.enabled })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set: { autoStash: input.enabled },
+ })
+ .run();
+
+ return { success: true };
+ }),
+
+ getBranchSortOrder: publicProcedure.query(() => {
+ const row = getSettings();
+ return {
+ sortOrder: row.branchSortOrder ?? "committerdate",
+ pinDefault: row.pinDefaultBranch ?? true,
+ };
+ }),
+
+ setBranchSortOrder: publicProcedure
+ .input(
+ z.object({
+ sortOrder: z.enum(BRANCH_SORT_ORDERS),
+ pinDefault: z.boolean(),
+ }),
+ )
+ .mutation(({ input }) => {
+ localDb
+ .insert(settings)
+ .values({
+ id: 1,
+ branchSortOrder: input.sortOrder,
+ pinDefaultBranch: input.pinDefault,
+ })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set: {
+ branchSortOrder: input.sortOrder,
+ pinDefaultBranch: input.pinDefault,
+ },
+ })
+ .run();
+
+ return { success: true };
+ }),
+
+ getPostCommitCommand: publicProcedure.query(() => {
+ const row = getSettings();
+ return row.postCommitCommand ?? "none";
+ }),
+
+ setPostCommitCommand: publicProcedure
+ .input(z.object({ command: z.enum(POST_COMMIT_COMMANDS) }))
+ .mutation(({ input }) => {
+ localDb
+ .insert(settings)
+ .values({ id: 1, postCommitCommand: input.command })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set: { postCommitCommand: input.command },
+ })
+ .run();
+
+ return { success: true };
+ }),
+
getNotificationSoundsMuted: publicProcedure.query(() => {
const row = getSettings();
return row.notificationSoundsMuted ?? false;
@@ -965,6 +1160,117 @@ export const createSettingsRouter = () => {
return { success: true };
}),
+ getIndentRainbow: publicProcedure.query(() => {
+ const row = getSettings();
+ const colors = row.indentRainbowColors
+ ? (JSON.parse(row.indentRainbowColors) as string[])
+ : null;
+ return {
+ enabled: row.indentRainbowEnabled ?? false,
+ colors,
+ };
+ }),
+
+ setIndentRainbow: publicProcedure
+ .input(
+ z.object({
+ enabled: z.boolean().optional(),
+ colors: z.array(z.string()).nullable().optional(),
+ }),
+ )
+ .mutation(({ input }) => {
+ const set: Record = {};
+ if (input.enabled !== undefined) {
+ set.indentRainbowEnabled = input.enabled;
+ }
+ if (input.colors !== undefined) {
+ set.indentRainbowColors = input.colors
+ ? JSON.stringify(input.colors)
+ : null;
+ }
+
+ if (Object.keys(set).length === 0) {
+ return { success: true };
+ }
+
+ localDb
+ .insert(settings)
+ .values({ id: 1, ...set })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set,
+ })
+ .run();
+
+ return { success: true };
+ }),
+
+ getTrailingSpaces: publicProcedure.query(() => {
+ const row = getSettings();
+ return {
+ enabled: row.trailingSpacesEnabled ?? false,
+ color: row.trailingSpacesColor ?? null,
+ };
+ }),
+
+ setTrailingSpaces: publicProcedure
+ .input(
+ z.object({
+ enabled: z.boolean().optional(),
+ color: z.string().nullable().optional(),
+ }),
+ )
+ .mutation(({ input }) => {
+ const set: Record = {};
+ if (input.enabled !== undefined) {
+ set.trailingSpacesEnabled = input.enabled;
+ }
+ if (input.color !== undefined) {
+ set.trailingSpacesColor = input.color;
+ }
+
+ if (Object.keys(set).length === 0) {
+ return { success: true };
+ }
+
+ localDb
+ .insert(settings)
+ .values({ id: 1, ...set })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set,
+ })
+ .run();
+
+ return { success: true };
+ }),
+
+ getReferenceGraph: publicProcedure.query(() => {
+ const row = getSettings();
+ return {
+ enabled: row.referenceGraphEnabled ?? true,
+ };
+ }),
+
+ setReferenceGraph: publicProcedure
+ .input(
+ z.object({
+ enabled: z.boolean(),
+ }),
+ )
+ .mutation(({ input }) => {
+ localDb
+ .insert(settings)
+ .values({ id: 1, referenceGraphEnabled: input.enabled })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set: { referenceGraphEnabled: input.enabled },
+ })
+ .run();
+
+ return { success: true };
+ }),
+
// TODO: remove telemetry procedures once telemetry_enabled column is dropped
getTelemetryEnabled: publicProcedure.query(() => {
return true;
diff --git a/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts b/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts
new file mode 100644
index 00000000000..bf0addef948
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts
@@ -0,0 +1,44 @@
+import type { WindowManager } from "main/lib/window-manager";
+import { z } from "zod";
+import { publicProcedure, router } from "..";
+import { loadToken } from "./auth/utils/auth-functions";
+
+export const createTabTearoffRouter = (wm: WindowManager) => {
+ return router({
+ create: publicProcedure
+ .input(
+ z.object({
+ tab: z.unknown(),
+ panes: z.record(z.string(), z.unknown()),
+ workspaceId: z.string(),
+ screenX: z.number(),
+ screenY: z.number(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const windowId = `tearoff-${Date.now()}`;
+
+ // Store data FIRST so it's available when preload requests it
+ wm.setPendingTearoffData(windowId, {
+ tab: input.tab,
+ panes: input.panes,
+ workspaceId: input.workspaceId,
+ });
+
+ // Pre-load auth token so tearoff window can skip async auth hydration
+ const { token, expiresAt } = await loadToken();
+ wm.setPendingAuthToken(
+ windowId,
+ token && expiresAt ? { token, expiresAt } : null,
+ );
+
+ wm.createTearoffWindow({
+ windowId,
+ screenX: input.screenX,
+ screenY: input.screenY,
+ });
+
+ return { windowId };
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
index dc01978fb40..872557c44db 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
@@ -419,6 +419,20 @@ export const createTerminalRouter = () => {
return restartDaemonShared();
}),
+ getSuggestions: publicProcedure
+ .input(z.object({ prefix: z.string(), offset: z.number().optional() }))
+ .query(async ({ input }) => {
+ const { getSuggestions } = await import("main/lib/shell-history");
+ return getSuggestions(input.prefix, input.offset ?? 0);
+ }),
+
+ deleteHistorySuggestion: publicProcedure
+ .input(z.object({ command: z.string().min(1) }))
+ .mutation(async ({ input }) => {
+ const { deleteHistoryEntry } = await import("main/lib/shell-history");
+ await deleteHistoryEntry(input.command);
+ }),
+
getSession: publicProcedure
.input(z.string())
.query(async ({ input: paneId }) => {
diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts
index 40b752eb072..c8cd30db854 100644
--- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts
@@ -10,11 +10,11 @@ import { publicProcedure, router } from "../..";
*/
const fileViewerStateSchema = z.object({
filePath: z.string(),
- viewMode: z.enum(["rendered", "raw", "diff"]),
+ viewMode: z.enum(["rendered", "raw", "diff", "conflict"]),
isPinned: z.boolean(),
diffLayout: z.enum(["inline", "side-by-side"]),
diffCategory: z
- .enum(["against-base", "committed", "staged", "unstaged"])
+ .enum(["against-base", "committed", "staged", "unstaged", "conflicted"])
.optional(),
commitHash: z.string().optional(),
oldPath: z.string().optional(),
@@ -36,7 +36,18 @@ const chatLaunchConfigSchema = z.object({
const paneSchema = z.object({
id: z.string(),
tabId: z.string(),
- type: z.enum(["terminal", "webview", "file-viewer", "chat", "devtools"]),
+ type: z.enum([
+ "terminal",
+ "webview",
+ "file-viewer",
+ "chat",
+ "devtools",
+ "git-graph",
+ "database-explorer",
+ "action-logs",
+ "vscode-extension",
+ "reference-graph",
+ ]),
name: z.string(),
isNew: z.boolean().optional(),
status: z.enum(["idle", "working", "permission", "review"]).optional(),
@@ -79,6 +90,50 @@ const paneSchema = z.object({
targetPaneId: z.string(),
})
.optional(),
+ databaseExplorer: z
+ .object({
+ connectionId: z.string().nullable(),
+ })
+ .optional(),
+ actionLogs: z
+ .object({
+ jobs: z.array(
+ z.object({
+ detailsUrl: z.string(),
+ name: z.string(),
+ status: z.enum([
+ "success",
+ "failure",
+ "pending",
+ "skipped",
+ "cancelled",
+ ]),
+ }),
+ ),
+ initialJobIndex: z.number().optional(),
+ })
+ .optional(),
+ vscodeExtension: z
+ .object({
+ viewType: z.string(),
+ extensionId: z.string(),
+ source: z.enum(["view", "panel"]).optional(),
+ sessionId: z.string().optional(),
+ })
+ .optional(),
+ gitGraph: z
+ .object({
+ worktreePath: z.string(),
+ })
+ .optional(),
+ referenceGraph: z
+ .object({
+ absolutePath: z.string(),
+ languageId: z.string(),
+ line: z.number(),
+ column: z.number(),
+ })
+ .optional(),
workspaceRun: z
.object({
workspaceId: z.string(),
@@ -204,7 +259,98 @@ const terminalColorsSchema = z.object({
});
/**
- * Zod schema for Theme
+ * Zod schema for editor chrome colors.
+ * Mirrors EditorColors in shared/themes/types.ts.
+ */
+const editorColorsSchema = z.object({
+ background: z.string(),
+ foreground: z.string(),
+ border: z.string(),
+ cursor: z.string(),
+ gutterBackground: z.string(),
+ gutterForeground: z.string(),
+ activeLine: z.string(),
+ selection: z.string(),
+ search: z.string(),
+ searchActive: z.string(),
+ panel: z.string(),
+ panelBorder: z.string(),
+ panelInputBackground: z.string(),
+ panelInputForeground: z.string(),
+ panelInputBorder: z.string(),
+ panelButtonBackground: z.string(),
+ panelButtonForeground: z.string(),
+ panelButtonBorder: z.string(),
+ diffBuffer: z.string(),
+ diffHover: z.string(),
+ diffSeparator: z.string(),
+ addition: z.string(),
+ deletion: z.string(),
+ modified: z.string(),
+});
+
+/**
+ * Zod schema for editor syntax colors.
+ * Mirrors EditorSyntaxColors in shared/themes/types.ts.
+ */
+const editorSyntaxColorsSchema = z.object({
+ plainText: z.string(),
+ comment: z.string(),
+ docComment: z.string(),
+ keyword: z.string(),
+ controlKeyword: z.string(),
+ storageKeyword: z.string(),
+ string: z.string(),
+ escape: z.string(),
+ number: z.string(),
+ functionCall: z.string(),
+ variableName: z.string(),
+ variableProperty: z.string(),
+ typeName: z.string(),
+ className: z.string(),
+ constant: z.string(),
+ regexp: z.string(),
+ tagName: z.string(),
+ attributeName: z.string(),
+ invalid: z.string(),
+ annotation: z.string(),
+ operator: z.string(),
+ punctuation: z.string(),
+ markdownHeading: z.string(),
+ markdownEmphasis: z.string(),
+ markdownStrong: z.string(),
+ markdownStrikethrough: z.string(),
+ markdownLink: z.string(),
+ markdownUrl: z.string(),
+ markdownCode: z.string(),
+ markdownQuote: z.string(),
+ markdownList: z.string(),
+ markdownSeparator: z.string(),
+ meta: z.string(),
+});
+
+/**
+ * Zod schema for EditorThemeOverrides.
+ * Both `colors` and `syntax` accept partial shapes so imported themes that
+ * only override a subset of tokens still round-trip through persistence.
+ */
+const editorThemeOverridesSchema = z.object({
+ colors: editorColorsSchema.partial().optional(),
+ syntax: editorSyntaxColorsSchema.partial().optional(),
+});
+
+/**
+ * Zod schema for Theme.
+ *
+ * `terminal` and `editor` are optional to match the Theme interface in
+ * shared/themes/types.ts. If they are missing, the app falls back to
+ * defaults derived from the theme type and base UI colors.
+ *
+ * Every field declared on the Theme interface MUST appear here — Zod's
+ * default `z.object()` silently strips unknown keys during
+ * `.input(...)` validation on the `theme.set` tRPC mutation, which
+ * means any missing field would be dropped on every persist cycle and
+ * lost after app restart.
*/
const themeSchema = z.object({
id: z.string(),
@@ -214,7 +360,8 @@ const themeSchema = z.object({
description: z.string().optional(),
type: z.enum(["dark", "light"]),
ui: uiColorsSchema,
- terminal: terminalColorsSchema,
+ terminal: terminalColorsSchema.optional(),
+ editor: editorThemeOverridesSchema.optional(),
isBuiltIn: z.boolean().optional(),
isCustom: z.boolean().optional(),
});
@@ -229,6 +376,11 @@ const themeStateSchema = z.object({
systemDarkThemeId: z.string().optional(),
});
+export const __testing = {
+ themeSchema,
+ themeStateSchema,
+};
+
/**
* UI State router - manages tabs and theme persistence via lowdb
*/
diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/ui-state-schema.test.ts b/apps/desktop/src/lib/trpc/routers/ui-state/ui-state-schema.test.ts
new file mode 100644
index 00000000000..839ed02c892
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/ui-state/ui-state-schema.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from "bun:test";
+import { darkTheme } from "shared/themes";
+import { __testing } from "./index";
+
+const { themeSchema, themeStateSchema } = __testing;
+
+describe("themeSchema", () => {
+ it("preserves the editor field on a custom theme", () => {
+ const input = {
+ id: "my-custom",
+ name: "My Custom",
+ type: "dark" as const,
+ ui: darkTheme.ui,
+ terminal: darkTheme.terminal,
+ editor: {
+ colors: {
+ background: "#111111",
+ foreground: "#eeeeee",
+ },
+ syntax: {
+ keyword: "#ff6688",
+ string: "#88ff66",
+ },
+ },
+ isCustom: true,
+ };
+
+ const parsed = themeSchema.parse(input);
+
+ expect(parsed.editor).toBeDefined();
+ expect(parsed.editor?.colors?.background).toBe("#111111");
+ expect(parsed.editor?.colors?.foreground).toBe("#eeeeee");
+ expect(parsed.editor?.syntax?.keyword).toBe("#ff6688");
+ expect(parsed.editor?.syntax?.string).toBe("#88ff66");
+ });
+
+ it("accepts a theme without terminal overrides", () => {
+ const input = {
+ id: "no-terminal",
+ name: "No Terminal",
+ type: "light" as const,
+ ui: darkTheme.ui,
+ isCustom: true,
+ };
+
+ expect(() => themeSchema.parse(input)).not.toThrow();
+ });
+
+ it("accepts a theme without editor overrides", () => {
+ const input = {
+ id: "no-editor",
+ name: "No Editor",
+ type: "dark" as const,
+ ui: darkTheme.ui,
+ terminal: darkTheme.terminal,
+ isCustom: true,
+ };
+
+ const parsed = themeSchema.parse(input);
+ expect(parsed.editor).toBeUndefined();
+ });
+
+ it("preserves partial editor.colors overrides", () => {
+ const input = {
+ id: "partial-colors",
+ name: "Partial Colors",
+ type: "dark" as const,
+ ui: darkTheme.ui,
+ editor: {
+ colors: {
+ addition: "#00ff00",
+ },
+ },
+ };
+
+ const parsed = themeSchema.parse(input);
+ expect(parsed.editor?.colors?.addition).toBe("#00ff00");
+ });
+
+ it("preserves partial editor.syntax overrides", () => {
+ const input = {
+ id: "partial-syntax",
+ name: "Partial Syntax",
+ type: "dark" as const,
+ ui: darkTheme.ui,
+ editor: {
+ syntax: {
+ markdownHeading: "#abcdef",
+ },
+ },
+ };
+
+ const parsed = themeSchema.parse(input);
+ expect(parsed.editor?.syntax?.markdownHeading).toBe("#abcdef");
+ });
+
+ it("round-trips a full theme state with editor overrides via themeStateSchema", () => {
+ const customTheme = {
+ id: "round-trip",
+ name: "Round Trip",
+ type: "dark" as const,
+ ui: darkTheme.ui,
+ editor: {
+ colors: { background: "#000000" },
+ syntax: { keyword: "#ffffff" },
+ },
+ isCustom: true,
+ };
+
+ const parsed = themeStateSchema.parse({
+ activeThemeId: "round-trip",
+ customThemes: [customTheme],
+ });
+
+ expect(parsed.customThemes[0]?.editor?.colors?.background).toBe("#000000");
+ expect(parsed.customThemes[0]?.editor?.syntax?.keyword).toBe("#ffffff");
+ });
+});
diff --git a/apps/desktop/src/lib/trpc/routers/vibrancy.ts b/apps/desktop/src/lib/trpc/routers/vibrancy.ts
new file mode 100644
index 00000000000..38a0e5626c3
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/vibrancy.ts
@@ -0,0 +1,92 @@
+import { observable } from "@trpc/server/observable";
+import { nativeTheme } from "electron";
+import { appState } from "main/lib/app-state";
+import {
+ applyVibrancy,
+ DEFAULT_VIBRANCY_STATE,
+ isNativeContinuousBlurSupported,
+ isVibrancySupported,
+ normalizeVibrancyState,
+ type VibrancyBlurLevel,
+ type VibrancyState,
+} from "main/lib/vibrancy";
+import { VIBRANCY_EVENTS, vibrancyEmitter } from "main/lib/vibrancy/emitter";
+import type { WindowManager } from "main/lib/window-manager";
+import { z } from "zod";
+import { publicProcedure, router } from "..";
+
+const blurLevelSchema: z.ZodType = z.enum([
+ "subtle",
+ "standard",
+ "strong",
+ "ultra",
+]);
+
+const vibrancyInputSchema = z.object({
+ enabled: z.boolean().optional(),
+ opacity: z.number().int().min(0).max(100).optional(),
+ blurLevel: blurLevelSchema.optional(),
+ blurRadius: z.number().min(0).max(100).optional(),
+});
+
+function getCurrentState(): VibrancyState {
+ const stored = appState.data?.vibrancyState;
+ // Merge over defaults so older on-disk states (written before we added
+ // blurRadius) still produce a complete VibrancyState. Otherwise the
+ // missing field would round-trip as `undefined` and the slider would
+ // appear to reset on every restart.
+ return { ...DEFAULT_VIBRANCY_STATE, ...stored };
+}
+
+async function writeState(next: VibrancyState): Promise {
+ if (!appState.data) return;
+ appState.data.vibrancyState = next;
+ await appState.write();
+}
+
+function broadcastVibrancy(wm: WindowManager, state: VibrancyState): void {
+ const isDark = nativeTheme.shouldUseDarkColors;
+ for (const window of wm.getAll().values()) {
+ applyVibrancy(window, state, isDark);
+ }
+}
+
+export const createVibrancyRouter = (wm: WindowManager) => {
+ return router({
+ getSupported: publicProcedure.query(() => {
+ return {
+ supported: isVibrancySupported(),
+ nativeBlurSupported: isNativeContinuousBlurSupported(),
+ };
+ }),
+
+ get: publicProcedure.query(() => {
+ return getCurrentState();
+ }),
+
+ set: publicProcedure
+ .input(vibrancyInputSchema)
+ .mutation(async ({ input }) => {
+ const current = getCurrentState();
+ const next = normalizeVibrancyState(input, current);
+ await writeState(next);
+ broadcastVibrancy(wm, next);
+ vibrancyEmitter.emit(VIBRANCY_EVENTS.CHANGED, next);
+ return next;
+ }),
+
+ onChanged: publicProcedure.subscription(() => {
+ return observable((emit) => {
+ const handler = (state: VibrancyState) => {
+ emit.next(state);
+ };
+ vibrancyEmitter.on(VIBRANCY_EVENTS.CHANGED, handler);
+ return () => {
+ vibrancyEmitter.off(VIBRANCY_EVENTS.CHANGED, handler);
+ };
+ });
+ }),
+ });
+};
+
+export type VibrancyRouter = ReturnType;
diff --git a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts
new file mode 100644
index 00000000000..09b48e05c51
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts
@@ -0,0 +1,550 @@
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { pipeline } from "node:stream/promises";
+import { TRPCError } from "@trpc/server";
+import { observable } from "@trpc/server/observable";
+import {
+ getActivePanel,
+ getActiveView,
+} from "main/lib/vscode-shim/api/webview";
+import {
+ clearWebviewHtml,
+ getWebviewUrl,
+ hasWebviewHtml,
+ setCustomThemeCss,
+ setWebviewHtml,
+} from "main/lib/vscode-shim/api/webview-server";
+import { getExtensionHostManager } from "main/lib/vscode-shim/extension-host-manager";
+import type { WebviewBridgeEvent } from "main/lib/vscode-shim/webview-bridge";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+
+/** Known VS Code extensions that can be managed */
+const KNOWN_EXTENSIONS = [
+ {
+ id: "anthropic.claude-code",
+ name: "Claude Code",
+ publisher: "Anthropic",
+ description: "AI coding assistant by Anthropic",
+ marketplaceUrl:
+ "https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code",
+ viewType: "claudeVSCodeSidebar",
+ },
+ {
+ id: "openai.chatgpt",
+ name: "ChatGPT / Codex",
+ publisher: "OpenAI",
+ description: "AI coding assistant by OpenAI",
+ marketplaceUrl:
+ "https://marketplace.visualstudio.com/items?itemName=openai.chatgpt",
+ viewType: "chatgpt.sidebarView",
+ },
+] as const;
+
+function getExtensionsDir(): string {
+ return path.join(os.homedir(), ".vscode", "extensions");
+}
+
+/** Persistent enabled/disabled state for extensions */
+function getEnabledConfigPath(): string {
+ const userDataPath = (() => {
+ try {
+ return require("electron").app.getPath("userData");
+ } catch {
+ return path.join(os.homedir(), ".superset-desktop");
+ }
+ })();
+ return path.join(userDataPath, "vscode-extensions-enabled.json");
+}
+
+function readEnabledConfig(): Record {
+ try {
+ const p = getEnabledConfigPath();
+ if (fs.existsSync(p)) {
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
+ }
+ } catch {}
+ // All enabled by default
+ return {};
+}
+
+function writeEnabledConfig(config: Record): void {
+ try {
+ const p = getEnabledConfigPath();
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, JSON.stringify(config, null, 2));
+ } catch {}
+}
+
+function isExtensionEnabled(extensionId: string): boolean {
+ const config = readEnabledConfig();
+ return config[extensionId] !== false; // enabled by default
+}
+
+function isExtensionInstalled(extensionId: string): boolean {
+ const dir = getExtensionsDir();
+ if (!fs.existsSync(dir)) return false;
+ const entries = fs.readdirSync(dir);
+ return entries.some((entry) =>
+ entry.toLowerCase().startsWith(extensionId.toLowerCase()),
+ );
+}
+
+/**
+ * Download a VS Code extension from the marketplace and extract to extensions dir.
+ * Uses the VS Code Marketplace Gallery API to fetch the .vsix package.
+ */
+async function downloadAndInstallExtension(extensionId: string): Promise {
+ // Validate against known extensions whitelist
+ if (!KNOWN_EXTENSIONS.some((e) => e.id === extensionId)) {
+ throw new Error(`Unknown extension: ${extensionId}`);
+ }
+
+ // Strict format validation
+ if (!/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/.test(extensionId)) {
+ throw new Error(`Invalid extension ID format: ${extensionId}`);
+ }
+
+ const [publisher, name] = extensionId.split(".");
+ if (!publisher || !name) {
+ throw new Error(`Invalid extension ID: ${extensionId}`);
+ }
+
+ const extensionsDir = getExtensionsDir();
+ fs.mkdirSync(extensionsDir, { recursive: true });
+
+ // Step 1: Query marketplace for latest version + download URL
+ const queryBody = JSON.stringify({
+ filters: [
+ {
+ criteria: [{ filterType: 7, value: `${publisher}.${name}` }],
+ },
+ ],
+ flags: 0x200 | 0x1, // IncludeFiles | IncludeVersions
+ });
+
+ const queryResponse = await fetch(
+ "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json;api-version=6.0-preview.1",
+ },
+ body: queryBody,
+ },
+ );
+
+ if (!queryResponse.ok) {
+ throw new Error(`Marketplace query failed: ${queryResponse.status}`);
+ }
+
+ const queryData = (await queryResponse.json()) as {
+ results: Array<{
+ extensions: Array<{
+ versions: Array<{
+ version: string;
+ targetPlatform?: string;
+ files: Array<{ assetType: string; source: string }>;
+ }>;
+ }>;
+ }>;
+ };
+
+ const ext = queryData.results?.[0]?.extensions?.[0];
+ if (!ext) {
+ throw new Error(`Extension not found: ${extensionId}`);
+ }
+
+ // Find the best matching version (prefer platform-specific)
+ const platform = `${process.platform}-${process.arch}`;
+ const platformVersion = ext.versions.find(
+ (v) => v.targetPlatform === platform,
+ );
+ const universalVersion = ext.versions.find(
+ (v) => !v.targetPlatform || v.targetPlatform === "universal",
+ );
+ const version = platformVersion ?? universalVersion ?? ext.versions[0];
+ if (!version) {
+ throw new Error(`No version found for ${extensionId}`);
+ }
+
+ // Find VSIX download URL
+ const vsixAsset = version.files.find(
+ (f) => f.assetType === "Microsoft.VisualStudio.Services.VSIXPackage",
+ );
+ if (!vsixAsset) {
+ throw new Error(`No VSIX package found for ${extensionId}`);
+ }
+
+ // Step 2: Download .vsix
+ const vsixResponse = await fetch(vsixAsset.source);
+ if (!vsixResponse.ok || !vsixResponse.body) {
+ throw new Error(`VSIX download failed: ${vsixResponse.status}`);
+ }
+
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vscode-ext-"));
+ const vsixPath = path.join(tmpDir, `${extensionId}.vsix`);
+
+ const fileStream = fs.createWriteStream(vsixPath);
+ // @ts-expect-error - Node fetch body is a ReadableStream
+ await pipeline(vsixResponse.body, fileStream);
+
+ // Step 3: Extract .vsix (it's a zip file)
+ const targetSuffix = version.targetPlatform
+ ? `-${version.targetPlatform}`
+ : "";
+ const extDir = path.join(
+ extensionsDir,
+ `${publisher}.${name}-${version.version}${targetSuffix}`,
+ );
+
+ try {
+ if (fs.existsSync(extDir)) {
+ fs.rmSync(extDir, { recursive: true });
+ }
+ fs.mkdirSync(extDir, { recursive: true });
+
+ // Extract .vsix using spawnSync (no shell injection risk)
+ const extractDir = path.join(tmpDir, "extracted");
+ const unzipResult = spawnSync(
+ "unzip",
+ ["-q", "-o", vsixPath, "-d", extractDir],
+ {
+ stdio: "pipe",
+ },
+ );
+ if (unzipResult.status !== 0) {
+ throw new Error(`unzip failed: ${unzipResult.stderr?.toString()}`);
+ }
+
+ // Copy extension content using Node.js fs (no shell commands)
+ const extractedExtDir = path.join(extractDir, "extension");
+ if (fs.existsSync(extractedExtDir)) {
+ fs.cpSync(extractedExtDir, extDir, { recursive: true });
+ }
+
+ // Copy vsixmanifest if present
+ const vsixManifest = path.join(extractDir, "extension.vsixmanifest");
+ if (fs.existsSync(vsixManifest)) {
+ fs.copyFileSync(vsixManifest, path.join(extDir, ".vsixmanifest"));
+ }
+ } finally {
+ // Always cleanup temp directory
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+}
+
+async function waitForWebviewHtml(
+ viewId: string,
+ timeoutMs = 5000,
+ pollIntervalMs = 100,
+): Promise {
+ if (hasWebviewHtml(viewId)) {
+ return true;
+ }
+
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
+ if (hasWebviewHtml(viewId)) {
+ return true;
+ }
+ }
+
+ return hasWebviewHtml(viewId);
+}
+
+export const createVscodeExtensionsRouter = () => {
+ return router({
+ /** Get all known extensions with their install/active status */
+ getKnownExtensions: publicProcedure.query(() => {
+ const manager = getExtensionHostManager();
+ const hasRunningExtensionHost =
+ manager.getRunningWorkspaceIds().length > 0;
+ return KNOWN_EXTENSIONS.map((ext) => {
+ const installed = isExtensionInstalled(ext.id);
+ const enabled = isExtensionEnabled(ext.id);
+ return {
+ ...ext,
+ installed,
+ enabled,
+ active: installed && enabled && hasRunningExtensionHost,
+ };
+ });
+ }),
+
+ /** Resolve a webview view for a given viewType, returns viewId + HTML */
+ resolveWebview: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ workspacePath: z.string(),
+ viewType: z.string(),
+ extensionPath: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const manager = getExtensionHostManager();
+
+ // Start worker for this workspace if not already running
+ if (!manager.isRunning(input.workspaceId)) {
+ await manager.start(input.workspaceId, input.workspacePath);
+ }
+
+ const result = await manager.resolveWebview(
+ input.workspaceId,
+ input.viewType,
+ input.extensionPath,
+ );
+
+ if (!result.viewId) {
+ return { viewId: null, url: null };
+ }
+
+ if (result.html) {
+ setWebviewHtml(result.viewId, result.html);
+ }
+
+ const url = getWebviewUrl(result.viewId);
+ return { viewId: result.viewId, url };
+ }),
+
+ /** Attach to an existing webview session by viewId/panelId */
+ attachWebview: publicProcedure
+ .input(
+ z.object({
+ viewId: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const target =
+ getActiveView(input.viewId) ?? getActivePanel(input.viewId);
+ if (!target) {
+ return { viewId: null, url: null };
+ }
+
+ const hasHtml = await waitForWebviewHtml(input.viewId);
+ if (!hasHtml) {
+ return { viewId: null, url: null };
+ }
+
+ return { viewId: input.viewId, url: getWebviewUrl(input.viewId) };
+ }),
+
+ /** Dispose an existing panel-backed webview session */
+ disposeWebview: publicProcedure
+ .input(
+ z.object({
+ viewId: z.string(),
+ }),
+ )
+ .mutation(({ input }) => {
+ const panel = getActivePanel(input.viewId);
+ if (!panel) {
+ clearWebviewHtml(input.viewId);
+ return { success: false };
+ }
+
+ panel.dispose();
+ clearWebviewHtml(input.viewId);
+ return { success: true };
+ }),
+
+ /** Get current webview HTML */
+ getWebviewHtml: publicProcedure
+ .input(z.object({ viewType: z.string() }))
+ .query(() => {
+ return null;
+ }),
+
+ /** Send a message from renderer to extension webview */
+ postMessageToExtension: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ viewId: z.string(),
+ message: z.unknown(),
+ }),
+ )
+ .mutation(({ input }) => {
+ const manager = getExtensionHostManager();
+ manager.postMessageToExtension(
+ input.workspaceId,
+ input.viewId,
+ input.message,
+ );
+ return { success: true };
+ }),
+
+ /** Enable or disable an extension (persisted, requires restart for full effect) */
+ setExtensionEnabled: publicProcedure
+ .input(
+ z.object({
+ extensionId: z.string(),
+ enabled: z.boolean(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ if (!KNOWN_EXTENSIONS.some((e) => e.id === input.extensionId)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Unknown extension",
+ });
+ }
+ const config = readEnabledConfig();
+ config[input.extensionId] = input.enabled;
+ writeEnabledConfig(config);
+
+ return { success: true, needsRestart: true };
+ }),
+
+ /** Set custom theme CSS for webview rendering (null = use default dark theme) */
+ setThemeCss: publicProcedure
+ .input(z.object({ css: z.string().nullable() }))
+ .mutation(({ input }) => {
+ setCustomThemeCss(input.css);
+ return { success: true };
+ }),
+
+ /** Set the workspace folder path for extensions */
+ setWorkspacePath: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ workspacePath: z.string(),
+ }),
+ )
+ .mutation(({ input }) => {
+ const manager = getExtensionHostManager();
+ manager.setWorkspacePath(input.workspaceId, input.workspacePath);
+ return { success: true };
+ }),
+
+ /** Notify main process of active file change (for activeTextEditor) */
+ setActiveEditor: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ filePath: z.string().nullable(),
+ languageId: z.string().optional(),
+ }),
+ )
+ .mutation(({ input }) => {
+ const manager = getExtensionHostManager();
+ manager.setActiveEditor(
+ input.workspaceId,
+ input.filePath,
+ input.languageId,
+ );
+ return { success: true };
+ }),
+
+ /** Download and install an extension from the VS Code Marketplace */
+ installExtension: publicProcedure
+ .input(z.object({ extensionId: z.string() }))
+ .mutation(async ({ input }) => {
+ await downloadAndInstallExtension(input.extensionId);
+ return { success: true };
+ }),
+
+ /** Restart a specific extension (stops and restarts the workspace worker) */
+ restartExtension: publicProcedure
+ .input(
+ z.object({
+ extensionId: z.string(),
+ workspaceId: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ if (!input.workspaceId) {
+ const manager = getExtensionHostManager();
+ const runningWorkspaceIds = manager.getRunningWorkspaceIds();
+ if (runningWorkspaceIds.length === 0) {
+ return { success: false };
+ }
+
+ await Promise.all(
+ runningWorkspaceIds.map(async (workspaceId) => {
+ const workspacePath = manager.getWorkspacePath(workspaceId) ?? "";
+ manager.stop(workspaceId);
+ await manager.start(workspaceId, workspacePath);
+ }),
+ );
+ return { success: true };
+ }
+ const manager = getExtensionHostManager();
+ if (!manager.isRunning(input.workspaceId)) {
+ return { success: false };
+ }
+ // Stop then explicitly restart (stop sets "stopped" status which prevents auto-restart)
+ const workspacePath = manager.getWorkspacePath(input.workspaceId) ?? "";
+ manager.stop(input.workspaceId);
+ await manager.start(input.workspaceId, workspacePath);
+ return { success: true };
+ }),
+
+ /** Subscribe to file open requests from extensions (showTextDocument) */
+ subscribeOpenFile: publicProcedure
+ .input(z.object({ workspaceId: z.string().optional() }).optional())
+ .subscription(({ input }) => {
+ return observable<{ filePath: string; line?: number }>((emit) => {
+ const manager = getExtensionHostManager();
+ const handler = (
+ wsId: string,
+ data: { filePath: string; line?: number },
+ ) => {
+ if (input?.workspaceId && wsId !== input.workspaceId) return;
+ emit.next(data);
+ };
+ manager.on("open-file", handler);
+ return () => {
+ manager.off("open-file", handler);
+ };
+ });
+ }),
+
+ /** Subscribe to diff open requests from extensions (vscode.diff calls) */
+ subscribeDiff: publicProcedure
+ .input(z.object({ workspaceId: z.string().optional() }).optional())
+ .subscription(({ input }) => {
+ return observable<{
+ leftUri: string;
+ rightUri: string;
+ title?: string;
+ }>((emit) => {
+ const manager = getExtensionHostManager();
+ const handler = (
+ wsId: string,
+ data: { leftUri: string; rightUri: string; title?: string },
+ ) => {
+ if (input?.workspaceId && wsId !== input.workspaceId) return;
+ emit.next(data);
+ };
+ manager.on("open-diff", handler);
+ return () => {
+ manager.off("open-diff", handler);
+ };
+ });
+ }),
+
+ /** Subscribe to webview events (HTML changes, messages from extension) */
+ subscribeWebview: publicProcedure
+ .input(z.object({ workspaceId: z.string().optional() }).optional())
+ .subscription(({ input }) => {
+ return observable((emit) => {
+ const manager = getExtensionHostManager();
+ const handler = (wsId: string, event: WebviewBridgeEvent) => {
+ if (input?.workspaceId && wsId !== input.workspaceId) return;
+ emit.next(event);
+ };
+ manager.on("webview-event", handler);
+ return () => {
+ manager.off("webview-event", handler);
+ };
+ });
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts b/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts
index cd1e7e7cc02..75b22d6fd7c 100644
--- a/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts
@@ -6,6 +6,7 @@ import {
toRelativePath,
type WorkspaceFsPathError,
} from "@superset/workspace-fs/host";
+import { TRPCError } from "@trpc/server";
import { shell } from "electron";
import { getWorkspace } from "./workspaces/utils/db-helpers";
import { execWithShellEnv } from "./workspaces/utils/shell-env";
@@ -19,12 +20,13 @@ const sharedHostServiceOptions = {
},
runRipgrep: async (
args: string[],
- options: { cwd: string; maxBuffer: number },
+ options: { cwd: string; maxBuffer: number; signal?: AbortSignal },
) => {
const result = await execWithShellEnv("rg", args, {
cwd: options.cwd,
maxBuffer: options.maxBuffer,
windowsHide: true,
+ signal: options.signal,
});
return { stdout: result.stdout };
},
@@ -81,7 +83,12 @@ export function toRegisteredWorktreeRelativePath(
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
- throw new Error(`Path is outside worktree: ${absolutePath}`);
+ // This helper is only consumed by tRPC routers, so out-of-worktree access
+ // should be surfaced directly as BAD_REQUEST instead of bubbling as internal.
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Path is outside worktree: ${absolutePath}`,
+ });
}
return relativePath.replace(/\\/g, "/");
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
index fc4a0729296..edfd08d4691 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
@@ -27,6 +27,7 @@ import {
listExternalWorktrees,
worktreeExists,
} from "../utils/git";
+import { githubSyncService } from "../utils/github/github-sync-service";
import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown";
const normalizePath = (p: string): string => {
@@ -325,6 +326,12 @@ export const createDeleteProcedures = () => {
}
}
+ // Stop SyncService polling for this workspace
+ const repoPath = worktree?.path ?? project?.mainRepoPath;
+ if (repoPath) {
+ githubSyncService.unregisterWorkspace(repoPath);
+ }
+
deleteWorkspace(input.id);
if (worktree) {
@@ -360,6 +367,19 @@ export const createDeleteProcedures = () => {
.getForWorkspaceId(input.id)
.terminal.killByWorkspaceId(input.id);
+ // Stop SyncService polling for this workspace
+ if (workspace.worktreeId) {
+ const wt = getWorktree(workspace.worktreeId);
+ if (wt?.path) {
+ githubSyncService.unregisterWorkspace(wt.path);
+ }
+ } else {
+ const proj = getProject(workspace.projectId);
+ if (proj?.mainRepoPath) {
+ githubSyncService.unregisterWorkspace(proj.mainRepoPath);
+ }
+ }
+
deleteWorkspace(input.id);
hideProjectIfNoWorkspaces(workspace.projectId);
updateActiveWorkspaceIfRemoved(input.id);
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts
index 91ec39d7137..07d3d6d97ba 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts
@@ -1,7 +1,10 @@
-import { existsSync } from "node:fs";
+import { existsSync, readFileSync } from "node:fs";
+import path from "node:path";
import type { GitHubStatus } from "@superset/local-db";
import { workspaces, worktrees } from "@superset/local-db";
+import { TRPCError } from "@trpc/server";
import { and, eq, isNull } from "drizzle-orm";
+import yaml from "js-yaml";
import { localDb } from "main/lib/local-db";
import { z } from "zod";
import { publicProcedure, router } from "../../..";
@@ -12,28 +15,367 @@ import {
updateProjectDefaultBranch,
} from "../utils/db-helpers";
import {
+ branchExistsOnRemote,
fetchDefaultBranch,
getAheadBehindCount,
+ getCurrentBranch,
getDefaultBranch,
listExternalWorktrees,
refreshDefaultBranch,
} from "../utils/git";
import {
+ addPullRequestConversationComment,
clearGitHubCachesForWorktree,
+ extractNwoFromUrl,
+ fetchCheckJobSteps,
fetchGitHubPRComments,
fetchGitHubPRStatus,
+ fetchGitHubPreviewUrl,
+ fetchJobStatuses,
+ fetchStructuredJobLogs,
+ getRepoContext,
type PullRequestCommentsTarget,
- resolveReviewThread,
+ replyToReviewThread,
} from "../utils/github";
+import { githubSyncService } from "../utils/github/github-sync-service";
+import { GHIdentityCandidatesResponseSchema } from "../utils/github/types";
+import { execWithShellEnv } from "../utils/shell-env";
const gitHubPRCommentsInputSchema = z.object({
workspaceId: z.string(),
prNumber: z.number().int().positive().optional(),
+ prUrl: z.string().optional(),
repoUrl: z.string().optional(),
upstreamUrl: z.string().optional(),
isFork: z.boolean().optional(),
+ forceFresh: z.boolean().optional(),
});
+const ghRepositoryPullRequestSchema = z.object({
+ number: z.number(),
+ title: z.string(),
+ url: z.string(),
+ state: z.enum(["OPEN", "CLOSED", "MERGED"]),
+ isDraft: z.boolean().optional().default(false),
+ headRefName: z.string().optional(),
+ updatedAt: z.string().nullable().optional(),
+ author: z
+ .object({
+ login: z.string().optional(),
+ })
+ .nullable()
+ .optional(),
+});
+
+const ghRepositoryWorkflowSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ path: z.string().optional(),
+ state: z.string().optional(),
+});
+
+const ghRepositoryWorkflowsResponseSchema = z.object({
+ workflows: z.array(ghRepositoryWorkflowSchema).optional(),
+});
+
+const ghRepositoryWorkflowRunSchema = z.object({
+ id: z.number(),
+ name: z.string().nullable().optional(),
+ display_title: z.string().nullable().optional(),
+ html_url: z.string().optional(),
+ status: z.string().nullable().optional(),
+ conclusion: z.string().nullable().optional(),
+ event: z.string().nullable().optional(),
+ created_at: z.string().nullable().optional(),
+ updated_at: z.string().nullable().optional(),
+ run_started_at: z.string().nullable().optional(),
+ head_branch: z.string().nullable().optional(),
+ head_sha: z.string().nullable().optional(),
+ run_number: z.number().optional(),
+ workflow_id: z.number().optional(),
+});
+
+const ghRepositoryWorkflowRunsResponseSchema = z.object({
+ workflow_runs: z.array(ghRepositoryWorkflowRunSchema).optional(),
+});
+
+const ghRepositoryLabelSchema = z.object({
+ name: z.string(),
+ color: z.string().optional(),
+ description: z.string().nullable().optional(),
+});
+
+const ghRepositoryAssigneeSchema = z.object({
+ login: z.string(),
+ avatar_url: z.string().optional(),
+});
+
+async function loadGitHubOverviewSegment({
+ label,
+ load,
+ fallback,
+ workspaceId,
+ repositoryNameWithOwner,
+}: {
+ label: string;
+ load: () => Promise;
+ fallback: T;
+ workspaceId: string;
+ repositoryNameWithOwner: string;
+}): Promise {
+ try {
+ return await load();
+ } catch (error) {
+ // Overview data is best-effort. A flaky GitHub endpoint should degrade this
+ // segment to empty data rather than failing the entire repository overview.
+ console.warn("[git-status/github-overview] Falling back to empty segment", {
+ label,
+ workspaceId,
+ repositoryNameWithOwner,
+ error,
+ });
+ return fallback;
+ }
+}
+
+function sanitizeIssueAssetBasename(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 80);
+}
+
+function getIssueAssetExtension({
+ filename,
+ mimeType,
+}: {
+ filename?: string;
+ mimeType?: string;
+}): string {
+ const lower = filename?.toLowerCase() ?? "";
+ if (lower.endsWith(".png")) return "png";
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "jpg";
+ if (lower.endsWith(".gif")) return "gif";
+ if (lower.endsWith(".webp")) return "webp";
+
+ if (mimeType === "image/jpeg") return "jpg";
+ if (mimeType === "image/gif") return "gif";
+ if (mimeType === "image/webp") return "webp";
+ return "png";
+}
+
+async function ensureGitHubBranchExists({
+ repoPath,
+ repositoryNameWithOwner,
+ branchName,
+ baseBranch,
+}: {
+ repoPath: string;
+ repositoryNameWithOwner: string;
+ branchName: string;
+ baseBranch: string;
+}) {
+ try {
+ await execWithShellEnv(
+ "gh",
+ ["api", `repos/${repositoryNameWithOwner}/git/ref/heads/${branchName}`],
+ { cwd: repoPath },
+ );
+ return;
+ } catch (error) {
+ const errorText =
+ error instanceof Error
+ ? [
+ error.message,
+ "stderr" in error && typeof error.stderr === "string"
+ ? error.stderr
+ : "",
+ "stdout" in error && typeof error.stdout === "string"
+ ? error.stdout
+ : "",
+ ]
+ .join("\n")
+ .toLowerCase()
+ : String(error).toLowerCase();
+ const isMissingRefError =
+ errorText.includes("404") ||
+ errorText.includes("not found") ||
+ errorText.includes("no ref found");
+
+ if (!isMissingRefError) {
+ console.warn("[git-status] GitHub branch probe failed", {
+ repoPath,
+ repositoryNameWithOwner,
+ branchName,
+ baseBranch,
+ error,
+ });
+ throw error;
+ }
+
+ console.warn("[git-status] GitHub branch not found, creating branch", {
+ repoPath,
+ repositoryNameWithOwner,
+ branchName,
+ baseBranch,
+ error,
+ });
+ }
+
+ const { stdout } = await execWithShellEnv(
+ "gh",
+ ["api", `repos/${repositoryNameWithOwner}/git/ref/heads/${baseBranch}`],
+ { cwd: repoPath },
+ );
+ const raw = JSON.parse(stdout) as {
+ object?: { sha?: string };
+ };
+ const sha = raw.object?.sha;
+ if (!sha) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Could not determine the base branch SHA for issue assets.",
+ });
+ }
+
+ await execWithShellEnv(
+ "gh",
+ [
+ "api",
+ "--method",
+ "POST",
+ `repos/${repositoryNameWithOwner}/git/refs`,
+ "-f",
+ `ref=refs/heads/${branchName}`,
+ "-f",
+ `sha=${sha}`,
+ ],
+ { cwd: repoPath },
+ );
+}
+
+function parseRunIdFromActionsUrl(detailsUrl?: string): string | null {
+ if (!detailsUrl) {
+ return null;
+ }
+
+ try {
+ const url = new URL(detailsUrl);
+ const match = url.pathname.match(/\/actions\/runs\/(\d+)(?:\/|$)/);
+ return match?.[1] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function isGitHubActionsUrl(url?: string): boolean {
+ return parseRunIdFromActionsUrl(url) !== null;
+}
+
+interface WorkflowDispatchInput {
+ name: string;
+ description: string;
+ required: boolean;
+ default: string;
+ type: "string" | "choice" | "boolean" | "number" | "environment";
+ options: string[];
+}
+
+interface WorkflowDispatchInfo {
+ supportsDispatch: boolean;
+ inputs: WorkflowDispatchInput[];
+}
+
+function parseWorkflowDispatchInfo({
+ repoPath,
+ workflowPath,
+}: {
+ repoPath: string;
+ workflowPath?: string;
+}): WorkflowDispatchInfo {
+ const noDispatch: WorkflowDispatchInfo = {
+ supportsDispatch: false,
+ inputs: [],
+ };
+
+ if (!workflowPath) {
+ return noDispatch;
+ }
+
+ const absolutePath = path.join(repoPath, workflowPath);
+ if (!existsSync(absolutePath)) {
+ return noDispatch;
+ }
+
+ let content: string;
+ try {
+ content = readFileSync(absolutePath, "utf8");
+ } catch {
+ return noDispatch;
+ }
+
+ const hasDispatch =
+ /^\s*workflow_dispatch\s*:/m.test(content) ||
+ /^\s*on\s*:\s*workflow_dispatch\s*$/m.test(content) ||
+ /^\s*on\s*:\s*\[[^\]]*\bworkflow_dispatch\b[^\]]*\]/m.test(content);
+
+ if (!hasDispatch) {
+ return noDispatch;
+ }
+
+ try {
+ const parsed = yaml.load(content) as Record | null;
+ if (!parsed || typeof parsed !== "object") {
+ return { supportsDispatch: true, inputs: [] };
+ }
+
+ const onBlock = parsed.on ?? parsed.true;
+ if (!onBlock || typeof onBlock !== "object") {
+ return { supportsDispatch: true, inputs: [] };
+ }
+
+ const dispatchBlock = (onBlock as Record)
+ .workflow_dispatch;
+ if (!dispatchBlock || typeof dispatchBlock !== "object") {
+ return { supportsDispatch: true, inputs: [] };
+ }
+
+ const rawInputs = (dispatchBlock as Record).inputs;
+ if (!rawInputs || typeof rawInputs !== "object") {
+ return { supportsDispatch: true, inputs: [] };
+ }
+
+ const inputs: WorkflowDispatchInput[] = Object.entries(
+ rawInputs as Record,
+ ).map(([name, value]) => {
+ const input = (value ?? {}) as Record;
+ const inputType = String(input.type ?? "string");
+ const options: string[] = Array.isArray(input.options)
+ ? input.options.map(String)
+ : [];
+
+ return {
+ name,
+ description: String(input.description ?? ""),
+ required: Boolean(input.required ?? false),
+ default: String(input.default ?? ""),
+ type: (
+ ["string", "choice", "boolean", "number", "environment"] as const
+ ).includes(inputType as never)
+ ? (inputType as WorkflowDispatchInput["type"])
+ : "string",
+ options,
+ };
+ });
+
+ return { supportsDispatch: true, inputs };
+ } catch {
+ return { supportsDispatch: true, inputs: [] };
+ }
+}
+
function resolveCommentsPullRequestTarget({
input,
githubStatus,
@@ -59,6 +401,7 @@ function resolveCommentsPullRequestTarget({
return {
prNumber,
+ prUrl: input.prUrl ?? githubStatus?.pr?.url,
repoContext: {
repoUrl,
upstreamUrl,
@@ -83,7 +426,7 @@ function hasMeaningfulGitHubStatusChange({
next,
}: {
current: GitHubStatus | null | undefined;
- next: GitHubStatus;
+ next: GitHubStatus | null;
}): boolean {
return (
JSON.stringify(stripGitHubStatusTimestamp(current)) !==
@@ -91,6 +434,845 @@ function hasMeaningfulGitHubStatusChange({
);
}
+function resolveRepoPathForWorkspace(workspaceId: string): {
+ workspace: NonNullable>;
+ worktree: NonNullable> | null;
+ repoPath: string;
+} {
+ const workspace = getWorkspace(workspaceId);
+ if (!workspace) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Workspace ${workspaceId} not found`,
+ });
+ }
+
+ const worktree = workspace.worktreeId
+ ? (getWorktree(workspace.worktreeId) ?? null)
+ : null;
+ let repoPath: string | null = worktree?.path ?? null;
+ if (!repoPath && workspace.type === "branch") {
+ const project = getProject(workspace.projectId);
+ repoPath = project?.mainRepoPath ?? null;
+ }
+
+ if (!repoPath) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "GitHub is not available for this workspace.",
+ });
+ }
+
+ return { workspace, worktree, repoPath };
+}
+
+async function getFreshPullRequestForWorkspace(workspaceId: string): Promise<{
+ repoPath: string;
+ worktree: NonNullable> | null;
+ pullRequest: NonNullable;
+}> {
+ const { repoPath, worktree } = resolveRepoPathForWorkspace(workspaceId);
+ clearGitHubCachesForWorktree(repoPath);
+ const githubStatus = await fetchGitHubPRStatus(repoPath);
+ const pullRequest = githubStatus?.pr ?? null;
+
+ if (!pullRequest) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "No pull request found for this workspace.",
+ });
+ }
+
+ return { repoPath, worktree, pullRequest };
+}
+
+async function resolveRepositoryTargetForWorkspace(
+ workspaceId: string,
+): Promise<{
+ repoPath: string;
+ worktree: NonNullable> | null;
+ repositoryUrl: string;
+ repositoryNameWithOwner: string;
+ upstreamUrl: string;
+ upstreamNameWithOwner: string;
+ isFork: boolean;
+ branchExistsOnRemote: boolean;
+ currentBranch: string;
+ defaultBranch: string;
+}> {
+ const { repoPath, worktree } = resolveRepoPathForWorkspace(workspaceId);
+ const [githubStatus, repoContext, currentBranch, defaultBranch] =
+ await Promise.all([
+ fetchGitHubPRStatus(repoPath),
+ getRepoContext(repoPath),
+ getCurrentBranch(repoPath),
+ getDefaultBranch(repoPath),
+ ]);
+
+ const repoUrl = githubStatus?.repoUrl ?? repoContext?.repoUrl;
+ const upstreamUrl =
+ githubStatus?.upstreamUrl ?? repoContext?.upstreamUrl ?? repoUrl;
+ const isFork = githubStatus?.isFork ?? repoContext?.isFork ?? false;
+ const repositoryUrl = repoUrl;
+ const repositoryNameWithOwner = repositoryUrl
+ ? extractNwoFromUrl(repositoryUrl)
+ : null;
+ const upstreamNameWithOwner = upstreamUrl
+ ? extractNwoFromUrl(upstreamUrl)
+ : null;
+
+ if (
+ !repoUrl ||
+ !upstreamUrl ||
+ !repositoryUrl ||
+ !repositoryNameWithOwner ||
+ !upstreamNameWithOwner
+ ) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Could not determine the GitHub repository for this workspace.",
+ });
+ }
+
+ if (!currentBranch) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Could not determine the current branch for this workspace.",
+ });
+ }
+
+ return {
+ repoPath,
+ worktree,
+ repositoryUrl,
+ repositoryNameWithOwner,
+ upstreamUrl,
+ upstreamNameWithOwner,
+ isFork,
+ branchExistsOnRemote: githubStatus?.branchExistsOnRemote ?? false,
+ currentBranch,
+ defaultBranch,
+ };
+}
+
+async function getGitHubRepositoryOverview(workspaceId: string) {
+ const {
+ repoPath,
+ repositoryNameWithOwner,
+ repositoryUrl,
+ upstreamUrl,
+ upstreamNameWithOwner,
+ isFork,
+ branchExistsOnRemote,
+ currentBranch,
+ defaultBranch,
+ } = await resolveRepositoryTargetForWorkspace(workspaceId);
+
+ const [pullRequests, workflows, labels, assignees] = await Promise.all([
+ loadGitHubOverviewSegment({
+ label: "pullRequests",
+ workspaceId,
+ repositoryNameWithOwner,
+ fallback: [] as Array>,
+ load: async () => {
+ const result = await execWithShellEnv(
+ "gh",
+ [
+ "pr",
+ "list",
+ "--repo",
+ repositoryNameWithOwner,
+ "--state",
+ "open",
+ "--limit",
+ "8",
+ "--json",
+ "number,title,url,state,isDraft,headRefName,updatedAt,author",
+ ],
+ { cwd: repoPath },
+ );
+ return z
+ .array(ghRepositoryPullRequestSchema)
+ .parse(JSON.parse(result.stdout) as unknown);
+ },
+ }),
+ loadGitHubOverviewSegment({
+ label: "workflows",
+ workspaceId,
+ repositoryNameWithOwner,
+ fallback: [] as Array>,
+ load: async () => {
+ const result = await execWithShellEnv(
+ "gh",
+ [
+ "api",
+ `repos/${repositoryNameWithOwner}/actions/workflows?per_page=100`,
+ ],
+ { cwd: repoPath },
+ );
+ return (
+ ghRepositoryWorkflowsResponseSchema.parse(
+ JSON.parse(result.stdout) as unknown,
+ ).workflows ?? []
+ );
+ },
+ }),
+ loadGitHubOverviewSegment({
+ label: "labels",
+ workspaceId,
+ repositoryNameWithOwner,
+ fallback: [] as Array>,
+ load: async () => {
+ const result = await execWithShellEnv(
+ "gh",
+ ["api", `repos/${repositoryNameWithOwner}/labels?per_page=100`],
+ { cwd: repoPath },
+ );
+ return z
+ .array(ghRepositoryLabelSchema)
+ .parse(JSON.parse(result.stdout) as unknown);
+ },
+ }),
+ loadGitHubOverviewSegment({
+ label: "assignees",
+ workspaceId,
+ repositoryNameWithOwner,
+ fallback: [] as Array>,
+ load: async () => {
+ const result = await execWithShellEnv(
+ "gh",
+ ["api", `repos/${repositoryNameWithOwner}/assignees?per_page=100`],
+ { cwd: repoPath },
+ );
+ return z
+ .array(ghRepositoryAssigneeSchema)
+ .parse(JSON.parse(result.stdout) as unknown);
+ },
+ }),
+ ]);
+
+ return {
+ repositoryNameWithOwner,
+ repositoryUrl,
+ upstreamUrl,
+ upstreamNameWithOwner,
+ isFork,
+ branchExistsOnRemote,
+ currentBranch,
+ defaultBranch,
+ issueAssignees: assignees.map((assignee) => ({
+ login: assignee.login,
+ avatarUrl: assignee.avatar_url ?? null,
+ })),
+ issueLabels: labels.map((label) => ({
+ name: label.name,
+ color: label.color ?? "",
+ description: label.description ?? "",
+ })),
+ pullsUrl: `${repositoryUrl}/pulls`,
+ issuesUrl: `${repositoryUrl}/issues`,
+ actionsUrl: `${repositoryUrl}/actions`,
+ newIssueUrl: `${repositoryUrl}/issues/new`,
+ pullRequests: pullRequests.map((pullRequest) => ({
+ number: pullRequest.number,
+ title: pullRequest.title,
+ url: pullRequest.url,
+ state: pullRequest.isDraft ? "draft" : pullRequest.state.toLowerCase(),
+ headRefName: pullRequest.headRefName ?? "",
+ updatedAt: pullRequest.updatedAt ?? null,
+ authorLogin: pullRequest.author?.login ?? null,
+ })),
+ workflows: workflows
+ .filter((workflow) => workflow.state !== "disabled_manually")
+ .map((workflow) => {
+ const dispatchInfo = parseWorkflowDispatchInfo({
+ repoPath,
+ workflowPath: workflow.path,
+ });
+ return {
+ id: workflow.id,
+ name: workflow.name,
+ path: workflow.path ?? "",
+ state: workflow.state ?? "unknown",
+ supportsDispatch: dispatchInfo.supportsDispatch,
+ inputs: dispatchInfo.inputs,
+ };
+ })
+ .filter((workflow) => workflow.supportsDispatch),
+ };
+}
+
+async function createGitHubIssueForWorkspace({
+ workspaceId,
+ title,
+ body,
+ assignees,
+ labels,
+}: {
+ workspaceId: string;
+ title: string;
+ body?: string;
+ assignees?: string[];
+ labels?: string[];
+}) {
+ const { repoPath, repositoryNameWithOwner } =
+ await resolveRepositoryTargetForWorkspace(workspaceId);
+ const args = [
+ "issue",
+ "create",
+ "--repo",
+ repositoryNameWithOwner,
+ "--title",
+ title.trim(),
+ "--body",
+ body?.trim() || "",
+ ];
+ const normalizedAssignees = normalizeIdentityList(assignees ?? []);
+ const normalizedLabels = normalizeIdentityList(labels ?? []);
+ if (normalizedAssignees.length > 0) {
+ args.push("--assignee", normalizedAssignees.join(","));
+ }
+ if (normalizedLabels.length > 0) {
+ args.push("--label", normalizedLabels.join(","));
+ }
+ const { stdout } = await execWithShellEnv("gh", args, { cwd: repoPath });
+
+ return {
+ url: stdout.trim(),
+ };
+}
+
+async function uploadIssueAssetForWorkspace({
+ workspaceId,
+ filename,
+ contentBase64,
+ mimeType,
+}: {
+ workspaceId: string;
+ filename: string;
+ contentBase64: string;
+ mimeType?: string;
+}) {
+ const { repoPath, repositoryNameWithOwner, defaultBranch } =
+ await resolveRepositoryTargetForWorkspace(workspaceId);
+ const assetBranch = "superset-issue-assets";
+ await ensureGitHubBranchExists({
+ repoPath,
+ repositoryNameWithOwner,
+ branchName: assetBranch,
+ baseBranch: defaultBranch,
+ });
+
+ const now = new Date();
+ const extension = getIssueAssetExtension({ filename, mimeType });
+ const basename =
+ sanitizeIssueAssetBasename(filename.replace(/\.[^.]+$/, "")) ||
+ "pasted-image";
+ const timestamp = now.toISOString().replace(/[:.]/g, "-");
+ const assetPath = [
+ ".superset",
+ "issue-assets",
+ String(now.getUTCFullYear()),
+ String(now.getUTCMonth() + 1).padStart(2, "0"),
+ `${timestamp}-${basename}.${extension}`,
+ ].join("/");
+
+ await execWithShellEnv(
+ "gh",
+ [
+ "api",
+ "--method",
+ "PUT",
+ `repos/${repositoryNameWithOwner}/contents/${assetPath}`,
+ "-f",
+ `message=Add issue asset ${assetPath}`,
+ "-f",
+ `content=${contentBase64}`,
+ "-f",
+ `branch=${assetBranch}`,
+ ],
+ { cwd: repoPath },
+ );
+
+ const assetUrl = `https://github.com/${repositoryNameWithOwner}/raw/${assetBranch}/${assetPath}`;
+
+ return {
+ name: `${basename}.${extension}`,
+ url: assetUrl,
+ markdown: ``,
+ };
+}
+
+async function dispatchGitHubWorkflowForWorkspace({
+ workspaceId,
+ workflowId,
+ ref,
+ inputs,
+}: {
+ workspaceId: string;
+ workflowId: number;
+ ref?: string;
+ inputs?: Record;
+}) {
+ const { repoPath, repositoryNameWithOwner, currentBranch, defaultBranch } =
+ await resolveRepositoryTargetForWorkspace(workspaceId);
+ const requestedRef = ref?.trim() || currentBranch || defaultBranch;
+ let targetRef = requestedRef;
+ if (requestedRef === currentBranch) {
+ const branchCheck = await branchExistsOnRemote(
+ repoPath,
+ currentBranch,
+ "origin",
+ );
+ if (branchCheck.status !== "exists") {
+ targetRef = defaultBranch;
+ }
+ }
+
+ const args = [
+ "api",
+ "--method",
+ "POST",
+ `repos/${repositoryNameWithOwner}/actions/workflows/${workflowId}/dispatches`,
+ "-f",
+ `ref=${targetRef}`,
+ ];
+
+ if (inputs) {
+ for (const [key, value] of Object.entries(inputs)) {
+ args.push("-f", `inputs[${key}]=${value}`);
+ }
+ }
+
+ await execWithShellEnv("gh", args, { cwd: repoPath });
+
+ return {
+ success: true as const,
+ ref: targetRef,
+ dispatchedAt: new Date().toISOString(),
+ };
+}
+
+async function getGitHubWorkflowRunsForWorkspace({
+ workspaceId,
+ workflowId,
+}: {
+ workspaceId: string;
+ workflowId: number;
+}) {
+ const { repoPath, repositoryNameWithOwner } =
+ await resolveRepositoryTargetForWorkspace(workspaceId);
+ const { stdout } = await execWithShellEnv(
+ "gh",
+ [
+ "api",
+ `repos/${repositoryNameWithOwner}/actions/workflows/${workflowId}/runs?per_page=10&event=workflow_dispatch`,
+ ],
+ { cwd: repoPath },
+ );
+
+ const rawRuns = JSON.parse(stdout) as unknown;
+ const runs =
+ ghRepositoryWorkflowRunsResponseSchema.parse(rawRuns).workflow_runs ?? [];
+
+ return runs.map((run) => ({
+ id: run.id,
+ name: run.name ?? "",
+ displayTitle: run.display_title ?? "",
+ url: run.html_url ?? "",
+ status: run.status ?? "unknown",
+ conclusion: run.conclusion ?? null,
+ event: run.event ?? null,
+ createdAt: run.created_at ?? null,
+ updatedAt: run.updated_at ?? null,
+ runStartedAt: run.run_started_at ?? null,
+ headBranch: run.head_branch ?? null,
+ headSha: run.head_sha ?? null,
+ runNumber: run.run_number ?? null,
+ workflowId: run.workflow_id ?? workflowId,
+ }));
+}
+
+async function getWorkflowRunJobsForWorkspace({
+ workspaceId,
+ runId,
+}: {
+ workspaceId: string;
+ runId: number;
+}) {
+ const { repoPath, repositoryNameWithOwner } =
+ await resolveRepositoryTargetForWorkspace(workspaceId);
+ const { stdout } = await execWithShellEnv(
+ "gh",
+ [
+ "api",
+ `repos/${repositoryNameWithOwner}/actions/runs/${runId}/jobs?per_page=100`,
+ ],
+ { cwd: repoPath },
+ );
+
+ const raw: unknown = JSON.parse(stdout);
+ const parsed = z
+ .object({
+ jobs: z
+ .array(
+ z.object({
+ id: z.number(),
+ name: z.string(),
+ status: z.string(),
+ conclusion: z.string().nullable(),
+ html_url: z.string().nullable().optional(),
+ }),
+ )
+ .optional(),
+ })
+ .parse(raw);
+
+ return (parsed.jobs ?? []).map((job) => ({
+ detailsUrl: job.html_url ?? "",
+ name: job.name,
+ status: mapJobStatus(job.status, job.conclusion),
+ }));
+}
+
+function mapJobStatus(
+ status: string,
+ conclusion: string | null,
+): "success" | "failure" | "pending" | "skipped" | "cancelled" {
+ if (status !== "completed") {
+ return "pending";
+ }
+ switch (conclusion) {
+ case "success":
+ return "success";
+ case "failure":
+ case "timed_out":
+ return "failure";
+ case "cancelled":
+ return "cancelled";
+ case "skipped":
+ return "skipped";
+ default:
+ return "pending";
+ }
+}
+
+async function rerunPullRequestChecksForWorkspace({
+ workspaceId,
+ mode,
+}: {
+ workspaceId: string;
+ mode: "all" | "failed";
+}) {
+ const { repoPath, worktree, pullRequest } =
+ await getFreshPullRequestForWorkspace(workspaceId);
+ const checksToRerun = pullRequest.checks.filter((check) => {
+ if (!isGitHubActionsUrl(check.url)) {
+ return false;
+ }
+
+ if (mode === "failed") {
+ return check.status === "failure";
+ }
+
+ return true;
+ });
+
+ if (checksToRerun.length === 0) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ mode === "failed"
+ ? "No failed GitHub Actions jobs found for this pull request."
+ : "No GitHub Actions jobs found for this pull request.",
+ });
+ }
+
+ const runTargets = new Map();
+ for (const check of checksToRerun) {
+ const runId = parseRunIdFromActionsUrl(check.url);
+ const repositoryNameWithOwner = check.url
+ ? extractNwoFromUrl(check.url)
+ : null;
+ if (!runId || !repositoryNameWithOwner) {
+ continue;
+ }
+
+ runTargets.set(
+ `${repositoryNameWithOwner}:${runId}`,
+ `${repositoryNameWithOwner}:${runId}`,
+ );
+ }
+
+ if (runTargets.size === 0) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "No rerunnable GitHub Actions runs were found.",
+ });
+ }
+
+ for (const target of runTargets.values()) {
+ const [repositoryNameWithOwner, runId] = target.split(":");
+ if (!repositoryNameWithOwner || !runId) {
+ continue;
+ }
+
+ await execWithShellEnv(
+ "gh",
+ [
+ "api",
+ "--method",
+ "POST",
+ `repos/${repositoryNameWithOwner}/actions/runs/${runId}/${mode === "failed" ? "rerun-failed-jobs" : "rerun"}`,
+ ],
+ { cwd: repoPath },
+ );
+ }
+
+ clearGitHubCachesForWorktree(repoPath);
+ if (worktree) {
+ localDb
+ .update(worktrees)
+ .set({ githubStatus: null })
+ .where(eq(worktrees.id, worktree.id))
+ .run();
+ }
+
+ return {
+ success: true as const,
+ rerunCount: runTargets.size,
+ };
+}
+
+function resolvePullRequestTarget({
+ workspaceId,
+ pullRequestNumber,
+ pullRequestUrl,
+}: {
+ workspaceId: string;
+ pullRequestNumber?: number;
+ pullRequestUrl?: string;
+}): {
+ repoPath: string;
+ worktree: NonNullable> | null;
+ repoNameWithOwner: string;
+ pullRequestNumber: number;
+} {
+ const { repoPath, worktree } = resolveRepoPathForWorkspace(workspaceId);
+ const repoNameWithOwner = pullRequestUrl
+ ? extractNwoFromUrl(pullRequestUrl)
+ : null;
+
+ if (!repoNameWithOwner || !pullRequestNumber) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Could not determine the pull request target.",
+ });
+ }
+
+ return {
+ repoPath,
+ worktree,
+ repoNameWithOwner,
+ pullRequestNumber,
+ };
+}
+
+function resolvePullRequestRepoTarget({
+ workspaceId,
+ pullRequestUrl,
+}: {
+ workspaceId: string;
+ pullRequestUrl?: string;
+}): {
+ repoPath: string;
+ worktree: NonNullable> | null;
+ repoNameWithOwner: string;
+} {
+ const { repoPath, worktree } = resolveRepoPathForWorkspace(workspaceId);
+ const repoNameWithOwner = pullRequestUrl
+ ? extractNwoFromUrl(pullRequestUrl)
+ : null;
+
+ if (!repoNameWithOwner) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Could not determine the pull request repository.",
+ });
+ }
+
+ return {
+ repoPath,
+ worktree,
+ repoNameWithOwner,
+ };
+}
+
+function normalizeIdentityList(values: string[]): string[] {
+ return Array.from(
+ new Set(values.map((value) => value.trim()).filter(Boolean)),
+ );
+}
+
+async function updatePullRequestMembers({
+ workspaceId,
+ kind,
+ add,
+ remove,
+ pullRequestNumber,
+ pullRequestUrl,
+}: {
+ workspaceId: string;
+ kind: "reviewer" | "assignee";
+ add: string[];
+ remove: string[];
+ pullRequestNumber?: number;
+ pullRequestUrl?: string;
+}): Promise<{ success: true }> {
+ const normalizedAdd = normalizeIdentityList(add);
+ const normalizedRemove = normalizeIdentityList(remove);
+
+ if (normalizedAdd.length === 0 && normalizedRemove.length === 0) {
+ return { success: true };
+ }
+
+ const {
+ repoPath,
+ worktree,
+ repoNameWithOwner,
+ pullRequestNumber: resolvedPr,
+ } = resolvePullRequestTarget({
+ workspaceId,
+ pullRequestNumber,
+ pullRequestUrl,
+ });
+
+ const args = ["pr", "edit", String(resolvedPr), "--repo", repoNameWithOwner];
+
+ if (normalizedAdd.length > 0) {
+ args.push(
+ kind === "reviewer" ? "--add-reviewer" : "--add-assignee",
+ normalizedAdd.join(","),
+ );
+ }
+
+ if (normalizedRemove.length > 0) {
+ args.push(
+ kind === "reviewer" ? "--remove-reviewer" : "--remove-assignee",
+ normalizedRemove.join(","),
+ );
+ }
+
+ await execWithShellEnv("gh", args, { cwd: repoPath });
+ clearGitHubCachesForWorktree(repoPath);
+
+ if (worktree) {
+ localDb
+ .update(worktrees)
+ .set({ githubStatus: null })
+ .where(eq(worktrees.id, worktree.id))
+ .run();
+ }
+
+ return { success: true };
+}
+
+async function getPullRequestIdentityCandidates({
+ workspaceId,
+ kind,
+ pullRequestUrl,
+}: {
+ workspaceId: string;
+ kind: "reviewer" | "assignee";
+ pullRequestUrl?: string;
+}): Promise> {
+ const { repoPath, repoNameWithOwner } = resolvePullRequestRepoTarget({
+ workspaceId,
+ pullRequestUrl,
+ });
+
+ const [owner, name] = repoNameWithOwner.split("/");
+ if (!owner || !name) {
+ return [];
+ }
+
+ const fieldName =
+ kind === "assignee" ? "assignableUsers" : "mentionableUsers";
+ const query = `query PullRequestIdentityCandidates($owner: String!, $name: String!, $after: String) {
+ repository(owner: $owner, name: $name) {
+ users: ${fieldName}(first: 100, after: $after) {
+ nodes {
+ login
+ avatarUrl
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+}`;
+
+ const usersByLogin = new Map();
+ let afterCursor: string | null = null;
+
+ while (true) {
+ const args = [
+ "api",
+ "graphql",
+ "-f",
+ `query=${query}`,
+ "-F",
+ `owner=${owner}`,
+ "-F",
+ `name=${name}`,
+ ];
+ if (afterCursor) {
+ args.push("-F", `after=${afterCursor}`);
+ }
+
+ const { stdout } = await execWithShellEnv("gh", args, { cwd: repoPath });
+ const raw = JSON.parse(stdout) as unknown;
+ const parsed = GHIdentityCandidatesResponseSchema.safeParse(raw);
+ if (!parsed.success) {
+ console.warn(
+ "[GitHub] Failed to parse pull request identity candidates:",
+ parsed.error.message,
+ );
+ break;
+ }
+
+ const users = parsed.data.data.repository?.users;
+ if (!users) {
+ break;
+ }
+
+ for (const user of users.nodes ?? []) {
+ if (user?.login) {
+ usersByLogin.set(user.login, user.avatarUrl ?? null);
+ }
+ }
+
+ if (!users.pageInfo.hasNextPage || !users.pageInfo.endCursor) {
+ break;
+ }
+
+ afterCursor = users.pageInfo.endCursor;
+ }
+
+ return [...usersByLogin.entries()].map(([login, avatarUrl]) => ({
+ login,
+ avatarUrl,
+ }));
+}
+
+// Initialize the SyncService with fetch dependencies (idempotent)
+githubSyncService.initialize({
+ fetchPRStatus: fetchGitHubPRStatus,
+ fetchPRComments: ({ worktreePath, pullRequest }) =>
+ fetchGitHubPRComments({ worktreePath, pullRequest }),
+});
+
export const createGitStatusProcedures = () => {
return router({
refreshGitStatus: publicProcedure
@@ -175,7 +1357,13 @@ export const createGitStatusProcedures = () => {
}),
getGitHubStatus: publicProcedure
- .input(z.object({ workspaceId: z.string() }))
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ forceFresh: z.boolean().optional(),
+ includePreview: z.boolean().optional(),
+ }),
+ )
.query(async ({ input }) => {
const workspace = getWorkspace(input.workspaceId);
if (!workspace) {
@@ -185,14 +1373,31 @@ export const createGitStatusProcedures = () => {
const worktree = workspace.worktreeId
? getWorktree(workspace.worktreeId)
: null;
- if (!worktree) {
+
+ // For "branch" type workspaces without a worktree record,
+ // fall back to the project's mainRepoPath
+ let repoPath: string | null = worktree?.path ?? null;
+ if (!repoPath && workspace.type === "branch") {
+ const project = getProject(workspace.projectId);
+ repoPath = project?.mainRepoPath ?? null;
+ }
+ if (!repoPath) {
return null;
}
- const freshStatus = await fetchGitHubPRStatus(worktree.path);
+ if (input.forceFresh) {
+ clearGitHubCachesForWorktree(repoPath);
+ }
+
+ // Register workspace with SyncService for proactive cache warming
+ if (!githubSyncService.isRegistered(repoPath)) {
+ githubSyncService.registerWorkspace(repoPath);
+ }
+
+ const freshStatus = await fetchGitHubPRStatus(repoPath);
if (
- freshStatus &&
+ worktree &&
hasMeaningfulGitHubStatusChange({
current: worktree.githubStatus,
next: freshStatus,
@@ -205,7 +1410,20 @@ export const createGitStatusProcedures = () => {
.run();
}
- return freshStatus;
+ if (!input.includePreview || !freshStatus) {
+ return freshStatus;
+ }
+
+ const previewUrl = await fetchGitHubPreviewUrl({
+ worktreePath: repoPath,
+ githubStatus: freshStatus,
+ forceFresh: input.forceFresh,
+ });
+
+ return {
+ ...freshStatus,
+ previewUrl: previewUrl ?? undefined,
+ };
}),
getGitHubPRComments: publicProcedure
@@ -219,14 +1437,24 @@ export const createGitStatusProcedures = () => {
const worktree = workspace.worktreeId
? getWorktree(workspace.worktreeId)
: null;
- if (!worktree) {
+
+ let repoPath: string | null = worktree?.path ?? null;
+ if (!repoPath && workspace.type === "branch") {
+ const project = getProject(workspace.projectId);
+ repoPath = project?.mainRepoPath ?? null;
+ }
+ if (!repoPath) {
return [];
}
- const cachedGitHubStatus = worktree.githubStatus ?? null;
+ if (input.forceFresh) {
+ clearGitHubCachesForWorktree(repoPath);
+ }
+
+ const cachedGitHubStatus = worktree?.githubStatus ?? null;
return fetchGitHubPRComments({
- worktreePath: worktree.path,
+ worktreePath: repoPath,
pullRequest: resolveCommentsPullRequestTarget({
input,
githubStatus: cachedGitHubStatus,
@@ -234,36 +1462,313 @@ export const createGitStatusProcedures = () => {
});
}),
- resolveReviewThread: publicProcedure
+ getPullRequestIdentityCandidates: publicProcedure
.input(
z.object({
workspaceId: z.string(),
- threadId: z.string(),
- resolve: z.boolean(),
+ kind: z.enum(["reviewer", "assignee"]),
+ pullRequestUrl: z.string().optional(),
+ }),
+ )
+ .query(async ({ input }) => {
+ return getPullRequestIdentityCandidates(input);
+ }),
+
+ getGitHubRepositoryOverview: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ }),
+ )
+ .query(async ({ input }) => {
+ return getGitHubRepositoryOverview(input.workspaceId);
+ }),
+
+ createGitHubIssue: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ title: z.string().trim().min(1),
+ body: z.string().optional(),
+ assignees: z.array(z.string()).optional(),
+ labels: z.array(z.string()).optional(),
}),
)
.mutation(async ({ input }) => {
- const workspace = getWorkspace(input.workspaceId);
- if (!workspace) {
- throw new Error(`Workspace ${input.workspaceId} not found`);
+ return createGitHubIssueForWorkspace(input);
+ }),
+
+ uploadGitHubIssueAsset: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ filename: z.string().trim().min(1),
+ contentBase64: z.string().trim().min(1),
+ mimeType: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ return uploadIssueAssetForWorkspace(input);
+ }),
+
+ dispatchGitHubWorkflow: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ workflowId: z.number().int().positive(),
+ ref: z.string().optional(),
+ inputs: z.record(z.string(), z.string()).optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ return dispatchGitHubWorkflowForWorkspace(input);
+ }),
+
+ getGitHubWorkflowRuns: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ workflowId: z.number().int().positive(),
+ }),
+ )
+ .query(async ({ input }) => {
+ return getGitHubWorkflowRunsForWorkspace(input);
+ }),
+
+ getWorkflowRunJobs: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ runId: z.number().int().positive(),
+ }),
+ )
+ .query(async ({ input }) => {
+ return getWorkflowRunJobsForWorkspace(input);
+ }),
+
+ rerunPullRequestChecks: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ mode: z.enum(["all", "failed"]),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ return rerunPullRequestChecksForWorkspace(input);
+ }),
+
+ setPullRequestDraftState: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ isDraft: z.boolean(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { repoPath, worktree, pullRequest } =
+ await getFreshPullRequestForWorkspace(input.workspaceId);
+
+ const isCurrentlyDraft = pullRequest.state === "draft";
+ if (pullRequest.state !== "draft" && pullRequest.state !== "open") {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Only open or draft pull requests can be updated from Review.",
+ });
}
- const worktree = workspace.worktreeId
- ? getWorktree(workspace.worktreeId)
- : null;
- if (!worktree) {
- throw new Error(
- `Worktree for workspace ${input.workspaceId} not found`,
- );
+ if (input.isDraft === isCurrentlyDraft) {
+ return { success: true };
+ }
+
+ const repoNameWithOwner = extractNwoFromUrl(pullRequest.url);
+ if (!repoNameWithOwner) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Could not determine the pull request repository.",
+ });
+ }
+
+ const args = [
+ "pr",
+ "ready",
+ String(pullRequest.number),
+ "--repo",
+ repoNameWithOwner,
+ ];
+ if (input.isDraft) {
+ args.push("--undo");
}
- await resolveReviewThread({
- worktreePath: worktree.path,
- threadId: input.threadId,
- resolve: input.resolve,
+ await execWithShellEnv("gh", args, { cwd: repoPath });
+ clearGitHubCachesForWorktree(repoPath);
+
+ if (worktree) {
+ localDb
+ .update(worktrees)
+ .set({ githubStatus: null })
+ .where(eq(worktrees.id, worktree.id))
+ .run();
+ }
+
+ return { success: true };
+ }),
+
+ setPullRequestThreadResolution: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ threadId: z.string().min(1),
+ isResolved: z.boolean(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { repoPath, worktree } = resolveRepoPathForWorkspace(
+ input.workspaceId,
+ );
+ const mutationName = input.isResolved
+ ? "resolveReviewThread"
+ : "unresolveReviewThread";
+ const mutationQuery = `mutation ${mutationName}($threadId: ID!) {
+ ${mutationName}(input: { threadId: $threadId }) {
+ thread {
+ id
+ isResolved
+ }
+ }
+}`;
+
+ await execWithShellEnv(
+ "gh",
+ [
+ "api",
+ "graphql",
+ "-f",
+ `query=${mutationQuery}`,
+ "-F",
+ `threadId=${input.threadId}`,
+ ],
+ { cwd: repoPath },
+ );
+
+ clearGitHubCachesForWorktree(repoPath);
+ if (worktree) {
+ localDb
+ .update(worktrees)
+ .set({ githubStatus: null })
+ .where(eq(worktrees.id, worktree.id))
+ .run();
+ }
+
+ return { success: true };
+ }),
+
+ replyToPullRequestComment: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ body: z.string().trim().min(1),
+ threadId: z.string().min(1).optional(),
+ pullRequestNumber: z.number().int().positive().optional(),
+ pullRequestUrl: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { repoPath, worktree } = resolveRepoPathForWorkspace(
+ input.workspaceId,
+ );
+
+ if (input.threadId) {
+ await replyToReviewThread({
+ worktreePath: repoPath,
+ threadId: input.threadId,
+ body: input.body,
+ });
+ } else {
+ const githubStatus = await fetchGitHubPRStatus(repoPath);
+ const pullRequestNumber =
+ input.pullRequestNumber ?? githubStatus?.pr?.number;
+ if (!pullRequestNumber) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "No pull request found for this workspace.",
+ });
+ }
+
+ const prUrl = input.pullRequestUrl ?? githubStatus?.pr?.url;
+ const repoNameWithOwner = prUrl
+ ? extractNwoFromUrl(prUrl)
+ : githubStatus?.repoUrl
+ ? extractNwoFromUrl(githubStatus.repoUrl)
+ : null;
+ if (!repoNameWithOwner) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Could not determine the repository for this pull request.",
+ });
+ }
+
+ await addPullRequestConversationComment({
+ worktreePath: repoPath,
+ repoNameWithOwner,
+ pullRequestNumber,
+ body: input.body,
+ });
+ }
+
+ clearGitHubCachesForWorktree(repoPath);
+ if (worktree) {
+ localDb
+ .update(worktrees)
+ .set({ githubStatus: null })
+ .where(eq(worktrees.id, worktree.id))
+ .run();
+ }
+
+ return { success: true };
+ }),
+
+ updatePullRequestReviewers: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ add: z.array(z.string()).optional().default([]),
+ remove: z.array(z.string()).optional().default([]),
+ pullRequestNumber: z.number().int().positive().optional(),
+ pullRequestUrl: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ return updatePullRequestMembers({
+ workspaceId: input.workspaceId,
+ kind: "reviewer",
+ add: input.add,
+ remove: input.remove,
+ pullRequestNumber: input.pullRequestNumber,
+ pullRequestUrl: input.pullRequestUrl,
});
+ }),
- clearGitHubCachesForWorktree(worktree.path);
+ updatePullRequestAssignees: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ add: z.array(z.string()).optional().default([]),
+ remove: z.array(z.string()).optional().default([]),
+ pullRequestNumber: z.number().int().positive().optional(),
+ pullRequestUrl: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ return updatePullRequestMembers({
+ workspaceId: input.workspaceId,
+ kind: "assignee",
+ add: input.add,
+ remove: input.remove,
+ pullRequestNumber: input.pullRequestNumber,
+ pullRequestUrl: input.pullRequestUrl,
+ });
}),
getWorktreeInfo: publicProcedure
@@ -354,5 +1859,131 @@ export const createGitStatusProcedures = () => {
branch: wt.branch!,
}));
}),
+
+ getCheckJobSteps: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ detailsUrl: z.string(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const workspace = getWorkspace(input.workspaceId);
+ if (!workspace) {
+ return [];
+ }
+
+ const worktree = workspace.worktreeId
+ ? getWorktree(workspace.worktreeId)
+ : null;
+
+ let repoPath: string | null = worktree?.path ?? null;
+ if (!repoPath && workspace.type === "branch") {
+ const project = getProject(workspace.projectId);
+ repoPath = project?.mainRepoPath ?? null;
+ }
+ if (!repoPath) {
+ return [];
+ }
+
+ return fetchCheckJobSteps(repoPath, input.detailsUrl);
+ }),
+
+ getJobLogs: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ detailsUrl: z.string(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const workspace = getWorkspace(input.workspaceId);
+ if (!workspace) {
+ return {
+ jobStatus: "queued" as const,
+ jobConclusion: null,
+ steps: [],
+ };
+ }
+
+ const worktree = workspace.worktreeId
+ ? getWorktree(workspace.worktreeId)
+ : null;
+
+ let repoPath: string | null = worktree?.path ?? null;
+ if (!repoPath && workspace.type === "branch") {
+ const project = getProject(workspace.projectId);
+ repoPath = project?.mainRepoPath ?? null;
+ }
+ if (!repoPath) {
+ return {
+ jobStatus: "queued" as const,
+ jobConclusion: null,
+ steps: [],
+ };
+ }
+
+ return fetchStructuredJobLogs(repoPath, input.detailsUrl);
+ }),
+
+ getJobStatuses: publicProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ detailsUrls: z.array(z.string()),
+ }),
+ )
+ .query(async ({ input }) => {
+ const workspace = getWorkspace(input.workspaceId);
+ if (!workspace) {
+ return [];
+ }
+
+ const worktree = workspace.worktreeId
+ ? getWorktree(workspace.worktreeId)
+ : null;
+
+ let repoPath: string | null = worktree?.path ?? null;
+ if (!repoPath && workspace.type === "branch") {
+ const project = getProject(workspace.projectId);
+ repoPath = project?.mainRepoPath ?? null;
+ }
+ if (!repoPath) {
+ return [];
+ }
+
+ return fetchJobStatuses(repoPath, input.detailsUrls);
+ }),
+
+ /**
+ * Notify the SyncService which workspace is currently active.
+ * Deactivates all other workspaces to stop their polling timers.
+ * Pass empty workspaceId to deactivate all (e.g., dashboard view).
+ */
+ setActiveSyncWorkspace: publicProcedure
+ .input(z.object({ workspaceId: z.string() }))
+ .mutation(({ input }) => {
+ if (!input.workspaceId) {
+ githubSyncService.deactivateAll();
+ return { success: true };
+ }
+
+ const workspace = getWorkspace(input.workspaceId);
+ if (!workspace) return { success: false };
+
+ const worktree = workspace.worktreeId
+ ? getWorktree(workspace.worktreeId)
+ : null;
+
+ let repoPath: string | null = worktree?.path ?? null;
+ if (!repoPath && workspace.type === "branch") {
+ const project = getProject(workspace.projectId);
+ repoPath = project?.mainRepoPath ?? null;
+ }
+ if (!repoPath) return { success: false };
+
+ githubSyncService.setActiveWorkspace(repoPath);
+ return { success: true };
+ }),
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
index 4fa87789c46..381d6f8bd22 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
@@ -1,5 +1,6 @@
import {
projects,
+ type SelectProject,
workspaceSections,
workspaces,
worktrees,
@@ -9,6 +10,7 @@ import { eq, isNotNull, isNull } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import { z } from "zod";
import { publicProcedure, router } from "../../..";
+import { fetchGitHubOwner } from "../../projects/utils/github";
import { getWorkspace } from "../utils/db-helpers";
import { getProjectChildItems } from "../utils/project-children-order";
import { loadSetupConfig } from "../utils/setup";
@@ -36,6 +38,30 @@ function getWorkspacesInVisualOrder(): string[] {
return computeVisualOrder(activeProjects, allWorkspaces, allSections);
}
+async function ensureProjectHasGitHubOwner(
+ project: SelectProject,
+): Promise {
+ if (project.githubOwner) {
+ return project;
+ }
+
+ const githubOwner = await fetchGitHubOwner(project.mainRepoPath);
+ if (!githubOwner) {
+ return project;
+ }
+
+ localDb
+ .update(projects)
+ .set({ githubOwner })
+ .where(eq(projects.id, project.id))
+ .run();
+
+ return {
+ ...project,
+ githubOwner,
+ };
+}
+
export const createQueryProcedures = () => {
return router({
get: publicProcedure
@@ -54,6 +80,9 @@ export const createQueryProcedures = () => {
.from(projects)
.where(eq(projects.id, workspace.projectId))
.get();
+ const resolvedProject = project
+ ? await ensureProjectHasGitHubOwner(project)
+ : null;
const worktree = workspace.worktreeId
? localDb
.select()
@@ -66,13 +95,13 @@ export const createQueryProcedures = () => {
...workspace,
type: workspace.type as "worktree" | "branch",
worktreePath: getWorkspacePath(workspace) ?? "",
- project: project
+ project: resolvedProject
? {
- id: project.id,
- name: project.name,
- mainRepoPath: project.mainRepoPath,
- githubOwner: project.githubOwner ?? null,
- defaultBranch: project.defaultBranch ?? null,
+ id: resolvedProject.id,
+ name: resolvedProject.name,
+ mainRepoPath: resolvedProject.mainRepoPath,
+ githubOwner: resolvedProject.githubOwner ?? null,
+ defaultBranch: resolvedProject.defaultBranch ?? null,
}
: null,
worktree: worktree
@@ -95,7 +124,7 @@ export const createQueryProcedures = () => {
.sort((a, b) => a.tabOrder - b.tabOrder);
}),
- getAllGrouped: publicProcedure.query(() => {
+ getAllGrouped: publicProcedure.query(async () => {
type WorkspaceItem = {
id: string;
projectId: string;
@@ -135,6 +164,9 @@ export const createQueryProcedures = () => {
.from(projects)
.where(isNotNull(projects.tabOrder))
.all();
+ const resolvedProjects = await Promise.all(
+ activeProjects.map((project) => ensureProjectHasGitHubOwner(project)),
+ );
const allWorktrees = localDb.select().from(worktrees).all();
const worktreePathMap: WorktreePathMap = new Map(
@@ -165,7 +197,7 @@ export const createQueryProcedures = () => {
}
>();
- for (const project of activeProjects) {
+ for (const project of resolvedProjects) {
const projectSections = allSections
.filter((s) => s.projectId === project.id)
.sort((a, b) => a.tabOrder - b.tabOrder)
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/base-branch-config.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/base-branch-config.ts
index c70671e0692..65964d2c6c8 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/base-branch-config.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/base-branch-config.ts
@@ -15,6 +15,10 @@ interface BranchBaseConfig {
isExplicit: boolean;
}
+export interface BranchPullRequestBaseRepoConfig {
+ baseRepoUrl: string | null;
+}
+
function parseBooleanConfig(value: string): boolean {
const normalized = value.trim().toLowerCase();
return (
@@ -78,3 +82,36 @@ export async function unsetBranchBaseConfig({
.catch(() => {}),
]);
}
+
+export async function getBranchPullRequestBaseRepoConfig({
+ repoPath,
+ branch,
+}: BranchConfigParams): Promise {
+ const git = await getSimpleGitWithShellPath(repoPath);
+ const baseRepoOutput = await git
+ .raw(["config", `branch.${branch}.pr-base-repo`])
+ .catch(() => "");
+
+ return {
+ baseRepoUrl: baseRepoOutput.trim() || null,
+ };
+}
+
+export async function setBranchPullRequestBaseRepoConfig({
+ repoPath,
+ branch,
+ baseRepoUrl,
+}: BranchConfigParams & { baseRepoUrl: string }): Promise {
+ const git = await getSimpleGitWithShellPath(repoPath);
+ await git.raw(["config", `branch.${branch}.pr-base-repo`, baseRepoUrl]);
+}
+
+export async function unsetBranchPullRequestBaseRepoConfig({
+ repoPath,
+ branch,
+}: BranchConfigParams): Promise {
+ const git = await getSimpleGitWithShellPath(repoPath);
+ await git
+ .raw(["config", "--unset", `branch.${branch}.pr-base-repo`])
+ .catch(() => {});
+}
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git-client.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git-client.ts
index 86417b35482..b19235050e3 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git-client.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git-client.ts
@@ -1,13 +1,11 @@
import {
+ type ExecFileOptions,
type ExecFileOptionsWithStringEncoding,
execFile,
} from "node:child_process";
-import { promisify } from "node:util";
import simpleGit, { type SimpleGit } from "simple-git";
import { getProcessEnvWithShellPath } from "./shell-env";
-const execFileAsync = promisify(execFile);
-
export async function getSimpleGitWithShellPath(
repoPath?: string,
): Promise {
@@ -20,13 +18,48 @@ export async function execGitWithShellPath(
args: string[],
options?: Omit,
): Promise<{ stdout: string; stderr: string }> {
+ return execGitWithShellPathWithEncoding(args, {
+ ...options,
+ encoding: "utf8",
+ });
+}
+
+export async function execGitWithShellPathBuffer(
+ args: string[],
+ options?: Omit,
+): Promise<{ stdout: Buffer; stderr: Buffer }> {
+ return execGitWithShellPathWithEncoding(args, {
+ ...options,
+ encoding: "buffer",
+ });
+}
+
+async function execGitWithShellPathWithEncoding<
+ TEncoding extends BufferEncoding | "buffer",
+>(
+ args: string[],
+ options:
+ | (Omit & { encoding: TEncoding })
+ | undefined,
+): Promise<{
+ stdout: TEncoding extends "buffer" ? Buffer : string;
+ stderr: TEncoding extends "buffer" ? Buffer : string;
+}> {
const env = await getProcessEnvWithShellPath(
options?.env ? { ...process.env, ...options.env } : process.env,
);
- return execFileAsync("git", args, {
- ...options,
- encoding: "utf8",
- env,
+ return new Promise((resolve, reject) => {
+ execFile("git", args, { ...options, env }, (error, stdout, stderr) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ resolve({
+ stdout: stdout as TEncoding extends "buffer" ? Buffer : string,
+ stderr: stderr as TEncoding extends "buffer" ? Buffer : string,
+ });
+ });
});
}
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 84033314620..63324b30b59 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
@@ -409,6 +409,22 @@ export async function getGitAuthorName(
}
}
+export async function getGitAuthorEmail(
+ repoPath?: string,
+): Promise {
+ try {
+ const git = await getSimpleGitWithShellPath(repoPath);
+ const email = await git.getConfig("user.email");
+ return email.value?.trim() || null;
+ } catch (error) {
+ console.warn(
+ "[git/getGitAuthorEmail] Failed to read git user.email:",
+ error,
+ );
+ return null;
+ }
+}
+
let cachedGitHubUsername: { value: string | null; timestamp: number } | null =
null;
const GITHUB_USERNAME_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.test.ts
index a334ebf747a..dc9f25a1769 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.test.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.test.ts
@@ -86,7 +86,7 @@ describe("getCachedGitHubStatusState", () => {
try {
setCachedGitHubStatus(worktreePath, status);
- Date.now = () => 1000 + 10_001;
+ Date.now = () => 1000 + 30_001;
expect(getCachedGitHubStatus(worktreePath)).toBeNull();
expect(getCachedGitHubStatusState(worktreePath)).toEqual({
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts
index adda1913d44..04edaed046f 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts
@@ -4,15 +4,22 @@ import {
type CacheState,
createCachedResource,
} from "./cached-resource";
+import { recordGitHubCacheMetric } from "./github-metrics";
import type { RepoContext } from "./types";
-const GITHUB_STATUS_CACHE_TTL_MS = 10_000;
-const GITHUB_PR_COMMENTS_CACHE_TTL_MS = 30_000;
+const GITHUB_STATUS_CACHE_TTL_MS = 30_000;
+const GITHUB_PR_COMMENTS_CACHE_TTL_MS = 60_000;
+const GITHUB_PREVIEW_URL_CACHE_TTL_MS = 10 * 60 * 1000;
const GITHUB_REPO_CONTEXT_CACHE_TTL_MS = 300_000;
+const GITHUB_COMMIT_AUTHOR_CACHE_TTL_MS = 300_000;
+const GITHUB_NO_PR_MATCH_CACHE_TTL_MS = 120_000;
const MAX_GITHUB_STATUS_CACHE_ENTRIES = 256;
const MAX_GITHUB_PR_COMMENTS_CACHE_ENTRIES = 512;
+const MAX_GITHUB_PREVIEW_URL_CACHE_ENTRIES = 512;
const MAX_GITHUB_REPO_CONTEXT_CACHE_ENTRIES = 256;
+const MAX_GITHUB_COMMIT_AUTHOR_CACHE_ENTRIES = 2048;
+const MAX_GITHUB_NO_PR_MATCH_CACHE_ENTRIES = 512;
const githubStatusResource = createCachedResource({
ttlMs: GITHUB_STATUS_CACHE_TTL_MS,
@@ -24,15 +31,42 @@ const pullRequestCommentsResource = createCachedResource({
maxEntries: MAX_GITHUB_PR_COMMENTS_CACHE_ENTRIES,
});
+const previewUrlResource = createCachedResource({
+ ttlMs: GITHUB_PREVIEW_URL_CACHE_TTL_MS,
+ maxEntries: MAX_GITHUB_PREVIEW_URL_CACHE_ENTRIES,
+});
+
const repoContextResource = createCachedResource({
ttlMs: GITHUB_REPO_CONTEXT_CACHE_TTL_MS,
maxEntries: MAX_GITHUB_REPO_CONTEXT_CACHE_ENTRIES,
});
+const noPullRequestMatchResource = createCachedResource({
+ ttlMs: GITHUB_NO_PR_MATCH_CACHE_TTL_MS,
+ maxEntries: MAX_GITHUB_NO_PR_MATCH_CACHE_ENTRIES,
+});
+
+export interface GitHubCommitAuthor {
+ login: string | null;
+ avatarUrl: string | null;
+}
+
+const commitAuthorResource = createCachedResource({
+ ttlMs: GITHUB_COMMIT_AUTHOR_CACHE_TTL_MS,
+ maxEntries: MAX_GITHUB_COMMIT_AUTHOR_CACHE_ENTRIES,
+});
+
export function getCachedGitHubStatus(
worktreePath: string,
): GitHubStatus | null {
- return githubStatusResource.get(worktreePath);
+ const cachedState = githubStatusResource.getState(worktreePath);
+ const cached = cachedState?.isFresh ? cachedState.value : null;
+ recordGitHubCacheMetric({
+ kind: "status",
+ event: cachedState?.isFresh ? "fresh_hit" : "miss",
+ worktreePath,
+ });
+ return cached;
}
export function getCachedGitHubStatusState(
@@ -46,6 +80,11 @@ export function setCachedGitHubStatus(
value: GitHubStatus,
): void {
githubStatusResource.set(worktreePath, value);
+ recordGitHubCacheMetric({
+ kind: "status",
+ event: "write",
+ worktreePath,
+ });
}
export function readCachedGitHubStatus(
@@ -53,9 +92,34 @@ export function readCachedGitHubStatus(
load: () => Promise,
options?: CachedResourceReadOptions,
): Promise {
+ const cached = githubStatusResource.getState(worktreePath);
+ recordGitHubCacheMetric({
+ kind: "status",
+ event: options?.forceFresh
+ ? "force_fresh"
+ : cached?.isFresh
+ ? "fresh_hit"
+ : cached
+ ? "stale_hit"
+ : "miss",
+ worktreePath,
+ });
+
return githubStatusResource.read(worktreePath, load, {
...options,
- shouldCache: options?.shouldCache ?? ((value) => value !== null),
+ shouldCache:
+ options?.shouldCache ??
+ ((value) => {
+ const shouldCache = value !== null;
+ if (shouldCache) {
+ recordGitHubCacheMetric({
+ kind: "status",
+ event: "write",
+ worktreePath,
+ });
+ }
+ return shouldCache;
+ }),
});
}
@@ -80,7 +144,14 @@ export function makePullRequestCommentsCacheKey({
export function getCachedPullRequestComments(
cacheKey: string,
): PullRequestComment[] | null {
- return pullRequestCommentsResource.get(cacheKey);
+ const cachedState = pullRequestCommentsResource.getState(cacheKey);
+ const cached = cachedState?.isFresh ? cachedState.value : null;
+ recordGitHubCacheMetric({
+ kind: "comments",
+ event: cachedState?.isFresh ? "fresh_hit" : "miss",
+ worktreePath: extractWorktreePathFromCacheKey(cacheKey),
+ });
+ return cached;
}
export function getCachedPullRequestCommentsState(
@@ -94,6 +165,11 @@ export function setCachedPullRequestComments(
value: PullRequestComment[],
): void {
pullRequestCommentsResource.set(cacheKey, value);
+ recordGitHubCacheMetric({
+ kind: "comments",
+ event: "write",
+ worktreePath: extractWorktreePathFromCacheKey(cacheKey),
+ });
}
export function readCachedPullRequestComments(
@@ -101,7 +177,129 @@ export function readCachedPullRequestComments(
load: () => Promise,
options?: CachedResourceReadOptions,
): Promise {
- return pullRequestCommentsResource.read(cacheKey, load, options);
+ const worktreePath = extractWorktreePathFromCacheKey(cacheKey);
+ const cached = pullRequestCommentsResource.getState(cacheKey);
+ recordGitHubCacheMetric({
+ kind: "comments",
+ event: options?.forceFresh
+ ? "force_fresh"
+ : cached?.isFresh
+ ? "fresh_hit"
+ : cached
+ ? "stale_hit"
+ : "miss",
+ worktreePath,
+ });
+
+ return pullRequestCommentsResource.read(
+ cacheKey,
+ async () => {
+ const value = await load();
+ recordGitHubCacheMetric({
+ kind: "comments",
+ event: "write",
+ worktreePath,
+ });
+ return value;
+ },
+ options,
+ );
+}
+
+export function makeGitHubPreviewCachePrefix(worktreePath: string): string {
+ return `${worktreePath}::preview::`;
+}
+
+export function makeGitHubNoPullRequestCachePrefix(
+ worktreePath: string,
+): string {
+ return `${worktreePath}::no-pr::`;
+}
+
+export function makeGitHubNoPullRequestCacheKey({
+ worktreePath,
+ localBranch,
+ headSha,
+}: {
+ worktreePath: string;
+ localBranch: string;
+ headSha?: string;
+}): string {
+ return `${makeGitHubNoPullRequestCachePrefix(worktreePath)}${localBranch}::${headSha ?? "no-head"}`;
+}
+
+export function hasCachedNoPullRequestMatch(cacheKey: string): boolean {
+ return noPullRequestMatchResource.get(cacheKey) === true;
+}
+
+export function setCachedNoPullRequestMatch(cacheKey: string): void {
+ noPullRequestMatchResource.set(cacheKey, true);
+}
+
+export function clearCachedNoPullRequestMatch(cacheKey: string): void {
+ noPullRequestMatchResource.invalidate(cacheKey);
+}
+
+export function makeGitHubPreviewCacheKey({
+ worktreePath,
+ repoNameWithOwner,
+ branchName,
+ headSha,
+ pullRequestNumber,
+}: {
+ worktreePath: string;
+ repoNameWithOwner: string;
+ branchName: string;
+ headSha?: string;
+ pullRequestNumber?: number | null;
+}): string {
+ return `${makeGitHubPreviewCachePrefix(worktreePath)}${repoNameWithOwner}::${branchName}::${headSha ?? "no-head"}::pr-${pullRequestNumber ?? "none"}`;
+}
+
+export function getCachedGitHubPreviewUrl(cacheKey: string): string | null {
+ const cachedState = previewUrlResource.getState(cacheKey);
+ const cached = cachedState?.isFresh ? cachedState.value : null;
+ recordGitHubCacheMetric({
+ kind: "preview",
+ event: cachedState?.isFresh ? "fresh_hit" : "miss",
+ worktreePath: extractWorktreePathFromCacheKey(cacheKey),
+ });
+ return cached;
+}
+
+export function readCachedGitHubPreviewUrl(
+ cacheKey: string,
+ load: () => Promise,
+ options?: CachedResourceReadOptions,
+): Promise {
+ const worktreePath = extractWorktreePathFromCacheKey(cacheKey);
+ const cached = previewUrlResource.getState(cacheKey);
+ recordGitHubCacheMetric({
+ kind: "preview",
+ event: options?.forceFresh
+ ? "force_fresh"
+ : cached?.isFresh
+ ? "fresh_hit"
+ : cached
+ ? "stale_hit"
+ : "miss",
+ worktreePath,
+ });
+
+ return previewUrlResource.read(cacheKey, load, {
+ ...options,
+ // Cache misses too so preview-less branches don't repeatedly hit deployments.
+ shouldCache:
+ options?.shouldCache ??
+ (() => {
+ recordGitHubCacheMetric({
+ kind: "preview",
+ event: "write",
+ worktreePath,
+ });
+ return true;
+ }),
+ });
}
export function getCachedRepoContext(worktreePath: string): RepoContext | null {
@@ -132,10 +330,64 @@ export function readCachedRepoContext(
});
}
+export function makeGitHubCommitAuthorCacheKey({
+ repoNameWithOwner,
+ commitHash,
+}: {
+ repoNameWithOwner: string;
+ commitHash: string;
+}): string {
+ return `${repoNameWithOwner}#${commitHash}`;
+}
+
+export function readCachedGitHubCommitAuthor(
+ cacheKey: string,
+ load: () => Promise,
+ options?: CachedResourceReadOptions,
+): Promise {
+ return commitAuthorResource.read(cacheKey, load, options);
+}
+
export function clearGitHubCachesForWorktree(worktreePath: string): void {
githubStatusResource.invalidate(worktreePath);
repoContextResource.invalidate(worktreePath);
+ recordGitHubCacheMetric({
+ kind: "status",
+ event: "invalidate",
+ worktreePath,
+ });
+ previewUrlResource.invalidatePrefix(
+ makeGitHubPreviewCachePrefix(worktreePath),
+ );
+ recordGitHubCacheMetric({
+ kind: "preview",
+ event: "invalidate",
+ worktreePath,
+ });
pullRequestCommentsResource.invalidatePrefix(
makePullRequestCommentsCachePrefix(worktreePath),
);
+ noPullRequestMatchResource.invalidatePrefix(
+ makeGitHubNoPullRequestCachePrefix(worktreePath),
+ );
+ recordGitHubCacheMetric({
+ kind: "comments",
+ event: "invalidate",
+ worktreePath,
+ });
+}
+
+function extractWorktreePathFromCacheKey(cacheKey: string): string | null {
+ const commentsSeparator = "::comments::";
+ const previewSeparator = "::preview::";
+
+ if (cacheKey.includes(commentsSeparator)) {
+ return cacheKey.split(commentsSeparator)[0] || null;
+ }
+
+ if (cacheKey.includes(previewSeparator)) {
+ return cacheKey.split(previewSeparator)[0] || null;
+ }
+
+ return cacheKey || null;
}
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts
index c4b7a7538b4..a3451fb1452 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts
@@ -1,6 +1,7 @@
import type { PullRequestComment } from "@superset/local-db";
import type { z } from "zod";
import { execWithShellEnv } from "../shell-env";
+import { trackGitHubOperation } from "./github-metrics";
import {
GHIssueCommentSchema,
type GHReviewThreadCommentSchema,
@@ -100,6 +101,149 @@ function sortPullRequestComments(
return comments.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
}
+const RESOLVE_REVIEW_THREAD_MUTATION = `
+mutation ResolveReviewThread($threadId: ID!) {
+ resolveReviewThread(input: {threadId: $threadId}) {
+ thread {
+ id
+ isResolved
+ }
+ }
+}
+`;
+
+const UNRESOLVE_REVIEW_THREAD_MUTATION = `
+mutation UnresolveReviewThread($threadId: ID!) {
+ unresolveReviewThread(input: {threadId: $threadId}) {
+ thread {
+ id
+ isResolved
+ }
+ }
+}
+`;
+
+export async function resolveReviewThread({
+ worktreePath,
+ threadId,
+ resolve,
+}: {
+ worktreePath: string;
+ threadId: string;
+ resolve: boolean;
+}): Promise {
+ const mutation = resolve
+ ? RESOLVE_REVIEW_THREAD_MUTATION
+ : UNRESOLVE_REVIEW_THREAD_MUTATION;
+
+ const { stdout } = await trackGitHubOperation({
+ name: "gh_graphql_resolve_review_thread",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv(
+ "gh",
+ [
+ "api",
+ "graphql",
+ "-f",
+ `query=${mutation}`,
+ "-F",
+ `threadId=${threadId}`,
+ ],
+ { cwd: worktreePath },
+ ),
+ });
+
+ const json = JSON.parse(stdout.trim());
+ if (Array.isArray(json.errors) && json.errors.length > 0) {
+ const msg = json.errors
+ .map((e: { message?: string }) => e.message)
+ .join("; ");
+ throw new Error(msg || "GraphQL mutation failed");
+ }
+}
+
+const ADD_REVIEW_THREAD_REPLY_MUTATION = `
+mutation AddPullRequestReviewThreadReply($threadId: ID!, $body: String!) {
+ addPullRequestReviewThreadReply(input: {pullRequestReviewThreadId: $threadId, body: $body}) {
+ comment {
+ id
+ }
+ }
+}
+`;
+
+export async function replyToReviewThread({
+ worktreePath,
+ threadId,
+ body,
+}: {
+ worktreePath: string;
+ threadId: string;
+ body: string;
+}): Promise {
+ const { stdout } = await trackGitHubOperation({
+ name: "gh_graphql_reply_review_thread",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv(
+ "gh",
+ [
+ "api",
+ "graphql",
+ "-f",
+ `query=${ADD_REVIEW_THREAD_REPLY_MUTATION}`,
+ "-F",
+ `threadId=${threadId}`,
+ "-f",
+ `body=${body}`,
+ ],
+ { cwd: worktreePath },
+ ),
+ });
+
+ const json = JSON.parse(stdout.trim());
+ if (Array.isArray(json.errors) && json.errors.length > 0) {
+ const msg = json.errors
+ .map((e: { message?: string }) => e.message)
+ .join("; ");
+ throw new Error(msg || "GraphQL mutation failed");
+ }
+}
+
+export async function addPullRequestConversationComment({
+ worktreePath,
+ repoNameWithOwner,
+ pullRequestNumber,
+ body,
+}: {
+ worktreePath: string;
+ repoNameWithOwner: string;
+ pullRequestNumber: number;
+ body: string;
+}): Promise {
+ await trackGitHubOperation({
+ name: "gh_api_add_issue_comment",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv(
+ "gh",
+ [
+ "api",
+ "--method",
+ "POST",
+ `repos/${repoNameWithOwner}/issues/${pullRequestNumber}/comments`,
+ "-f",
+ `body=${body}`,
+ ],
+ { cwd: worktreePath },
+ ),
+ });
+}
+
function getReviewThreadCommentId(
comment: ReviewThreadCommentNode,
): string | null {
@@ -135,10 +279,10 @@ function parseReviewThreadCommentNode({
createdAt: parseTimestamp(comment.createdAt),
url: comment.url,
kind: "review" as const,
+ threadId,
path: comment.path,
line: comment.line ?? comment.originalLine ?? undefined,
isResolved,
- ...(threadId ? { threadId } : {}),
};
}
@@ -207,7 +351,7 @@ export function parseReviewThreadCommentsResponse(
return parseReviewThreadCommentsConnection({
comments: result.data.comments,
isResolved: result.data.isResolved === true,
- threadId: result.data.id,
+ threadId: result.data.id ?? undefined,
});
}),
);
@@ -261,65 +405,19 @@ export function mergePullRequestComments(
return sortPullRequestComments([...commentsById.values()]);
}
-const RESOLVE_REVIEW_THREAD_MUTATION = `
-mutation ResolveReviewThread($threadId: ID!) {
- resolveReviewThread(input: {threadId: $threadId}) {
- thread {
- id
- isResolved
- }
- }
-}
-`;
-
-const UNRESOLVE_REVIEW_THREAD_MUTATION = `
-mutation UnresolveReviewThread($threadId: ID!) {
- unresolveReviewThread(input: {threadId: $threadId}) {
- thread {
- id
- isResolved
- }
- }
-}
-`;
-
-export async function resolveReviewThread({
- worktreePath,
- threadId,
- resolve,
-}: {
- worktreePath: string;
- threadId: string;
- resolve: boolean;
-}): Promise {
- const mutation = resolve
- ? RESOLVE_REVIEW_THREAD_MUTATION
- : UNRESOLVE_REVIEW_THREAD_MUTATION;
-
- const { stdout } = await execWithShellEnv(
- "gh",
- ["api", "graphql", "-f", `query=${mutation}`, "-F", `threadId=${threadId}`],
- { cwd: worktreePath },
- );
-
- const json = JSON.parse(stdout.trim());
- if (Array.isArray(json.errors) && json.errors.length > 0) {
- const msg = json.errors
- .map((e: { message?: string }) => e.message)
- .join("; ");
- throw new Error(msg || "GraphQL mutation failed");
- }
-}
-
async function fetchPaginatedCommentsEndpoint(
worktreePath: string,
endpoint: string,
): Promise {
- const { stdout } = await execWithShellEnv(
- "gh",
- ["api", "--paginate", "--slurp", endpoint],
- { cwd: worktreePath },
- );
+ const { stdout } = await trackGitHubOperation({
+ name: "gh_api_issue_comments_paginated",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv("gh", ["api", "--paginate", "--slurp", endpoint], {
+ cwd: worktreePath,
+ }),
+ });
return parsePaginatedApiArray(stdout);
}
@@ -362,20 +460,26 @@ async function fetchAdditionalReviewThreadCommentsForThread({
while (afterCursor) {
let stdout: string;
try {
- const result = await execWithShellEnv(
- "gh",
- [
- "api",
- "graphql",
- "-f",
- `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
- "-F",
- `threadId=${threadId}`,
- "-F",
- `after=${afterCursor}`,
- ],
- { cwd: worktreePath },
- );
+ const result = await trackGitHubOperation({
+ name: "gh_graphql_review_thread_comments_page",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv(
+ "gh",
+ [
+ "api",
+ "graphql",
+ "-f",
+ `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
+ "-F",
+ `threadId=${threadId}`,
+ "-F",
+ `after=${afterCursor}`,
+ ],
+ { cwd: worktreePath },
+ ),
+ });
stdout = result.stdout;
} catch (error) {
console.warn(
@@ -461,8 +565,14 @@ async function fetchReviewThreadCommentsForPullRequest(
let stdout: string;
try {
- const result = await execWithShellEnv("gh", args, {
- cwd: worktreePath,
+ const result = await trackGitHubOperation({
+ name: "gh_graphql_review_threads",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv("gh", args, {
+ cwd: worktreePath,
+ }),
});
stdout = result.stdout;
} catch (error) {
@@ -511,7 +621,7 @@ async function fetchReviewThreadCommentsForPullRequest(
...parseReviewThreadCommentsConnection({
comments: thread.comments,
isResolved,
- threadId: thread.id,
+ threadId: thread.id ?? undefined,
}),
);
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-metrics.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-metrics.ts
new file mode 100644
index 00000000000..a49f78bbb3a
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-metrics.ts
@@ -0,0 +1,574 @@
+const ROLLING_WINDOW_MS = 5 * 60 * 1000;
+const MAX_RECENT_OPERATION_EVENTS = 2000;
+const MAX_RECENT_CACHE_EVENTS = 2000;
+const MAX_LAST_ERRORS = 20;
+
+export type GitHubMetricOperationCategory = "sync" | "gh";
+export type GitHubCacheMetricKind = "status" | "comments" | "preview";
+export type GitHubCacheMetricEvent =
+ | "fresh_hit"
+ | "stale_hit"
+ | "miss"
+ | "force_fresh"
+ | "write"
+ | "invalidate";
+
+interface OperationEvent {
+ timestamp: number;
+ name: string;
+ category: GitHubMetricOperationCategory;
+ success: boolean;
+ rateLimited: boolean;
+ durationMs: number;
+ worktreePath: string | null;
+ errorMessage: string | null;
+}
+
+interface CacheEvent {
+ timestamp: number;
+ kind: GitHubCacheMetricKind;
+ event: GitHubCacheMetricEvent;
+ worktreePath: string | null;
+}
+
+interface OperationAggregateWorkspace {
+ calls: number;
+ successes: number;
+ failures: number;
+ rateLimited: number;
+ lastRunAt: number | null;
+}
+
+interface OperationAggregate {
+ name: string;
+ category: GitHubMetricOperationCategory;
+ calls: number;
+ successes: number;
+ failures: number;
+ rateLimited: number;
+ totalDurationMs: number;
+ maxDurationMs: number;
+ lastDurationMs: number | null;
+ lastRunAt: number | null;
+ lastErrorAt: number | null;
+ lastErrorMessage: string | null;
+ workspaces: Map;
+}
+
+interface CacheAggregate {
+ kind: GitHubCacheMetricKind;
+ freshHits: number;
+ staleHits: number;
+ misses: number;
+ forceFresh: number;
+ writes: number;
+ invalidations: number;
+}
+
+interface LastErrorEntry {
+ at: number;
+ operation: string;
+ category: GitHubMetricOperationCategory;
+ message: string;
+ worktreePath: string | null;
+}
+
+export interface GitHubOperationWorkspaceBreakdown {
+ worktreePath: string;
+ sessionCalls: number;
+ rolling5mCalls: number;
+ lastRunAt: number | null;
+}
+
+export interface GitHubOperationMetricSnapshot {
+ name: string;
+ category: GitHubMetricOperationCategory;
+ session: {
+ calls: number;
+ successes: number;
+ failures: number;
+ rateLimited: number;
+ avgDurationMs: number;
+ maxDurationMs: number;
+ };
+ rolling5m: {
+ calls: number;
+ successes: number;
+ failures: number;
+ rateLimited: number;
+ avgDurationMs: number;
+ maxDurationMs: number;
+ };
+ lastRunAt: number | null;
+ lastDurationMs: number | null;
+ lastErrorAt: number | null;
+ lastErrorMessage: string | null;
+ workspaces: GitHubOperationWorkspaceBreakdown[];
+}
+
+export interface GitHubCacheMetricSnapshot {
+ kind: GitHubCacheMetricKind;
+ session: CacheAggregateCounts;
+ rolling5m: CacheAggregateCounts;
+}
+
+interface CacheAggregateCounts {
+ freshHits: number;
+ staleHits: number;
+ misses: number;
+ forceFresh: number;
+ writes: number;
+ invalidations: number;
+}
+
+export interface GitHubMetricsSnapshot {
+ sessionStartedAt: number;
+ generatedAt: number;
+ totals: {
+ sessionCallCount: number;
+ sessionFailureCount: number;
+ rolling5mCallCount: number;
+ rolling5mFailureCount: number;
+ rolling5mRateLimitedCount: number;
+ };
+ operations: GitHubOperationMetricSnapshot[];
+ caches: GitHubCacheMetricSnapshot[];
+ lastErrors: LastErrorEntry[];
+}
+
+const sessionStartedAt = Date.now();
+const operationAggregates = new Map();
+const cacheAggregates = new Map();
+const recentOperationEvents: OperationEvent[] = [];
+const recentCacheEvents: CacheEvent[] = [];
+const lastErrors: LastErrorEntry[] = [];
+
+function trimRecentEvents(now: number): void {
+ const operationCutoff = now - ROLLING_WINDOW_MS;
+ while (
+ recentOperationEvents.length > 0 &&
+ (recentOperationEvents.length > MAX_RECENT_OPERATION_EVENTS ||
+ recentOperationEvents[0]?.timestamp < operationCutoff)
+ ) {
+ recentOperationEvents.shift();
+ }
+
+ while (
+ recentCacheEvents.length > 0 &&
+ (recentCacheEvents.length > MAX_RECENT_CACHE_EVENTS ||
+ recentCacheEvents[0]?.timestamp < operationCutoff)
+ ) {
+ recentCacheEvents.shift();
+ }
+}
+
+function getOperationAggregateKey(
+ name: string,
+ category: GitHubMetricOperationCategory,
+): string {
+ return `${category}:${name}`;
+}
+
+function getOrCreateOperationAggregate({
+ name,
+ category,
+}: {
+ name: string;
+ category: GitHubMetricOperationCategory;
+}): OperationAggregate {
+ const key = getOperationAggregateKey(name, category);
+ const existing = operationAggregates.get(key);
+ if (existing) {
+ return existing;
+ }
+
+ const aggregate: OperationAggregate = {
+ name,
+ category,
+ calls: 0,
+ successes: 0,
+ failures: 0,
+ rateLimited: 0,
+ totalDurationMs: 0,
+ maxDurationMs: 0,
+ lastDurationMs: null,
+ lastRunAt: null,
+ lastErrorAt: null,
+ lastErrorMessage: null,
+ workspaces: new Map(),
+ };
+ operationAggregates.set(key, aggregate);
+ return aggregate;
+}
+
+function getOrCreateCacheAggregate(
+ kind: GitHubCacheMetricKind,
+): CacheAggregate {
+ const existing = cacheAggregates.get(kind);
+ if (existing) {
+ return existing;
+ }
+
+ const aggregate: CacheAggregate = {
+ kind,
+ freshHits: 0,
+ staleHits: 0,
+ misses: 0,
+ forceFresh: 0,
+ writes: 0,
+ invalidations: 0,
+ };
+ cacheAggregates.set(kind, aggregate);
+ return aggregate;
+}
+
+function normalizeErrorMessage(error: unknown): string | null {
+ if (error instanceof Error) {
+ return error.message.slice(0, 300);
+ }
+
+ if (typeof error === "string") {
+ return error.slice(0, 300);
+ }
+
+ return null;
+}
+
+function recordLastError(entry: LastErrorEntry): void {
+ lastErrors.push(entry);
+ if (lastErrors.length > MAX_LAST_ERRORS) {
+ lastErrors.shift();
+ }
+}
+
+export function trackGitHubOperationEvent({
+ name,
+ category,
+ worktreePath = null,
+ success,
+ durationMs,
+ rateLimited = false,
+ error,
+}: {
+ name: string;
+ category: GitHubMetricOperationCategory;
+ worktreePath?: string | null;
+ success: boolean;
+ durationMs: number;
+ rateLimited?: boolean;
+ error?: unknown;
+}): void {
+ const now = Date.now();
+ trimRecentEvents(now);
+
+ const errorMessage = success ? null : normalizeErrorMessage(error);
+ const aggregate = getOrCreateOperationAggregate({ name, category });
+ aggregate.calls += 1;
+ aggregate.successes += success ? 1 : 0;
+ aggregate.failures += success ? 0 : 1;
+ aggregate.rateLimited += rateLimited ? 1 : 0;
+ aggregate.totalDurationMs += durationMs;
+ aggregate.maxDurationMs = Math.max(aggregate.maxDurationMs, durationMs);
+ aggregate.lastDurationMs = durationMs;
+ aggregate.lastRunAt = now;
+
+ if (errorMessage) {
+ aggregate.lastErrorAt = now;
+ aggregate.lastErrorMessage = errorMessage;
+ recordLastError({
+ at: now,
+ operation: name,
+ category,
+ message: errorMessage,
+ worktreePath,
+ });
+ }
+
+ if (worktreePath) {
+ const workspaceAggregate = aggregate.workspaces.get(worktreePath) ?? {
+ calls: 0,
+ successes: 0,
+ failures: 0,
+ rateLimited: 0,
+ lastRunAt: null,
+ };
+ workspaceAggregate.calls += 1;
+ workspaceAggregate.successes += success ? 1 : 0;
+ workspaceAggregate.failures += success ? 0 : 1;
+ workspaceAggregate.rateLimited += rateLimited ? 1 : 0;
+ workspaceAggregate.lastRunAt = now;
+ aggregate.workspaces.set(worktreePath, workspaceAggregate);
+ }
+
+ recentOperationEvents.push({
+ timestamp: now,
+ name,
+ category,
+ success,
+ rateLimited,
+ durationMs,
+ worktreePath,
+ errorMessage,
+ });
+}
+
+export async function trackGitHubOperation({
+ name,
+ category,
+ worktreePath = null,
+ fn,
+}: {
+ name: string;
+ category: GitHubMetricOperationCategory;
+ worktreePath?: string | null;
+ fn: () => Promise;
+}): Promise {
+ const startedAt = Date.now();
+ try {
+ const result = await fn();
+ trackGitHubOperationEvent({
+ name,
+ category,
+ worktreePath,
+ success: true,
+ durationMs: Date.now() - startedAt,
+ });
+ return result;
+ } catch (error) {
+ trackGitHubOperationEvent({
+ name,
+ category,
+ worktreePath,
+ success: false,
+ durationMs: Date.now() - startedAt,
+ error,
+ });
+ throw error;
+ }
+}
+
+export function recordGitHubCacheMetric({
+ kind,
+ event,
+ worktreePath = null,
+}: {
+ kind: GitHubCacheMetricKind;
+ event: GitHubCacheMetricEvent;
+ worktreePath?: string | null;
+}): void {
+ const now = Date.now();
+ trimRecentEvents(now);
+
+ const aggregate = getOrCreateCacheAggregate(kind);
+ switch (event) {
+ case "fresh_hit":
+ aggregate.freshHits += 1;
+ break;
+ case "stale_hit":
+ aggregate.staleHits += 1;
+ break;
+ case "miss":
+ aggregate.misses += 1;
+ break;
+ case "force_fresh":
+ aggregate.forceFresh += 1;
+ break;
+ case "write":
+ aggregate.writes += 1;
+ break;
+ case "invalidate":
+ aggregate.invalidations += 1;
+ break;
+ }
+
+ recentCacheEvents.push({
+ timestamp: now,
+ kind,
+ event,
+ worktreePath,
+ });
+}
+
+export function getGitHubMetricsSnapshot(): GitHubMetricsSnapshot {
+ const now = Date.now();
+ trimRecentEvents(now);
+ const rollingOperationCutoff = now - ROLLING_WINDOW_MS;
+ const recentOperations = recentOperationEvents.filter(
+ (event) => event.timestamp >= rollingOperationCutoff,
+ );
+ const recentCaches = recentCacheEvents.filter(
+ (event) => event.timestamp >= rollingOperationCutoff,
+ );
+
+ const operations = [...operationAggregates.values()]
+ .map((aggregate) => {
+ const rolling = recentOperations.filter(
+ (event) =>
+ event.name === aggregate.name &&
+ event.category === aggregate.category,
+ );
+ const rollingWorkspaceMap = new Map<
+ string,
+ { calls: number; lastRunAt: number | null }
+ >();
+
+ for (const event of rolling) {
+ if (!event.worktreePath) {
+ continue;
+ }
+ const workspaceEntry = rollingWorkspaceMap.get(event.worktreePath) ?? {
+ calls: 0,
+ lastRunAt: null,
+ };
+ workspaceEntry.calls += 1;
+ workspaceEntry.lastRunAt = event.timestamp;
+ rollingWorkspaceMap.set(event.worktreePath, workspaceEntry);
+ }
+
+ const rollingTotalDurationMs = rolling.reduce(
+ (total, event) => total + event.durationMs,
+ 0,
+ );
+
+ const workspaces = [...aggregate.workspaces.entries()]
+ .map(([worktreePath, workspaceAggregate]) => ({
+ worktreePath,
+ sessionCalls: workspaceAggregate.calls,
+ rolling5mCalls: rollingWorkspaceMap.get(worktreePath)?.calls ?? 0,
+ lastRunAt:
+ rollingWorkspaceMap.get(worktreePath)?.lastRunAt ??
+ workspaceAggregate.lastRunAt,
+ }))
+ .sort((left, right) => right.sessionCalls - left.sessionCalls);
+
+ return {
+ name: aggregate.name,
+ category: aggregate.category,
+ session: {
+ calls: aggregate.calls,
+ successes: aggregate.successes,
+ failures: aggregate.failures,
+ rateLimited: aggregate.rateLimited,
+ avgDurationMs:
+ aggregate.calls > 0
+ ? aggregate.totalDurationMs / aggregate.calls
+ : 0,
+ maxDurationMs: aggregate.maxDurationMs,
+ },
+ rolling5m: {
+ calls: rolling.length,
+ successes: rolling.filter((event) => event.success).length,
+ failures: rolling.filter((event) => !event.success).length,
+ rateLimited: rolling.filter((event) => event.rateLimited).length,
+ avgDurationMs:
+ rolling.length > 0 ? rollingTotalDurationMs / rolling.length : 0,
+ maxDurationMs: rolling.reduce(
+ (max, event) => Math.max(max, event.durationMs),
+ 0,
+ ),
+ },
+ lastRunAt: aggregate.lastRunAt,
+ lastDurationMs: aggregate.lastDurationMs,
+ lastErrorAt: aggregate.lastErrorAt,
+ lastErrorMessage: aggregate.lastErrorMessage,
+ workspaces,
+ };
+ })
+ .sort((left, right) => {
+ if (right.rolling5m.calls !== left.rolling5m.calls) {
+ return right.rolling5m.calls - left.rolling5m.calls;
+ }
+ return right.session.calls - left.session.calls;
+ });
+
+ const caches: GitHubCacheMetricSnapshot[] = (
+ ["status", "comments", "preview"] as const
+ ).map((kind) => {
+ const session = cacheAggregates.get(kind) ?? {
+ kind,
+ freshHits: 0,
+ staleHits: 0,
+ misses: 0,
+ forceFresh: 0,
+ writes: 0,
+ invalidations: 0,
+ };
+ const rolling = recentCaches.filter((event) => event.kind === kind);
+ const rollingCounts = rolling.reduce(
+ (counts, event) => {
+ switch (event.event) {
+ case "fresh_hit":
+ counts.freshHits += 1;
+ break;
+ case "stale_hit":
+ counts.staleHits += 1;
+ break;
+ case "miss":
+ counts.misses += 1;
+ break;
+ case "force_fresh":
+ counts.forceFresh += 1;
+ break;
+ case "write":
+ counts.writes += 1;
+ break;
+ case "invalidate":
+ counts.invalidations += 1;
+ break;
+ }
+ return counts;
+ },
+ {
+ freshHits: 0,
+ staleHits: 0,
+ misses: 0,
+ forceFresh: 0,
+ writes: 0,
+ invalidations: 0,
+ },
+ );
+
+ return {
+ kind,
+ session: {
+ freshHits: session.freshHits,
+ staleHits: session.staleHits,
+ misses: session.misses,
+ forceFresh: session.forceFresh,
+ writes: session.writes,
+ invalidations: session.invalidations,
+ },
+ rolling5m: rollingCounts,
+ };
+ });
+
+ return {
+ sessionStartedAt,
+ generatedAt: now,
+ totals: {
+ sessionCallCount: operations.reduce(
+ (total, operation) => total + operation.session.calls,
+ 0,
+ ),
+ sessionFailureCount: operations.reduce(
+ (total, operation) => total + operation.session.failures,
+ 0,
+ ),
+ rolling5mCallCount: operations.reduce(
+ (total, operation) => total + operation.rolling5m.calls,
+ 0,
+ ),
+ rolling5mFailureCount: operations.reduce(
+ (total, operation) => total + operation.rolling5m.failures,
+ 0,
+ ),
+ rolling5mRateLimitedCount: operations.reduce(
+ (total, operation) => total + operation.rolling5m.rateLimited,
+ 0,
+ ),
+ },
+ operations,
+ caches,
+ lastErrors: [...lastErrors].reverse(),
+ };
+}
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-rate-limiter.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-rate-limiter.ts
new file mode 100644
index 00000000000..e42078ff0f0
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-rate-limiter.ts
@@ -0,0 +1,79 @@
+/**
+ * Centralized GitHub API rate limiter.
+ *
+ * Detects HTTP 403 (secondary rate limit) errors from `gh` CLI commands
+ * and pauses ALL GitHub API calls with exponential backoff until the
+ * rate limit window resets.
+ */
+
+const INITIAL_BACKOFF_MS = 30_000;
+const MAX_BACKOFF_MS = 300_000;
+const BACKOFF_MULTIPLIER = 2;
+
+let pausedUntil = 0;
+let currentBackoffMs = INITIAL_BACKOFF_MS;
+let consecutiveFailures = 0;
+
+export interface GitHubRateLimitState {
+ isRateLimited: boolean;
+ resumeAt: number | null;
+ currentBackoffMs: number;
+ consecutiveFailures: number;
+}
+
+export function isRateLimited(): boolean {
+ return Date.now() < pausedUntil;
+}
+
+export function getRateLimitResumeTime(): number {
+ return pausedUntil;
+}
+
+export function getGitHubRateLimitState(): GitHubRateLimitState {
+ return {
+ isRateLimited: isRateLimited(),
+ resumeAt: pausedUntil > 0 ? pausedUntil : null,
+ currentBackoffMs,
+ consecutiveFailures,
+ };
+}
+
+export function onRateLimitHit(): void {
+ consecutiveFailures++;
+ currentBackoffMs = Math.min(
+ INITIAL_BACKOFF_MS * BACKOFF_MULTIPLIER ** (consecutiveFailures - 1),
+ MAX_BACKOFF_MS,
+ );
+ pausedUntil = Date.now() + currentBackoffMs;
+ console.warn(
+ `[GitHub] Rate limit hit. Pausing all API calls for ${currentBackoffMs / 1000}s (attempt ${consecutiveFailures})`,
+ );
+}
+
+export function onRateLimitSuccess(): void {
+ if (consecutiveFailures > 0) {
+ consecutiveFailures = 0;
+ currentBackoffMs = INITIAL_BACKOFF_MS;
+ console.log("[GitHub] Rate limit recovered. Resuming normal operations.");
+ }
+}
+
+export function isSecondaryRateLimitError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false;
+
+ const message = (error.message || "").toLowerCase();
+ const stdout =
+ "stdout" in error && typeof error.stdout === "string"
+ ? error.stdout.toLowerCase()
+ : "";
+ const stderr =
+ "stderr" in error && typeof error.stderr === "string"
+ ? error.stderr.toLowerCase()
+ : "";
+
+ const haystack = `${message} ${stdout} ${stderr}`;
+ return (
+ haystack.includes("secondary rate limit") ||
+ haystack.includes("exceeded a secondary rate limit")
+ );
+}
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-sync-service.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-sync-service.ts
new file mode 100644
index 00000000000..1d2d6112e61
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github-sync-service.ts
@@ -0,0 +1,489 @@
+/**
+ * GitHubSyncService — centralized GitHub API polling for all workspaces.
+ *
+ * Instead of each UI surface independently polling the GitHub API, this
+ * service runs per-workspace timers that proactively keep the backend
+ * cache warm. Frontend tRPC queries read from the always-warm cache
+ * without triggering additional API calls.
+ *
+ * Only the **active** workspace is polled. When the user switches to a
+ * different workspace, the previous one is deactivated (timers stopped)
+ * and the new one is activated (timers started).
+ *
+ * Intervals:
+ * - PR status: 30 seconds by default, 15 seconds while checks are pending
+ * - PR comments: 60 seconds for the currently attached PR only
+ *
+ * Rate limiting is handled by rateLimitedRefresh() in github.ts — the
+ * SyncService does NOT call onRateLimitHit/Success directly to avoid
+ * double-counting with the lower-level wrapper.
+ *
+ * --- FORK NOTE ---
+ * This service is a fork-specific replacement for upstream's frontend
+ * hover-debounce approach (useHoverGitHubStatus, commit be22b46dd, #3125).
+ * Upstream fetches GitHub data on-demand from the frontend; this fork
+ * centralizes polling in the backend for better API call efficiency.
+ * See also: githubQueryPolicy.ts for the frontend cache-reading strategy.
+ */
+
+import type { GitHubStatus, PullRequestComment } from "@superset/local-db";
+import type { PullRequestCommentsTarget } from "./github";
+import { isRateLimited } from "./github-rate-limiter";
+
+export const SYNC_PR_STATUS_INTERVAL_MS = 30_000;
+export const SYNC_PR_STATUS_PENDING_INTERVAL_MS = 15_000;
+export const SYNC_PR_COMMENTS_INTERVAL_MS = 60_000;
+
+type FetchPRStatusFn = (worktreePath: string) => Promise;
+type FetchPRCommentsFn = (params: {
+ worktreePath: string;
+ pullRequest?: PullRequestCommentsTarget | null;
+}) => Promise;
+
+interface WorkspaceSyncState {
+ worktreePath: string;
+ prStatusTimer: ReturnType | null;
+ prCommentsTimer: ReturnType | null;
+ nextPRStatusSyncAt: number | null;
+ nextPRCommentsSyncAt: number | null;
+ isActive: boolean;
+ prStatusInFlight: boolean;
+ prCommentsInFlight: boolean;
+ latestStatus: GitHubStatus | null;
+ lastPRStatusSuccessAt: number | null;
+ lastPRStatusErrorAt: number | null;
+ lastPRStatusErrorMessage: string | null;
+ lastPRCommentsSuccessAt: number | null;
+ lastPRCommentsErrorAt: number | null;
+ lastPRCommentsErrorMessage: string | null;
+}
+
+interface SyncServiceDeps {
+ fetchPRStatus: FetchPRStatusFn;
+ fetchPRComments: FetchPRCommentsFn;
+ onPRStatusUpdate?: (
+ worktreePath: string,
+ status: GitHubStatus | null,
+ ) => void;
+}
+
+export interface GitHubSyncWorkspaceDebugSnapshot {
+ worktreePath: string;
+ isActive: boolean;
+ prStatusInFlight: boolean;
+ prCommentsInFlight: boolean;
+ nextPRStatusSyncAt: number | null;
+ nextPRCommentsSyncAt: number | null;
+ prStatusIntervalMs: number;
+ prCommentsIntervalMs: number | null;
+ lastPRStatusSuccessAt: number | null;
+ lastPRStatusErrorAt: number | null;
+ lastPRStatusErrorMessage: string | null;
+ lastPRCommentsSuccessAt: number | null;
+ lastPRCommentsErrorAt: number | null;
+ lastPRCommentsErrorMessage: string | null;
+ latestStatus: {
+ hasPr: boolean;
+ prNumber: number | null;
+ checksStatus: NonNullable["checksStatus"] | null;
+ repoUrl: string | null;
+ branchExistsOnRemote: boolean;
+ lastRefreshed: number | null;
+ };
+}
+
+export interface GitHubSyncServiceDebugSnapshot {
+ registeredWorkspaceCount: number;
+ activeWorkspaceCount: number;
+ activeWorktreePaths: string[];
+ workspaces: GitHubSyncWorkspaceDebugSnapshot[];
+}
+
+class GitHubSyncServiceImpl {
+ private workspaces = new Map();
+ private deps: SyncServiceDeps | null = null;
+
+ initialize(deps: SyncServiceDeps): void {
+ this.deps = deps;
+ }
+
+ /**
+ * Register a workspace WITHOUT starting polling timers.
+ * The workspace is registered as inactive — call activateWorkspace()
+ * or setActiveWorkspace() to start polling.
+ *
+ * This prevents the "all workspaces poll until setActiveWorkspace
+ * arrives" race condition at startup.
+ */
+ registerWorkspace(worktreePath: string): void {
+ if (this.workspaces.has(worktreePath)) {
+ return;
+ }
+
+ const state: WorkspaceSyncState = {
+ worktreePath,
+ prStatusTimer: null,
+ prCommentsTimer: null,
+ nextPRStatusSyncAt: null,
+ nextPRCommentsSyncAt: null,
+ isActive: false,
+ prStatusInFlight: false,
+ prCommentsInFlight: false,
+ latestStatus: null,
+ lastPRStatusSuccessAt: null,
+ lastPRStatusErrorAt: null,
+ lastPRStatusErrorMessage: null,
+ lastPRCommentsSuccessAt: null,
+ lastPRCommentsErrorAt: null,
+ lastPRCommentsErrorMessage: null,
+ };
+
+ this.workspaces.set(worktreePath, state);
+ }
+
+ /**
+ * Unregister a workspace completely (e.g., workspace deleted).
+ * Stops timers and removes from the registry.
+ */
+ unregisterWorkspace(worktreePath: string): void {
+ const state = this.workspaces.get(worktreePath);
+ if (!state) return;
+
+ this.stopTimers(state);
+ state.isActive = false;
+ this.workspaces.delete(worktreePath);
+ }
+
+ /**
+ * Activate a workspace, starting its polling timers and triggering
+ * an immediate sync. If not yet registered, registers it first.
+ */
+ activateWorkspace(worktreePath: string): void {
+ let state = this.workspaces.get(worktreePath);
+
+ if (!state) {
+ this.registerWorkspace(worktreePath);
+ const registeredState = this.workspaces.get(worktreePath);
+ if (!registeredState) {
+ return;
+ }
+ state = registeredState;
+ }
+
+ if (state.isActive) return;
+
+ state.isActive = true;
+ this.stopTimers(state);
+ void this.primeWorkspace(worktreePath);
+ }
+
+ /**
+ * Deactivate a workspace, pausing its polling timers.
+ * The workspace remains in the registry and can be reactivated.
+ */
+ deactivateWorkspace(worktreePath: string): void {
+ const state = this.workspaces.get(worktreePath);
+ if (!state || !state.isActive) return;
+
+ state.isActive = false;
+ this.stopTimers(state);
+ }
+
+ /**
+ * Deactivate all workspaces except the given one.
+ * Activates the given workspace if not already active.
+ * Pass null to deactivate all workspaces (e.g., navigating away from workspaces).
+ */
+ setActiveWorkspace(worktreePath: string | null): void {
+ for (const state of this.workspaces.values()) {
+ if (worktreePath && state.worktreePath === worktreePath) {
+ if (!state.isActive) {
+ state.isActive = true;
+ this.stopTimers(state);
+ void this.primeWorkspace(state.worktreePath);
+ }
+ } else if (state.isActive) {
+ state.isActive = false;
+ this.stopTimers(state);
+ }
+ }
+
+ // Register and activate if not yet known
+ if (worktreePath && !this.workspaces.has(worktreePath)) {
+ this.registerWorkspace(worktreePath);
+ this.activateWorkspace(worktreePath);
+ }
+ }
+
+ /**
+ * Deactivate all workspaces. Used when navigating away from workspace views.
+ */
+ deactivateAll(): void {
+ for (const state of this.workspaces.values()) {
+ if (state.isActive) {
+ state.isActive = false;
+ this.stopTimers(state);
+ }
+ }
+ }
+
+ /**
+ * Trigger an immediate refresh for a workspace.
+ * Used after user mutations (merge, reviewer add, etc.)
+ * to provide instant feedback.
+ */
+ async invalidate(
+ worktreePath: string,
+ scope: "all" | "prStatus" | "prComments" = "all",
+ ): Promise {
+ if (!this.deps) return;
+
+ if (scope === "all" || scope === "prStatus") {
+ await this.syncPRStatus(worktreePath);
+ }
+ if (scope === "all" || scope === "prComments") {
+ await this.syncPRComments(worktreePath);
+ }
+ }
+
+ /**
+ * Clean up all timers (e.g., on app quit).
+ */
+ destroy(): void {
+ for (const state of this.workspaces.values()) {
+ this.stopTimers(state);
+ state.isActive = false;
+ }
+ this.workspaces.clear();
+ }
+
+ isRegistered(worktreePath: string): boolean {
+ return this.workspaces.has(worktreePath);
+ }
+
+ getDebugSnapshot(): GitHubSyncServiceDebugSnapshot {
+ const workspaces = [...this.workspaces.values()].map((state) => ({
+ worktreePath: state.worktreePath,
+ isActive: state.isActive,
+ prStatusInFlight: state.prStatusInFlight,
+ prCommentsInFlight: state.prCommentsInFlight,
+ nextPRStatusSyncAt: state.nextPRStatusSyncAt,
+ nextPRCommentsSyncAt: state.nextPRCommentsSyncAt,
+ prStatusIntervalMs: this.getPRStatusInterval(state),
+ prCommentsIntervalMs: getPullRequestCommentsTargetFromStatus(
+ state.latestStatus,
+ )
+ ? SYNC_PR_COMMENTS_INTERVAL_MS
+ : null,
+ lastPRStatusSuccessAt: state.lastPRStatusSuccessAt,
+ lastPRStatusErrorAt: state.lastPRStatusErrorAt,
+ lastPRStatusErrorMessage: state.lastPRStatusErrorMessage,
+ lastPRCommentsSuccessAt: state.lastPRCommentsSuccessAt,
+ lastPRCommentsErrorAt: state.lastPRCommentsErrorAt,
+ lastPRCommentsErrorMessage: state.lastPRCommentsErrorMessage,
+ latestStatus: {
+ hasPr: Boolean(state.latestStatus?.pr),
+ prNumber: state.latestStatus?.pr?.number ?? null,
+ checksStatus: state.latestStatus?.pr?.checksStatus ?? null,
+ repoUrl: state.latestStatus?.repoUrl ?? null,
+ branchExistsOnRemote: state.latestStatus?.branchExistsOnRemote ?? false,
+ lastRefreshed: state.latestStatus?.lastRefreshed ?? null,
+ },
+ }));
+
+ return {
+ registeredWorkspaceCount: workspaces.length,
+ activeWorkspaceCount: workspaces.filter((workspace) => workspace.isActive)
+ .length,
+ activeWorktreePaths: workspaces
+ .filter((workspace) => workspace.isActive)
+ .map((workspace) => workspace.worktreePath),
+ workspaces,
+ };
+ }
+
+ private async primeWorkspace(worktreePath: string): Promise {
+ await this.syncPRStatus(worktreePath);
+ }
+
+ private stopTimers(state: WorkspaceSyncState): void {
+ if (state.prStatusTimer) {
+ clearTimeout(state.prStatusTimer);
+ state.prStatusTimer = null;
+ }
+ state.nextPRStatusSyncAt = null;
+ if (state.prCommentsTimer) {
+ clearTimeout(state.prCommentsTimer);
+ state.prCommentsTimer = null;
+ }
+ state.nextPRCommentsSyncAt = null;
+ }
+
+ private getPRStatusInterval(state: WorkspaceSyncState): number {
+ return state.latestStatus?.pr?.checksStatus === "pending"
+ ? SYNC_PR_STATUS_PENDING_INTERVAL_MS
+ : SYNC_PR_STATUS_INTERVAL_MS;
+ }
+
+ private scheduleNextPRStatusSync(state: WorkspaceSyncState): void {
+ if (!state.isActive) {
+ return;
+ }
+
+ if (state.prStatusTimer) {
+ clearTimeout(state.prStatusTimer);
+ }
+
+ const intervalMs = this.getPRStatusInterval(state);
+ state.nextPRStatusSyncAt = Date.now() + intervalMs;
+ state.prStatusTimer = setTimeout(() => {
+ void this.syncPRStatus(state.worktreePath);
+ }, intervalMs);
+ }
+
+ private scheduleNextPRCommentsSync(state: WorkspaceSyncState): void {
+ if (state.prCommentsTimer) {
+ clearTimeout(state.prCommentsTimer);
+ state.prCommentsTimer = null;
+ }
+ state.nextPRCommentsSyncAt = null;
+
+ if (
+ !state.isActive ||
+ !getPullRequestCommentsTargetFromStatus(state.latestStatus)
+ ) {
+ return;
+ }
+
+ state.nextPRCommentsSyncAt = Date.now() + SYNC_PR_COMMENTS_INTERVAL_MS;
+ state.prCommentsTimer = setTimeout(() => {
+ void this.syncPRComments(state.worktreePath);
+ }, SYNC_PR_COMMENTS_INTERVAL_MS);
+ }
+
+ private async syncPRStatus(worktreePath: string): Promise {
+ const state = this.workspaces.get(worktreePath);
+ if (!this.deps || !state) return;
+ if (state.prStatusTimer) {
+ clearTimeout(state.prStatusTimer);
+ state.prStatusTimer = null;
+ }
+ state.nextPRStatusSyncAt = null;
+ if (isRateLimited() || state.prStatusInFlight) {
+ if (state.isActive && !state.prStatusInFlight) {
+ this.scheduleNextPRStatusSync(state);
+ }
+ return;
+ }
+ state.prStatusInFlight = true;
+
+ const previousCommentsTargetKey = getPullRequestCommentsTargetKey(
+ state.latestStatus,
+ );
+
+ try {
+ const status = await this.deps.fetchPRStatus(worktreePath);
+ if (!this.workspaces.has(worktreePath)) return;
+ state.latestStatus = status;
+ state.lastPRStatusSuccessAt = Date.now();
+ state.lastPRStatusErrorAt = null;
+ state.lastPRStatusErrorMessage = null;
+ this.deps.onPRStatusUpdate?.(worktreePath, status);
+
+ const nextCommentsTargetKey = getPullRequestCommentsTargetKey(status);
+ if (
+ previousCommentsTargetKey !== nextCommentsTargetKey &&
+ !state.prCommentsInFlight
+ ) {
+ this.scheduleNextPRCommentsSync(state);
+ }
+ } catch (error) {
+ console.warn("[GitHub SyncService] PR status sync failed:", error);
+ state.lastPRStatusErrorAt = Date.now();
+ state.lastPRStatusErrorMessage =
+ error instanceof Error ? error.message : String(error);
+ this.scheduleNextPRCommentsSync(state);
+ } finally {
+ const current = this.workspaces.get(worktreePath);
+ if (current) {
+ current.prStatusInFlight = false;
+ this.scheduleNextPRStatusSync(current);
+ }
+ }
+ }
+
+ private async syncPRComments(worktreePath: string): Promise {
+ const state = this.workspaces.get(worktreePath);
+ if (!this.deps || !state) return;
+ if (state.prCommentsTimer) {
+ clearTimeout(state.prCommentsTimer);
+ state.prCommentsTimer = null;
+ }
+ state.nextPRCommentsSyncAt = null;
+ if (isRateLimited() || state.prCommentsInFlight) {
+ if (state.isActive && !state.prCommentsInFlight) {
+ this.scheduleNextPRCommentsSync(state);
+ }
+ return;
+ }
+
+ const pullRequest = getPullRequestCommentsTargetFromStatus(
+ state.latestStatus,
+ );
+ if (!pullRequest) {
+ return;
+ }
+
+ state.prCommentsInFlight = true;
+
+ try {
+ await this.deps.fetchPRComments({ worktreePath, pullRequest });
+ if (!this.workspaces.has(worktreePath)) return;
+ state.lastPRCommentsSuccessAt = Date.now();
+ state.lastPRCommentsErrorAt = null;
+ state.lastPRCommentsErrorMessage = null;
+ } catch (error) {
+ console.warn("[GitHub SyncService] PR comments sync failed:", error);
+ state.lastPRCommentsErrorAt = Date.now();
+ state.lastPRCommentsErrorMessage =
+ error instanceof Error ? error.message : String(error);
+ } finally {
+ const current = this.workspaces.get(worktreePath);
+ if (current) {
+ current.prCommentsInFlight = false;
+ this.scheduleNextPRCommentsSync(current);
+ }
+ }
+ }
+}
+
+function getPullRequestCommentsTargetFromStatus(
+ status: GitHubStatus | null,
+): PullRequestCommentsTarget | null {
+ if (!status?.pr) {
+ return null;
+ }
+
+ return {
+ prNumber: status.pr.number,
+ repoContext: {
+ repoUrl: status.repoUrl,
+ upstreamUrl: status.upstreamUrl ?? status.repoUrl,
+ isFork: status.isFork ?? false,
+ },
+ prUrl: status.pr.url,
+ };
+}
+
+function getPullRequestCommentsTargetKey(
+ status: GitHubStatus | null,
+): string | null {
+ const target = getPullRequestCommentsTargetFromStatus(status);
+ if (!target) {
+ return null;
+ }
+
+ return `${target.repoContext.repoUrl}::${target.repoContext.upstreamUrl}::${target.prNumber}::${target.prUrl ?? ""}`;
+}
+
+export const githubSyncService = new GitHubSyncServiceImpl();
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts
index a8794a67daa..9fa17c01459 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts
@@ -6,6 +6,10 @@ import {
parseReviewThreadCommentsResponse,
} from "./comments";
import { resolveRemoteBranchNameForGitHubStatus } from "./github";
+import {
+ canAttachPullRequestToWorkspace,
+ resolveOpenPullRequestPushTarget,
+} from "./pr-attachment";
import {
branchMatchesPR,
getPRHeadBranchCandidates,
@@ -74,14 +78,103 @@ describe("getPullRequestRepoArgs", () => {
});
});
+describe("pull request attachment", () => {
+ test("attaches same-repo open PRs using the fallback remote", () => {
+ expect(
+ resolveOpenPullRequestPushTarget({
+ pr: {
+ headRefName: "feature/my-thing",
+ isCrossRepository: false,
+ state: "open",
+ },
+ remotes: [
+ {
+ name: "origin",
+ fetchUrl: "git@github.com:superset-sh/superset.git",
+ },
+ ],
+ fallbackRemote: "origin",
+ }),
+ ).toEqual({
+ remote: "origin",
+ targetBranch: "feature/my-thing",
+ });
+ });
+
+ test("does not attach cross-repo open PRs when the fork remote is missing", () => {
+ expect(
+ canAttachPullRequestToWorkspace({
+ pr: {
+ headRefName: "feature/my-thing",
+ headRepositoryOwner: "forkowner",
+ headRepositoryName: "superset",
+ isCrossRepository: true,
+ state: "open",
+ },
+ remotes: [
+ {
+ name: "origin",
+ fetchUrl: "git@github.com:superset-sh/superset.git",
+ },
+ ],
+ fallbackRemote: "origin",
+ }),
+ ).toBe(false);
+ });
+
+ test("attaches cross-repo open PRs when the fork remote exists", () => {
+ expect(
+ resolveOpenPullRequestPushTarget({
+ pr: {
+ headRefName: "feature/my-thing",
+ headRepositoryOwner: "forkowner",
+ headRepositoryName: "superset",
+ isCrossRepository: true,
+ state: "draft",
+ },
+ remotes: [
+ {
+ name: "origin",
+ fetchUrl: "git@github.com:superset-sh/superset.git",
+ },
+ {
+ name: "forkowner",
+ fetchUrl: "git@github.com:forkowner/superset.git",
+ },
+ ],
+ fallbackRemote: "origin",
+ }),
+ ).toEqual({
+ remote: "forkowner",
+ targetBranch: "feature/my-thing",
+ });
+ });
+
+ test("keeps historical PRs attached even without a fork remote", () => {
+ expect(
+ canAttachPullRequestToWorkspace({
+ pr: {
+ headRefName: "feature/my-thing",
+ headRepositoryOwner: "forkowner",
+ headRepositoryName: "superset",
+ isCrossRepository: true,
+ state: "merged",
+ },
+ remotes: [],
+ fallbackRemote: "origin",
+ }),
+ ).toBe(true);
+ });
+});
+
describe("shouldRefreshCachedRepoContext", () => {
- test("returns false when no cached repo context exists", () => {
+ test("returns true when no cached repo context exists", () => {
expect(
shouldRefreshCachedRepoContext({
originUrl: "https://github.com/superset-sh/superset",
cachedRepoContext: null,
}),
- ).toBe(false);
+ ).toBe(true);
});
test("returns false when the cached repo still matches origin", () => {
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts
index 66ff14f3cf1..ba55a18d290 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts
@@ -4,35 +4,73 @@ import {
getCurrentBranch,
isUnbornHeadError,
} from "../git";
-import { execGitWithShellPath } from "../git-client";
+import { execGitWithShellPath, getSimpleGitWithShellPath } from "../git-client";
import { execWithShellEnv } from "../shell-env";
import { parseUpstreamRef } from "../upstream-ref";
import {
clearGitHubCachesForWorktree,
+ getCachedGitHubPreviewUrl,
+ getCachedGitHubStatus,
+ getCachedGitHubStatusState,
getCachedPullRequestCommentsState,
+ makeGitHubPreviewCacheKey,
makePullRequestCommentsCacheKey,
+ readCachedGitHubPreviewUrl,
readCachedGitHubStatus,
readCachedPullRequestComments,
} from "./cache";
-import { fetchPullRequestComments, resolveReviewThread } from "./comments";
+import {
+ addPullRequestConversationComment,
+ fetchPullRequestComments,
+ replyToReviewThread,
+ resolveReviewThread,
+} from "./comments";
+import {
+ trackGitHubOperation,
+ trackGitHubOperationEvent,
+} from "./github-metrics";
+import {
+ isRateLimited,
+ isSecondaryRateLimitError,
+ onRateLimitHit,
+ onRateLimitSuccess,
+} from "./github-rate-limiter";
+import {
+ canAttachPullRequestToWorkspace,
+ type GitRemoteInfo,
+} from "./pr-attachment";
import { getPRForBranch } from "./pr-resolution";
import { extractNwoFromUrl, getRepoContext } from "./repo-context";
import {
GHDeploymentSchema,
GHDeploymentStatusSchema,
+ GHJobResponseSchema,
type RepoContext,
} from "./types";
export interface PullRequestCommentsTarget {
prNumber: number;
repoContext: Pick;
+ prUrl?: string | null;
}
-export { clearGitHubCachesForWorktree, resolveReviewThread };
+export {
+ addPullRequestConversationComment,
+ clearGitHubCachesForWorktree,
+ replyToReviewThread,
+ resolveReviewThread,
+};
function getPullRequestCommentsRepoNameWithOwner(
target: PullRequestCommentsTarget,
): string | null {
+ const prRepoNameWithOwner = target.prUrl
+ ? extractNwoFromUrl(target.prUrl)
+ : null;
+ if (prRepoNameWithOwner) {
+ return prRepoNameWithOwner;
+ }
+
const targetUrl = target.repoContext.isFork
? target.repoContext.upstreamUrl
: target.repoContext.repoUrl;
@@ -40,30 +78,34 @@ function getPullRequestCommentsRepoNameWithOwner(
return extractNwoFromUrl(targetUrl);
}
-async function resolvePullRequestCommentsTarget(
+async function getGitRemoteInfos(
worktreePath: string,
-): Promise {
- const repoContext = await getRepoContext(worktreePath);
- if (!repoContext) {
- return null;
- }
+): Promise {
+ const git = await getSimpleGitWithShellPath(worktreePath);
+ const remotes = await git.getRemotes(true);
+ return remotes.map((remote) => ({
+ name: remote.name,
+ fetchUrl: remote.refs.fetch,
+ pushUrl: remote.refs.push,
+ }));
+}
- const branchName = await getCurrentBranch(worktreePath);
- if (!branchName) {
- return null;
- }
- const shaResult = await execGitWithShellPath(["rev-parse", "HEAD"], {
- cwd: worktreePath,
- }).catch((error) => {
- if (isUnbornHeadError(error)) {
- return { stdout: "", stderr: "" };
- }
- throw error;
- });
- const headSha = shaResult.stdout.trim() || undefined;
+async function resolveAttachedPullRequest({
+ worktreePath,
+ localBranch,
+ repoContext,
+ headSha,
+ fallbackRemote,
+}: {
+ worktreePath: string;
+ localBranch: string;
+ repoContext: RepoContext;
+ headSha?: string;
+ fallbackRemote: string;
+}): Promise {
const prInfo = await getPRForBranch(
worktreePath,
- branchName,
+ localBranch,
repoContext,
headSha,
);
@@ -71,9 +113,32 @@ async function resolvePullRequestCommentsTarget(
return null;
}
+ const remotes = await getGitRemoteInfos(worktreePath);
+ return canAttachPullRequestToWorkspace({
+ pr: prInfo,
+ remotes,
+ fallbackRemote,
+ })
+ ? prInfo
+ : null;
+}
+
+async function resolvePullRequestCommentsTarget(
+ worktreePath: string,
+): Promise {
+ const githubStatus = await fetchGitHubPRStatus(worktreePath);
+ if (!githubStatus?.pr) {
+ return null;
+ }
+
return {
- prNumber: prInfo.number,
- repoContext,
+ prNumber: githubStatus.pr.number,
+ repoContext: {
+ repoUrl: githubStatus.repoUrl,
+ upstreamUrl: githubStatus.upstreamUrl ?? githubStatus.repoUrl,
+ isFork: githubStatus.isFork ?? false,
+ },
+ prUrl: githubStatus.pr.url,
};
}
@@ -89,89 +154,95 @@ export function resolveRemoteBranchNameForGitHubStatus({
return upstreamBranchName?.trim() || prHeadRefName?.trim() || localBranchName;
}
+interface ResolvedGitHubStatusContext {
+ repoContext: RepoContext;
+ branchName: string;
+ headSha?: string;
+ trackingRemote: string;
+ previewBranchName: string;
+ parsedUpstreamBranchName?: string | null;
+}
+
+async function resolveGitHubStatusContext(
+ worktreePath: string,
+): Promise {
+ const repoContext = await getRepoContext(worktreePath);
+ if (!repoContext) {
+ return null;
+ }
+
+ const branchName = await getCurrentBranch(worktreePath);
+ if (!branchName) {
+ return null;
+ }
+
+ const [shaResult, upstreamResult] = await Promise.all([
+ execGitWithShellPath(["rev-parse", "HEAD"], {
+ cwd: worktreePath,
+ }).catch((error) => {
+ if (isUnbornHeadError(error)) {
+ return { stdout: "", stderr: "" };
+ }
+ throw error;
+ }),
+ execGitWithShellPath(["rev-parse", "--abbrev-ref", "@{upstream}"], {
+ cwd: worktreePath,
+ }).catch(() => ({ stdout: "", stderr: "" })),
+ ]);
+
+ const headSha = shaResult.stdout.trim() || undefined;
+ const parsedUpstreamRef = parseUpstreamRef(upstreamResult.stdout.trim());
+
+ return {
+ repoContext,
+ branchName,
+ headSha,
+ trackingRemote: parsedUpstreamRef?.remoteName ?? "origin",
+ previewBranchName: resolveRemoteBranchNameForGitHubStatus({
+ localBranchName: branchName,
+ upstreamBranchName: parsedUpstreamRef?.branchName,
+ }),
+ parsedUpstreamBranchName: parsedUpstreamRef?.branchName,
+ };
+}
+
async function refreshGitHubPRStatus(
worktreePath: string,
): Promise {
try {
- const repoContext = await getRepoContext(worktreePath);
- if (!repoContext) {
- return null;
- }
-
- const branchName = await getCurrentBranch(worktreePath);
- if (!branchName) {
+ const context = await resolveGitHubStatusContext(worktreePath);
+ if (!context) {
return null;
}
- const [shaResult, upstreamResult] = await Promise.all([
- execGitWithShellPath(["rev-parse", "HEAD"], {
- cwd: worktreePath,
- }).catch((error) => {
- if (isUnbornHeadError(error)) {
- return { stdout: "", stderr: "" };
- }
- throw error;
- }),
- execGitWithShellPath(["rev-parse", "--abbrev-ref", "@{upstream}"], {
- cwd: worktreePath,
- }).catch(() => ({ stdout: "", stderr: "" })),
- ]);
- const headSha = shaResult.stdout.trim() || undefined;
- const parsedUpstreamRef = parseUpstreamRef(upstreamResult.stdout.trim());
- const trackingRemote = parsedUpstreamRef?.remoteName ?? "origin";
- const previewBranchName = resolveRemoteBranchNameForGitHubStatus({
- localBranchName: branchName,
- upstreamBranchName: parsedUpstreamRef?.branchName,
+ const prInfo = await resolveAttachedPullRequest({
+ worktreePath,
+ localBranch: context.branchName,
+ repoContext: context.repoContext,
+ headSha: context.headSha,
+ fallbackRemote: context.trackingRemote,
});
- const [prInfo, previewUrl] = await Promise.all([
- getPRForBranch(worktreePath, branchName, repoContext, headSha),
- fetchPreviewDeploymentUrl(
- worktreePath,
- headSha,
- previewBranchName,
- repoContext,
- ),
- ]);
-
const remoteBranchName = resolveRemoteBranchNameForGitHubStatus({
- localBranchName: branchName,
- upstreamBranchName: parsedUpstreamRef?.branchName,
+ localBranchName: context.branchName,
+ upstreamBranchName: context.parsedUpstreamBranchName,
prHeadRefName: prInfo?.headRefName,
});
const branchCheck = await branchExistsOnRemote(
worktreePath,
remoteBranchName,
- trackingRemote,
+ context.trackingRemote,
);
- let finalPreviewUrl = previewUrl;
- if (!finalPreviewUrl && prInfo?.number) {
- const targetUrl = repoContext.isFork
- ? repoContext.upstreamUrl
- : repoContext.repoUrl;
- const nwo = extractNwoFromUrl(targetUrl);
- if (nwo) {
- finalPreviewUrl = await queryDeploymentUrl(
- worktreePath,
- nwo,
- `ref=${encodeURIComponent(`refs/pull/${prInfo.number}/merge`)}`,
- );
- }
- }
-
- const result: GitHubStatus = {
+ return {
pr: prInfo,
- repoUrl: repoContext.repoUrl,
- upstreamUrl: repoContext.upstreamUrl,
- isFork: repoContext.isFork,
+ repoUrl: context.repoContext.repoUrl,
+ upstreamUrl: context.repoContext.upstreamUrl,
+ isFork: context.repoContext.isFork,
branchExistsOnRemote: branchCheck.status === "exists",
- previewUrl: finalPreviewUrl,
lastRefreshed: Date.now(),
};
-
- return result;
} catch {
return null;
}
@@ -200,9 +271,47 @@ async function refreshGitHubPRComments({
export async function fetchGitHubPRStatus(
worktreePath: string,
): Promise {
- return readCachedGitHubStatus(worktreePath, () =>
- refreshGitHubPRStatus(worktreePath),
- );
+ if (isRateLimited()) {
+ // When rate limited, return stale cache or null — never throw,
+ // and never overwrite stale cache with null
+ const cached = getCachedGitHubStatus(worktreePath);
+ trackGitHubOperationEvent({
+ name: "status_refresh",
+ category: "sync",
+ worktreePath,
+ success:
+ cached !== null || getCachedGitHubStatusState(worktreePath) !== null,
+ durationMs: 0,
+ rateLimited: true,
+ error:
+ cached === null && getCachedGitHubStatusState(worktreePath) === null
+ ? "Rate limited without cached status"
+ : undefined,
+ });
+ return cached;
+ }
+ return trackGitHubOperation({
+ name: "status_refresh",
+ category: "sync",
+ worktreePath,
+ fn: () =>
+ readCachedGitHubStatus(worktreePath, () =>
+ rateLimitedRefresh(() => refreshGitHubPRStatus(worktreePath)),
+ ),
+ });
+}
+
+async function rateLimitedRefresh(fn: () => Promise): Promise {
+ try {
+ const result = await fn();
+ onRateLimitSuccess();
+ return result;
+ } catch (error) {
+ if (isSecondaryRateLimitError(error)) {
+ onRateLimitHit();
+ }
+ throw error;
+ }
}
export async function fetchGitHubPRComments({
@@ -212,49 +321,137 @@ export async function fetchGitHubPRComments({
worktreePath: string;
pullRequest?: PullRequestCommentsTarget | null;
}): Promise {
+ if (isRateLimited()) {
+ trackGitHubOperationEvent({
+ name: "comments_refresh",
+ category: "sync",
+ worktreePath,
+ success: true,
+ durationMs: 0,
+ rateLimited: true,
+ });
+ return [];
+ }
try {
- const pullRequestTarget =
- pullRequest ?? (await resolvePullRequestCommentsTarget(worktreePath));
- if (!pullRequestTarget) {
- return [];
- }
+ return await trackGitHubOperation({
+ name: "comments_refresh",
+ category: "sync",
+ worktreePath,
+ fn: async () => {
+ const pullRequestTarget =
+ pullRequest ?? (await resolvePullRequestCommentsTarget(worktreePath));
+ if (!pullRequestTarget) {
+ return [];
+ }
- const repoNameWithOwner =
- getPullRequestCommentsRepoNameWithOwner(pullRequestTarget);
- if (!repoNameWithOwner) {
- return [];
- }
+ const repoNameWithOwner =
+ getPullRequestCommentsRepoNameWithOwner(pullRequestTarget);
+ if (!repoNameWithOwner) {
+ return [];
+ }
- const cacheKey = makePullRequestCommentsCacheKey({
- worktreePath,
- repoNameWithOwner,
- pullRequestNumber: pullRequestTarget.prNumber,
- });
- try {
- return await readCachedPullRequestComments(cacheKey, () =>
- refreshGitHubPRComments({
+ const cacheKey = makePullRequestCommentsCacheKey({
worktreePath,
repoNameWithOwner,
pullRequestNumber: pullRequestTarget.prNumber,
- }),
- );
- } catch (error) {
- const cached = getCachedPullRequestCommentsState(cacheKey);
- if (cached) {
- console.warn(
- "[GitHub] Failed to refresh pull request comments; using cached value:",
- error,
- );
- return cached.value;
- }
-
- throw error;
- }
+ });
+ try {
+ return await readCachedPullRequestComments(cacheKey, () =>
+ rateLimitedRefresh(() =>
+ refreshGitHubPRComments({
+ worktreePath,
+ repoNameWithOwner,
+ pullRequestNumber: pullRequestTarget.prNumber,
+ }),
+ ),
+ );
+ } catch (error) {
+ const cached = getCachedPullRequestCommentsState(cacheKey);
+ if (cached) {
+ console.warn(
+ "[GitHub] Failed to refresh pull request comments; using cached value:",
+ error,
+ );
+ return cached.value;
+ }
+
+ throw error;
+ }
+ },
+ });
} catch {
return [];
}
}
+export async function fetchGitHubPreviewUrl({
+ worktreePath,
+ githubStatus,
+ forceFresh = false,
+}: {
+ worktreePath: string;
+ githubStatus?: GitHubStatus | null;
+ forceFresh?: boolean;
+}): Promise {
+ const context = await resolveGitHubStatusContext(worktreePath);
+ if (!context) {
+ return null;
+ }
+
+ const targetUrl = context.repoContext.isFork
+ ? context.repoContext.upstreamUrl
+ : context.repoContext.repoUrl;
+ const repoNameWithOwner = extractNwoFromUrl(targetUrl);
+ if (!repoNameWithOwner) {
+ return null;
+ }
+
+ const cacheKey = makeGitHubPreviewCacheKey({
+ worktreePath,
+ repoNameWithOwner,
+ branchName: context.previewBranchName,
+ headSha: context.headSha,
+ pullRequestNumber: githubStatus?.pr?.number,
+ });
+
+ if (isRateLimited()) {
+ const cached = getCachedGitHubPreviewUrl(cacheKey);
+ trackGitHubOperationEvent({
+ name: "preview_refresh",
+ category: "sync",
+ worktreePath,
+ success: true,
+ durationMs: 0,
+ rateLimited: true,
+ });
+ return cached;
+ }
+
+ return trackGitHubOperation({
+ name: "preview_refresh",
+ category: "sync",
+ worktreePath,
+ fn: async () => {
+ return readCachedGitHubPreviewUrl(
+ cacheKey,
+ () =>
+ rateLimitedRefresh(() =>
+ refreshGitHubPreviewUrl({
+ worktreePath,
+ repoNameWithOwner,
+ branchName: context.previewBranchName,
+ headSha: context.headSha,
+ pullRequestNumber: githubStatus?.pr?.number,
+ }),
+ ),
+ {
+ forceFresh,
+ },
+ );
+ },
+ });
+}
+
function isSafeHttpUrl(url: string): boolean {
try {
const parsed = new URL(url);
@@ -274,11 +471,17 @@ async function queryDeploymentUrl(
nwo: string,
queryParams: string,
): Promise {
- const { stdout } = await execWithShellEnv(
- "gh",
- ["api", `repos/${nwo}/deployments?${queryParams}&per_page=5`],
- { cwd: worktreePath },
- );
+ const { stdout } = await trackGitHubOperation({
+ name: "gh_api_deployments",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv(
+ "gh",
+ ["api", `repos/${nwo}/deployments?${queryParams}&per_page=5`],
+ { cwd: worktreePath },
+ ),
+ });
const rawDeployments: unknown = JSON.parse(stdout.trim());
if (!Array.isArray(rawDeployments) || rawDeployments.length === 0) {
@@ -299,11 +502,17 @@ async function queryDeploymentUrl(
const urls = await Promise.all(
deploymentIds.map(async (id): Promise => {
try {
- const { stdout: out } = await execWithShellEnv(
- "gh",
- ["api", `repos/${nwo}/deployments/${id}/statuses?per_page=1`],
- { cwd: worktreePath },
- );
+ const { stdout: out } = await trackGitHubOperation({
+ name: "gh_api_deployment_status",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv(
+ "gh",
+ ["api", `repos/${nwo}/deployments/${id}/statuses?per_page=1`],
+ { cwd: worktreePath },
+ ),
+ });
const rawStatuses: unknown = JSON.parse(out.trim());
if (!Array.isArray(rawStatuses) || rawStatuses.length === 0) {
return undefined;
@@ -334,29 +543,26 @@ async function queryDeploymentUrl(
* Fetches the preview deployment URL by trying multiple query strategies:
* 1. By commit SHA (works for Vercel, Netlify official integrations)
* 2. By branch name ref (works for some CI configurations)
- * The PR merge ref (refs/pull/N/merge) is handled in fetchGitHubPRStatus
- * after the PR number is known.
+ * 3. By PR merge ref when the PR number is already known
*/
-async function fetchPreviewDeploymentUrl(
- worktreePath: string,
- headSha: string | undefined,
- branchName: string,
- repoContext: RepoContext,
-): Promise {
+async function refreshGitHubPreviewUrl({
+ worktreePath,
+ repoNameWithOwner,
+ headSha,
+ branchName,
+ pullRequestNumber,
+}: {
+ worktreePath: string;
+ repoNameWithOwner: string;
+ headSha?: string;
+ branchName: string;
+ pullRequestNumber?: number;
+}): Promise {
try {
- const targetUrl = repoContext.isFork
- ? repoContext.upstreamUrl
- : repoContext.repoUrl;
- const nwo = extractNwoFromUrl(targetUrl);
- if (!nwo) {
- return undefined;
- }
-
if (headSha) {
- // Try by commit SHA (works for Vercel, Netlify official integrations)
const bySha = await queryDeploymentUrl(
worktreePath,
- nwo,
+ repoNameWithOwner,
`sha=${headSha}`,
);
if (bySha) {
@@ -364,13 +570,297 @@ async function fetchPreviewDeploymentUrl(
}
}
- // Fall back to branch name (works for some CI configurations)
- return await queryDeploymentUrl(
+ const byBranch = await queryDeploymentUrl(
worktreePath,
- nwo,
+ repoNameWithOwner,
`ref=${encodeURIComponent(branchName)}`,
);
+ if (byBranch) {
+ return byBranch;
+ }
+
+ if (!pullRequestNumber) {
+ return null;
+ }
+
+ return (
+ (await queryDeploymentUrl(
+ worktreePath,
+ repoNameWithOwner,
+ `ref=${encodeURIComponent(`refs/pull/${pullRequestNumber}/merge`)}`,
+ )) ?? null
+ );
} catch {
- return undefined;
+ return null;
}
}
+
+export interface JobStepInfo {
+ name: string;
+ status: "queued" | "in_progress" | "completed";
+ conclusion: string | null;
+ number: number;
+}
+
+/**
+ * Extracts job ID from a GitHub Actions details URL.
+ * URL format: https://github.com/{owner}/{repo}/actions/runs/{run_id}/job/{job_id}
+ */
+function parseJobIdFromUrl(detailsUrl: string): string | null {
+ try {
+ const url = new URL(detailsUrl);
+ const match = url.pathname.match(/\/actions\/runs\/\d+\/job\/(\d+)/);
+ return match?.[1] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Extracts nwo (owner/repo) from a GitHub Actions details URL.
+ */
+function parseNwoFromActionsUrl(detailsUrl: string): string | null {
+ try {
+ const url = new URL(detailsUrl);
+ const match = url.pathname.match(/^\/([^/]+\/[^/]+)\/actions\//);
+ return match?.[1] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Fetches job steps for a given GitHub Actions check using its details URL.
+ */
+export async function fetchCheckJobSteps(
+ worktreePath: string,
+ detailsUrl: string,
+): Promise {
+ const jobId = parseJobIdFromUrl(detailsUrl);
+ const nwo = parseNwoFromActionsUrl(detailsUrl);
+ if (!jobId || !nwo) {
+ return [];
+ }
+
+ try {
+ const { stdout } = await trackGitHubOperation({
+ name: "gh_api_actions_job",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv("gh", ["api", `repos/${nwo}/actions/jobs/${jobId}`], {
+ cwd: worktreePath,
+ }),
+ });
+
+ const raw: unknown = JSON.parse(stdout.trim());
+ const result = GHJobResponseSchema.safeParse(raw);
+ if (!result.success) {
+ return [];
+ }
+
+ return (result.data.steps ?? []).map((step) => ({
+ name: step.name,
+ status: step.status,
+ conclusion: step.conclusion ?? null,
+ number: step.number,
+ }));
+ } catch {
+ return [];
+ }
+}
+
+export interface StructuredJobStep {
+ name: string;
+ number: number;
+ status: "queued" | "in_progress" | "completed";
+ conclusion: string | null;
+ durationSeconds: number | null;
+ logs: string;
+}
+
+export interface StructuredJobResult {
+ jobStatus: "queued" | "in_progress" | "completed" | "waiting";
+ jobConclusion: string | null;
+ steps: StructuredJobStep[];
+}
+
+/**
+ * Fetches job step metadata and logs, returning structured per-step data.
+ */
+export async function fetchStructuredJobLogs(
+ worktreePath: string,
+ detailsUrl: string,
+): Promise {
+ const jobId = parseJobIdFromUrl(detailsUrl);
+ const nwo = parseNwoFromActionsUrl(detailsUrl);
+ const emptyResult: StructuredJobResult = {
+ jobStatus: "queued",
+ jobConclusion: null,
+ steps: [],
+ };
+ if (!jobId || !nwo) {
+ return emptyResult;
+ }
+
+ try {
+ // Always fetch job metadata; logs may 404 for in-progress jobs
+ const jobResult = await trackGitHubOperation({
+ name: "gh_api_actions_job",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv("gh", ["api", `repos/${nwo}/actions/jobs/${jobId}`], {
+ cwd: worktreePath,
+ }),
+ });
+
+ const raw: unknown = JSON.parse(jobResult.stdout.trim());
+ const result = GHJobResponseSchema.safeParse(raw);
+ if (!result.success || !result.data.steps) {
+ return emptyResult;
+ }
+
+ const jobData = result.data;
+ const steps = jobData.steps ?? [];
+ const jobCompleted = jobData.status === "completed";
+
+ // Only fetch logs if job is completed (API returns 404 for in-progress)
+ let rawLogs = "";
+ if (jobCompleted) {
+ try {
+ const logsResult = await trackGitHubOperation({
+ name: "gh_api_actions_job_logs",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv(
+ "gh",
+ ["api", `repos/${nwo}/actions/jobs/${jobId}/logs`],
+ { cwd: worktreePath, maxBuffer: 10 * 1024 * 1024 },
+ ),
+ });
+ rawLogs = logsResult.stdout;
+ } catch {
+ // Logs not yet available
+ }
+ }
+
+ // Parse raw logs into per-step sections.
+ // GitHub log format: each line starts with a timestamp like "2024-01-01T00:00:00.0000000Z "
+ // Steps are separated by ##[group] / ##[endgroup] markers, but these aren't always reliable.
+ // Instead, match by step started_at/completed_at time ranges.
+ const logLines = rawLogs.split("\n");
+ const stepLogs: Map = new Map();
+
+ // Build time ranges for each step
+ const stepRanges = steps.map((step) => ({
+ number: step.number,
+ start: step.started_at ? new Date(step.started_at).getTime() : 0,
+ end: step.completed_at
+ ? new Date(step.completed_at).getTime()
+ : Number.POSITIVE_INFINITY,
+ }));
+
+ for (const line of logLines) {
+ const tsMatch = line.match(
+ /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s/,
+ );
+ if (!tsMatch) continue;
+ const lineTime = new Date(tsMatch[1]).getTime();
+ const lineContent = line.slice(tsMatch[0].length);
+
+ // Find which step this line belongs to
+ for (const range of stepRanges) {
+ if (lineTime >= range.start && lineTime <= range.end + 1000) {
+ if (!stepLogs.has(range.number)) {
+ stepLogs.set(range.number, []);
+ }
+ stepLogs.get(range.number)?.push(lineContent);
+ break;
+ }
+ }
+ }
+
+ return {
+ jobStatus: jobData.status,
+ jobConclusion: jobData.conclusion ?? null,
+ steps: steps.map((step) => {
+ let durationSeconds: number | null = null;
+ if (step.started_at && step.completed_at) {
+ durationSeconds = Math.round(
+ (new Date(step.completed_at).getTime() -
+ new Date(step.started_at).getTime()) /
+ 1000,
+ );
+ }
+ return {
+ name: step.name,
+ number: step.number,
+ status: step.status,
+ conclusion: step.conclusion ?? null,
+ durationSeconds,
+ logs: stepLogs.get(step.number)?.join("\n") ?? "",
+ };
+ }),
+ };
+ } catch (err) {
+ console.error("[fetchStructuredJobLogs] Failed:", err);
+ return emptyResult;
+ }
+}
+
+export interface JobStatusInfo {
+ detailsUrl: string;
+ status: "queued" | "in_progress" | "completed" | "waiting";
+ conclusion: string | null;
+}
+
+/**
+ * Fetches current status for multiple jobs in parallel.
+ */
+export async function fetchJobStatuses(
+ worktreePath: string,
+ detailsUrls: string[],
+): Promise {
+ const results = await Promise.allSettled(
+ detailsUrls.map(async (detailsUrl) => {
+ const jobId = parseJobIdFromUrl(detailsUrl);
+ const nwo = parseNwoFromActionsUrl(detailsUrl);
+ if (!jobId || !nwo) {
+ return { detailsUrl, status: "queued" as const, conclusion: null };
+ }
+ const { stdout } = await trackGitHubOperation({
+ name: "gh_api_actions_job_status",
+ category: "gh",
+ worktreePath,
+ fn: () =>
+ execWithShellEnv(
+ "gh",
+ [
+ "api",
+ `repos/${nwo}/actions/jobs/${jobId}`,
+ "--jq",
+ '.status + "|" + (.conclusion // "")',
+ ],
+ { cwd: worktreePath },
+ ),
+ });
+ const [status, conclusion] = stdout.trim().split("|");
+ return {
+ detailsUrl,
+ status: (status || "queued") as JobStatusInfo["status"],
+ conclusion: conclusion || null,
+ };
+ }),
+ );
+ return results.map((r, i) =>
+ r.status === "fulfilled"
+ ? r.value
+ : {
+ detailsUrl: detailsUrls[i],
+ status: "queued" as const,
+ conclusion: null,
+ },
+ );
+}
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts
index 37e7dca32a3..5d93d9df4da 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts
@@ -1,14 +1,24 @@
export type { PullRequestCommentsTarget } from "./github";
export {
+ addPullRequestConversationComment,
clearGitHubCachesForWorktree,
+ fetchCheckJobSteps,
fetchGitHubPRComments,
fetchGitHubPRStatus,
+ fetchGitHubPreviewUrl,
+ fetchJobStatuses,
+ fetchStructuredJobLogs,
+ replyToReviewThread,
resolveReviewThread,
} from "./github";
+export { isRateLimited } from "./github-rate-limiter";
+export { githubSyncService } from "./github-sync-service";
export { getPRForBranch } from "./pr-resolution";
export {
extractNwoFromUrl,
getPullRequestRepoArgs,
+ getPullRequestRepoNamesForWorktree,
getRepoContext,
+ getTrackingRepoUrl,
normalizeGitHubUrl,
} from "./repo-context";
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-attachment.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-attachment.ts
new file mode 100644
index 00000000000..11da86fa025
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-attachment.ts
@@ -0,0 +1,152 @@
+import type { GitHubStatus } from "@superset/local-db";
+import { normalizeGitHubUrl } from "./repo-context";
+
+type PullRequest = NonNullable;
+
+export interface GitRemoteInfo {
+ name: string;
+ fetchUrl?: string;
+ pushUrl?: string;
+}
+
+export interface GitTrackingRefInfo {
+ remoteName: string;
+ branchName: string;
+}
+
+export interface PullRequestPushTargetInfo {
+ remote: string;
+ targetBranch: string;
+}
+
+export function isOpenPullRequestState(state: PullRequest["state"]): boolean {
+ return state === "open" || state === "draft";
+}
+
+export function getPullRequestHeadRepoUrl(
+ pr: Pick<
+ PullRequest,
+ "headRepositoryOwner" | "headRepositoryName" | "isCrossRepository"
+ >,
+): string | null {
+ if (
+ !pr.isCrossRepository ||
+ !pr.headRepositoryOwner ||
+ !pr.headRepositoryName
+ ) {
+ return null;
+ }
+
+ return `https://github.com/${pr.headRepositoryOwner}/${pr.headRepositoryName}`;
+}
+
+export function resolveRemoteNameForPullRequestHead({
+ remotes,
+ pr,
+ fallbackRemote,
+}: {
+ remotes: GitRemoteInfo[];
+ pr: Pick<
+ PullRequest,
+ "headRepositoryOwner" | "headRepositoryName" | "isCrossRepository"
+ >;
+ fallbackRemote: string;
+}): string | null {
+ if (!pr.isCrossRepository) {
+ return fallbackRemote;
+ }
+
+ const headRepoUrl = getPullRequestHeadRepoUrl(pr);
+ if (!headRepoUrl) {
+ return null;
+ }
+
+ const normalizedHeadRepoUrl = normalizeGitHubUrl(headRepoUrl);
+ if (!normalizedHeadRepoUrl) {
+ return null;
+ }
+
+ for (const remote of remotes) {
+ const fetchUrl = remote.fetchUrl
+ ? normalizeGitHubUrl(remote.fetchUrl)
+ : null;
+ const pushUrl = remote.pushUrl ? normalizeGitHubUrl(remote.pushUrl) : null;
+ if (
+ fetchUrl === normalizedHeadRepoUrl ||
+ pushUrl === normalizedHeadRepoUrl
+ ) {
+ return remote.name;
+ }
+ }
+
+ return null;
+}
+
+export function resolveOpenPullRequestPushTarget({
+ pr,
+ remotes,
+ fallbackRemote,
+}: {
+ pr: Pick<
+ PullRequest,
+ | "headRefName"
+ | "headRepositoryOwner"
+ | "headRepositoryName"
+ | "isCrossRepository"
+ | "state"
+ >;
+ remotes: GitRemoteInfo[];
+ fallbackRemote: string;
+}): PullRequestPushTargetInfo | null {
+ if (!isOpenPullRequestState(pr.state)) {
+ return null;
+ }
+
+ const targetBranch = pr.headRefName?.trim();
+ if (!targetBranch) {
+ return null;
+ }
+
+ const remote = resolveRemoteNameForPullRequestHead({
+ remotes,
+ pr,
+ fallbackRemote,
+ });
+ if (!remote) {
+ return null;
+ }
+
+ return {
+ remote,
+ targetBranch,
+ };
+}
+
+export function canAttachPullRequestToWorkspace({
+ pr,
+ remotes,
+ fallbackRemote,
+}: {
+ pr: Pick<
+ PullRequest,
+ | "headRefName"
+ | "headRepositoryOwner"
+ | "headRepositoryName"
+ | "isCrossRepository"
+ | "state"
+ >;
+ remotes: GitRemoteInfo[];
+ fallbackRemote: string;
+}): boolean {
+ if (!isOpenPullRequestState(pr.state)) {
+ return true;
+ }
+
+ return (
+ resolveOpenPullRequestPushTarget({
+ pr,
+ remotes,
+ fallbackRemote,
+ }) !== null
+ );
+}
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 90f09953237..ac2809dd786 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,7 +1,17 @@
import type { CheckItem, GitHubStatus } from "@superset/local-db";
import { execGitWithShellPath } from "../git-client";
import { execWithShellEnv } from "../shell-env";
-import { getPullRequestRepoArgs } from "./repo-context";
+import {
+ clearCachedNoPullRequestMatch,
+ hasCachedNoPullRequestMatch,
+ makeGitHubNoPullRequestCacheKey,
+ setCachedNoPullRequestMatch,
+} from "./cache";
+import {
+ trackGitHubOperation,
+ trackGitHubOperationEvent,
+} from "./github-metrics";
+import { getPullRequestRepoNamesForWorktree } from "./repo-context";
import {
type GHPRResponse,
GHPRResponseSchema,
@@ -9,7 +19,20 @@ import {
} from "./types";
const PR_JSON_FIELDS =
- "number,title,url,state,isDraft,mergedAt,additions,deletions,headRefOid,headRefName,headRepository,headRepositoryOwner,isCrossRepository,reviewDecision,statusCheckRollup,reviewRequests";
+ "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;
+}
export async function getPRForBranch(
worktreePath: string,
@@ -17,26 +40,55 @@ 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,
- repoContext,
+ repoNames,
+ headSha,
+ );
+ if (byHeadBranch.pr) {
+ clearCachedNoPullRequestMatch(noPullRequestCacheKey);
+ return byHeadBranch.pr;
+ }
+
+ const byHeadCommit = await findPRByHeadCommit(
+ worktreePath,
+ repoNames,
headSha,
);
- if (byHeadBranch) {
- return byHeadBranch;
+ if (byHeadCommit.pr) {
+ clearCachedNoPullRequestMatch(noPullRequestCacheKey);
+ return byHeadCommit.pr;
}
- return findPRByHeadCommit(worktreePath, repoContext, headSha);
+ if (!byHeadBranch.hadLookupFailure && !byHeadCommit.hadLookupFailure) {
+ setCachedNoPullRequestMatch(noPullRequestCacheKey);
+ }
+ return null;
}
/**
@@ -169,12 +221,20 @@ 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) {
@@ -195,8 +255,23 @@ 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;
}
}
@@ -208,42 +283,73 @@ async function getPRByBranchTracking(
async function findPRByHeadBranch(
worktreePath: string,
localBranch: string,
- repoContext?: RepoContext,
+ repoNames: string[],
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 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);
+ 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 bestMatch ? formatPRData(bestMatch) : null;
+ return {
+ pr: bestMatch ? formatPRData(bestMatch) : null,
+ hadLookupFailure,
+ };
} catch {
- return null;
+ return {
+ pr: null,
+ hadLookupFailure: true,
+ };
}
}
@@ -253,9 +359,9 @@ async function findPRByHeadBranch(
*/
async function findPRByHeadCommit(
worktreePath: string,
- repoContext?: RepoContext,
+ repoNames: string[],
providedSha?: string,
-): Promise {
+): Promise {
try {
let headSha = providedSha;
if (!headSha) {
@@ -266,39 +372,70 @@ async function findPRByHeadCommit(
headSha = headOutput.trim();
}
if (!headSha) {
- return null;
+ return {
+ pr: null,
+ hadLookupFailure: false,
+ };
}
- const { stdout } = await execWithShellEnv(
- "gh",
- [
- "pr",
- "list",
- ...getPullRequestRepoArgs(repoContext),
- "--state",
- "all",
- "--search",
- `${headSha} is:pr`,
- "--limit",
- "20",
- "--json",
- PR_JSON_FIELDS,
- ],
- { cwd: worktreePath },
- );
+ 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 candidates = parsePRListResponse(stdout);
- const exactHeadMatches = candidates.filter(
- (candidate) => candidate.headRefOid === headSha,
- );
- const bestMatch = sortPRCandidates(exactHeadMatches, headSha)[0];
- if (bestMatch) {
- return formatPRData(bestMatch);
+ const candidates = parsePRListResponse(stdout);
+ exactHeadMatches.push(
+ ...candidates.filter((candidate) => candidate.headRefOid === headSha),
+ );
}
- return null;
+ const bestMatch = sortPRCandidates(exactHeadMatches, headSha)[0];
+ return {
+ pr: bestMatch ? formatPRData(bestMatch) : null,
+ hadLookupFailure,
+ };
} catch {
- return null;
+ return {
+ pr: null,
+ hadLookupFailure: true,
+ };
}
}
@@ -375,6 +512,7 @@ function formatPRData(data: GHPRResponse): NonNullable {
checksStatus: computeChecksStatus(data.statusCheckRollup),
checks: parseChecks(data.statusCheckRollup),
requestedReviewers: parseReviewRequests(data.reviewRequests),
+ assignees: parseAssignees(data.assignees),
};
}
@@ -385,6 +523,11 @@ 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/lib/trpc/routers/workspaces/utils/github/repo-context.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/repo-context.ts
index 6091754b7ff..9d345174615 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/repo-context.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/repo-context.ts
@@ -1,5 +1,6 @@
import { execGitWithShellPath } from "../git-client";
import { execWithShellEnv } from "../shell-env";
+import { parseUpstreamRef } from "../upstream-ref";
import { getCachedRepoContextState, readCachedRepoContext } from "./cache";
import { GHRepoResponseSchema, type RepoContext } from "./types";
@@ -21,21 +22,23 @@ async function refreshRepoContext(
}
const data = result.data;
- let context: RepoContext;
+ let context: RepoContext | undefined;
if (data.isFork && data.parent) {
- context = {
- repoUrl: data.url,
- upstreamUrl: data.parent.url,
- isFork: true,
- };
- } else {
- const originUrl = await getOriginUrl(worktreePath);
- const ghUrl = normalizeGitHubUrl(data.url);
+ const upstreamUrl =
+ data.parent.url ??
+ (data.parent.owner?.login && data.parent.name
+ ? `https://github.com/${data.parent.owner.login}/${data.parent.name}`
+ : null);
- if (data.isFork) {
- return null;
+ if (upstreamUrl) {
+ context = { repoUrl: data.url, upstreamUrl, isFork: true };
}
+ }
+
+ if (!context) {
+ const originUrl = await getOriginUrl(worktreePath);
+ const ghUrl = normalizeGitHubUrl(data.url);
if (originUrl && ghUrl && originUrl !== ghUrl) {
context = {
@@ -43,6 +46,14 @@ async function refreshRepoContext(
upstreamUrl: ghUrl,
isFork: true,
};
+ } else if (data.isFork) {
+ // Fork but upstream URL could not be determined — surface as error
+ // rather than silently treating as non-fork (which would misdirect PRs)
+ console.warn(
+ "[GitHub] Fork detected but upstream URL could not be resolved",
+ { url: data.url },
+ );
+ return null;
} else {
context = {
repoUrl: data.url,
@@ -92,7 +103,7 @@ export function shouldRefreshCachedRepoContext({
cachedRepoContext: RepoContext | null;
}): boolean {
if (!cachedRepoContext) {
- return false;
+ return true;
}
const normalizedOriginUrl = normalizeGitHubUrl(
@@ -110,9 +121,20 @@ export function shouldRefreshCachedRepoContext({
}
async function getOriginUrl(worktreePath: string): Promise {
+ try {
+ return getRemoteUrl(worktreePath, "origin");
+ } catch {
+ return null;
+ }
+}
+
+async function getRemoteUrl(
+ worktreePath: string,
+ remoteName: string,
+): Promise {
try {
const { stdout } = await execGitWithShellPath(
- ["remote", "get-url", "origin"],
+ ["remote", "get-url", remoteName],
{ cwd: worktreePath },
);
return normalizeGitHubUrl(stdout.trim());
@@ -139,13 +161,86 @@ export function normalizeGitHubUrl(remoteUrl: string): string | null {
export function extractNwoFromUrl(normalizedUrl: string): string | null {
try {
- const path = new URL(normalizedUrl).pathname.slice(1);
- return path || null;
+ const segments = new URL(normalizedUrl).pathname.split("/").filter(Boolean);
+ if (segments.length < 2) {
+ return null;
+ }
+ return `${segments[0]}/${segments[1]}`;
} catch {
return null;
}
}
+export function getPullRequestRepoNames(
+ repoContext?: Pick | null,
+): string[] {
+ if (!repoContext) {
+ return [];
+ }
+
+ const candidates = [
+ repoContext.repoUrl,
+ repoContext.isFork ? repoContext.upstreamUrl : null,
+ ];
+
+ return Array.from(
+ new Set(
+ candidates
+ .map((candidate) => normalizeGitHubUrl(candidate ?? ""))
+ .filter((candidate): candidate is string => Boolean(candidate))
+ .map((candidate) => extractNwoFromUrl(candidate))
+ .filter((candidate): candidate is string => Boolean(candidate)),
+ ),
+ );
+}
+
+export async function getTrackingRepoUrl(
+ worktreePath: string,
+): Promise